dsh-ds-balance 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/README_en.md +81 -0
- package/cordis.patch.yml +9 -0
- package/lib/adapters/console-logger.d.ts +25 -0
- package/lib/adapters/console-logger.js +35 -0
- package/lib/adapters/domain-core-store.d.ts +104 -0
- package/lib/adapters/domain-core-store.js +184 -0
- package/lib/adapters/http-deepseek-client.d.ts +36 -0
- package/lib/adapters/http-deepseek-client.js +101 -0
- package/lib/adapters/memory-metrics.d.ts +30 -0
- package/lib/adapters/memory-metrics.js +67 -0
- package/lib/adapters/salt-file.d.ts +32 -0
- package/lib/adapters/salt-file.js +40 -0
- package/lib/client.js +2120 -0
- package/lib/config.d.ts +127 -0
- package/lib/config.js +112 -0
- package/lib/domain/balance.d.ts +70 -0
- package/lib/domain/balance.js +8 -0
- package/lib/domain/errors.d.ts +84 -0
- package/lib/domain/errors.js +136 -0
- package/lib/domain/money.d.ts +39 -0
- package/lib/domain/money.js +74 -0
- package/lib/domain/normalize.d.ts +38 -0
- package/lib/domain/normalize.js +120 -0
- package/lib/domain/select.d.ts +26 -0
- package/lib/domain/select.js +51 -0
- package/lib/domain/severity.d.ts +34 -0
- package/lib/domain/severity.js +44 -0
- package/lib/http/handlers.d.ts +47 -0
- package/lib/http/handlers.js +261 -0
- package/lib/http/routes.d.ts +63 -0
- package/lib/http/routes.js +61 -0
- package/lib/http/wire.d.ts +82 -0
- package/lib/http/wire.js +68 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +210 -0
- package/lib/ports/clock.d.ts +12 -0
- package/lib/ports/clock.js +6 -0
- package/lib/ports/core-store.d.ts +28 -0
- package/lib/ports/core-store.js +6 -0
- package/lib/ports/credentials.d.ts +30 -0
- package/lib/ports/credentials.js +9 -0
- package/lib/ports/deepseek-client.d.ts +47 -0
- package/lib/ports/deepseek-client.js +7 -0
- package/lib/ports/logger.d.ts +12 -0
- package/lib/ports/logger.js +6 -0
- package/lib/ports/metrics.d.ts +36 -0
- package/lib/ports/metrics.js +11 -0
- package/lib/services/account-tag.d.ts +24 -0
- package/lib/services/account-tag.js +29 -0
- package/lib/services/balance-service.d.ts +121 -0
- package/lib/services/balance-service.js +220 -0
- package/lib/services/config-service.d.ts +52 -0
- package/lib/services/config-service.js +51 -0
- package/lib/services/key-resolver.d.ts +42 -0
- package/lib/services/key-resolver.js +62 -0
- package/lib/services/scheduler.d.ts +93 -0
- package/lib/services/scheduler.js +142 -0
- package/lib/types/client/api-types.d.ts +54 -0
- package/lib/types/client/data.d.ts +103 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/locales.d.ts +98 -0
- package/lib/types/client/mock/index.d.ts +36 -0
- package/lib/types/client/mock/scenarios.d.ts +51 -0
- package/lib/types/client/model.d.ts +93 -0
- package/lib/types/client/settings/BalanceSettingsCard.d.ts +22 -0
- package/lib/types/client/settings/fields.d.ts +191 -0
- package/lib/types/client/settings/use-config-form.d.ts +212 -0
- package/lib/types/client/settings/use-credential-state.d.ts +36 -0
- package/lib/types/client/sidebar/BalancePopover.d.ts +46 -0
- package/lib/types/client/sidebar/PercentRing.d.ts +36 -0
- package/lib/types/client/sidebar/SidebarBalance.d.ts +44 -0
- package/lib/version.d.ts +12 -0
- package/lib/version.js +12 -0
- package/package.json +112 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用 `ctx.connection.fetch` 注册全部 HTTP 端点。
|
|
3
|
+
*
|
|
4
|
+
* 三层职责分得很开:本文件只管**注册**,`handlers.ts` 管**怎么做**,
|
|
5
|
+
* `wire.ts` 管**长什么样**。注册需要 `ctx`,所以只有本文件碰宿主。
|
|
6
|
+
*
|
|
7
|
+
* 契约要点(docs/backend-architecture.md §3.3 / §8.2):
|
|
8
|
+
* - `path` 写死精确值、不带尾随斜杠(实现是 Map 精确键匹配);
|
|
9
|
+
* - `requestBody` 必填;
|
|
10
|
+
* - 物理载体**已先做完信任与浏览器鉴权**,handler 不必再判一次。
|
|
11
|
+
* @module dsh-ds-balance/http/routes
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
+
import { handleBalance, handleHealthz, handleRefresh, handleTestConnection, type HttpDeps } from './handlers.js';
|
|
15
|
+
/**
|
|
16
|
+
* `/api/v1/config` 的方法分发。
|
|
17
|
+
*
|
|
18
|
+
* **写操作走 POST 而不是 PUT**:`ConnectionFetchMethod` 只有 `GET` / `HEAD` / `POST`
|
|
19
|
+
* 三档,PUT 注册即被拒。契约文档写的是 PUT,以平台实际能力为准。
|
|
20
|
+
* @param request - 已通过信任与鉴权的请求。
|
|
21
|
+
* @param deps - 注入的依赖。
|
|
22
|
+
* @returns 读或写的响应。
|
|
23
|
+
*/
|
|
24
|
+
declare function handleConfigRoute(request: Request, deps: HttpDeps): Promise<Response>;
|
|
25
|
+
/**
|
|
26
|
+
* 路由表。
|
|
27
|
+
*
|
|
28
|
+
* 同一 `path` 只能注册一次,方法在表里合并 —— 实现按 pathname 精确取一条,
|
|
29
|
+
* 再看方法集合是否命中。`readonly` 与 `as const` 是为了让字面量类型活下来。
|
|
30
|
+
*/
|
|
31
|
+
export declare const HTTP_ROUTES: readonly [{
|
|
32
|
+
readonly path: "/api/v1/balance";
|
|
33
|
+
readonly methods: readonly ["GET"];
|
|
34
|
+
readonly handle: typeof handleBalance;
|
|
35
|
+
}, {
|
|
36
|
+
readonly path: "/api/v1/balance/refresh";
|
|
37
|
+
readonly methods: readonly ["POST"];
|
|
38
|
+
readonly handle: typeof handleRefresh;
|
|
39
|
+
}, {
|
|
40
|
+
readonly path: "/api/v1/config";
|
|
41
|
+
readonly methods: readonly ["GET", "POST"];
|
|
42
|
+
readonly handle: typeof handleConfigRoute;
|
|
43
|
+
}, {
|
|
44
|
+
readonly path: "/api/v1/test-connection";
|
|
45
|
+
readonly methods: readonly ["POST"];
|
|
46
|
+
readonly handle: typeof handleTestConnection;
|
|
47
|
+
}, {
|
|
48
|
+
readonly path: "/api/v1/healthz";
|
|
49
|
+
readonly methods: readonly ["GET"];
|
|
50
|
+
readonly handle: typeof handleHealthz;
|
|
51
|
+
}];
|
|
52
|
+
/**
|
|
53
|
+
* 注册全部端点。
|
|
54
|
+
*
|
|
55
|
+
* 每个 `register` 都把 effect 挂在 `ctx` 上,所以随 fiber 卸载一起撤销;
|
|
56
|
+
* 返回的合成 disposer 收拢这五条,供组装点放到生命周期结尾。
|
|
57
|
+
* @param ctx - 宿主上下文;**必须已经能取到 `connection`**。
|
|
58
|
+
* @param deps - 注入的依赖。
|
|
59
|
+
* @returns 撤销全部路由的异步 disposer。
|
|
60
|
+
*/
|
|
61
|
+
export declare function registerHttpRoutes(ctx: Context, deps: HttpDeps): () => Promise<void>;
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用 `ctx.connection.fetch` 注册全部 HTTP 端点。
|
|
3
|
+
*
|
|
4
|
+
* 三层职责分得很开:本文件只管**注册**,`handlers.ts` 管**怎么做**,
|
|
5
|
+
* `wire.ts` 管**长什么样**。注册需要 `ctx`,所以只有本文件碰宿主。
|
|
6
|
+
*
|
|
7
|
+
* 契约要点(docs/backend-architecture.md §3.3 / §8.2):
|
|
8
|
+
* - `path` 写死精确值、不带尾随斜杠(实现是 Map 精确键匹配);
|
|
9
|
+
* - `requestBody` 必填;
|
|
10
|
+
* - 物理载体**已先做完信任与浏览器鉴权**,handler 不必再判一次。
|
|
11
|
+
* @module dsh-ds-balance/http/routes
|
|
12
|
+
*/
|
|
13
|
+
import { handleBalance, handleConfigGet, handleConfigUpdate, handleHealthz, handleRefresh, handleTestConnection, } from './handlers.js';
|
|
14
|
+
/**
|
|
15
|
+
* `/api/v1/config` 的方法分发。
|
|
16
|
+
*
|
|
17
|
+
* **写操作走 POST 而不是 PUT**:`ConnectionFetchMethod` 只有 `GET` / `HEAD` / `POST`
|
|
18
|
+
* 三档,PUT 注册即被拒。契约文档写的是 PUT,以平台实际能力为准。
|
|
19
|
+
* @param request - 已通过信任与鉴权的请求。
|
|
20
|
+
* @param deps - 注入的依赖。
|
|
21
|
+
* @returns 读或写的响应。
|
|
22
|
+
*/
|
|
23
|
+
async function handleConfigRoute(request, deps) {
|
|
24
|
+
return request.method === 'GET'
|
|
25
|
+
? handleConfigGet(request, deps)
|
|
26
|
+
: handleConfigUpdate(request, deps);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 路由表。
|
|
30
|
+
*
|
|
31
|
+
* 同一 `path` 只能注册一次,方法在表里合并 —— 实现按 pathname 精确取一条,
|
|
32
|
+
* 再看方法集合是否命中。`readonly` 与 `as const` 是为了让字面量类型活下来。
|
|
33
|
+
*/
|
|
34
|
+
export const HTTP_ROUTES = [
|
|
35
|
+
{ path: '/api/v1/balance', methods: ['GET'], handle: handleBalance },
|
|
36
|
+
{ path: '/api/v1/balance/refresh', methods: ['POST'], handle: handleRefresh },
|
|
37
|
+
{ path: '/api/v1/config', methods: ['GET', 'POST'], handle: handleConfigRoute },
|
|
38
|
+
{ path: '/api/v1/test-connection', methods: ['POST'], handle: handleTestConnection },
|
|
39
|
+
{ path: '/api/v1/healthz', methods: ['GET'], handle: handleHealthz },
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* 注册全部端点。
|
|
43
|
+
*
|
|
44
|
+
* 每个 `register` 都把 effect 挂在 `ctx` 上,所以随 fiber 卸载一起撤销;
|
|
45
|
+
* 返回的合成 disposer 收拢这五条,供组装点放到生命周期结尾。
|
|
46
|
+
* @param ctx - 宿主上下文;**必须已经能取到 `connection`**。
|
|
47
|
+
* @param deps - 注入的依赖。
|
|
48
|
+
* @returns 撤销全部路由的异步 disposer。
|
|
49
|
+
*/
|
|
50
|
+
export function registerHttpRoutes(ctx, deps) {
|
|
51
|
+
const disposers = HTTP_ROUTES.map((route) => ctx.connection.fetch.register({
|
|
52
|
+
path: route.path,
|
|
53
|
+
methods: route.methods,
|
|
54
|
+
requestBody: 'buffered',
|
|
55
|
+
fetch: (request) => route.handle(request, deps),
|
|
56
|
+
}));
|
|
57
|
+
return async () => {
|
|
58
|
+
await Promise.all(disposers.map((dispose) => dispose()));
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 线上形状与序列化。
|
|
3
|
+
*
|
|
4
|
+
* 契约见 docs/backend-architecture.md §8:**金额一律八位小数字符串**,
|
|
5
|
+
* `bigint` 最小单位只活在进程内。本模块是纯函数,不碰 `ctx`、不发请求。
|
|
6
|
+
* @module dsh-ds-balance/http/wire
|
|
7
|
+
*/
|
|
8
|
+
import type { BalanceView, CacheState, Currency, Severity } from '../domain/balance.js';
|
|
9
|
+
import type { ErrorInfo } from '../domain/errors.js';
|
|
10
|
+
/** 一个币种的余额,金额是八位小数字符串。 */
|
|
11
|
+
export interface WireBalance {
|
|
12
|
+
currency: Currency;
|
|
13
|
+
total: string;
|
|
14
|
+
granted: string;
|
|
15
|
+
toppedUp: string;
|
|
16
|
+
}
|
|
17
|
+
/** 一个币种的阈值,金额同样是字符串。 */
|
|
18
|
+
export interface WireThresholdPair {
|
|
19
|
+
warn: string;
|
|
20
|
+
critical: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 线上的错误结构。
|
|
24
|
+
*
|
|
25
|
+
* `details` **不出去**:它可能带上游原文,属于内部诊断面。
|
|
26
|
+
*/
|
|
27
|
+
export interface WireError {
|
|
28
|
+
code: string;
|
|
29
|
+
message: string;
|
|
30
|
+
retryable: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 凭据的只读描述。
|
|
34
|
+
*
|
|
35
|
+
* 形状逐字对齐官方 `credentialProvider.describe()`:**只有三个事实,没有装值的槽**。
|
|
36
|
+
* 界面靠它决定凭据字段是「可编辑」还是「由启动环境提供」。
|
|
37
|
+
*/
|
|
38
|
+
export interface WireCredentialInfo {
|
|
39
|
+
ref: string;
|
|
40
|
+
configured: boolean;
|
|
41
|
+
source: string | null;
|
|
42
|
+
writable: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** `GET /api/v1/balance` 的响应体。 */
|
|
45
|
+
export interface WireBalanceResponse {
|
|
46
|
+
requestId: string;
|
|
47
|
+
schemaVersion: number;
|
|
48
|
+
state: CacheState;
|
|
49
|
+
stale: boolean;
|
|
50
|
+
fetchedAt: number;
|
|
51
|
+
ageMs: number;
|
|
52
|
+
isAvailable: boolean;
|
|
53
|
+
accountTag8: string;
|
|
54
|
+
balances: WireBalance[];
|
|
55
|
+
selected: {
|
|
56
|
+
currency: Currency;
|
|
57
|
+
total: string;
|
|
58
|
+
} | null;
|
|
59
|
+
severity: Severity;
|
|
60
|
+
thresholds: Record<Currency, WireThresholdPair>;
|
|
61
|
+
/** 第一版不做「今日已用」;字段保留以维持契约完整。 */
|
|
62
|
+
todayUsage: null;
|
|
63
|
+
error: WireError | null;
|
|
64
|
+
}
|
|
65
|
+
/** 生成一个请求标识。只用于把响应与前端的日志对上,不含任何账户信息。 */
|
|
66
|
+
export declare function newRequestId(): string;
|
|
67
|
+
/** 把错误折成线上形状。 */
|
|
68
|
+
export declare function toWireError(info: ErrorInfo): WireError;
|
|
69
|
+
/**
|
|
70
|
+
* 把领域视图折成线上响应。
|
|
71
|
+
*
|
|
72
|
+
* 两处刻意的兜底,都是为了让响应仍能通过前端的字段类型:
|
|
73
|
+
* - 没有快照时 `fetchedAt` / `ageMs` 记 0(前端把 0 读成「刚刚」,
|
|
74
|
+
* 与 mock 在空态 / 错误态给的取值一致);
|
|
75
|
+
* - 没有快照时 `isAvailable` 记 `false`。
|
|
76
|
+
* @param view - 服务层给出的视图。
|
|
77
|
+
* @param accountTag8 - 当前账本标识前 8 位;没有快照时传空串。
|
|
78
|
+
* @param requestId - 本次请求的标识。
|
|
79
|
+
* @returns 契约 §8.3 的响应体。
|
|
80
|
+
*/
|
|
81
|
+
export declare function toWireBalanceView(view: BalanceView, accountTag8: string, requestId: string): WireBalanceResponse;
|
|
82
|
+
//# sourceMappingURL=wire.d.ts.map
|
package/lib/http/wire.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 线上形状与序列化。
|
|
3
|
+
*
|
|
4
|
+
* 契约见 docs/backend-architecture.md §8:**金额一律八位小数字符串**,
|
|
5
|
+
* `bigint` 最小单位只活在进程内。本模块是纯函数,不碰 `ctx`、不发请求。
|
|
6
|
+
* @module dsh-ds-balance/http/wire
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { formatMoney } from '../domain/money.js';
|
|
10
|
+
import { SCHEMA_VERSION } from '../version.js';
|
|
11
|
+
/** 生成一个请求标识。只用于把响应与前端的日志对上,不含任何账户信息。 */
|
|
12
|
+
export function newRequestId() {
|
|
13
|
+
return `req_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
|
|
14
|
+
}
|
|
15
|
+
/** 把错误折成线上形状。 */
|
|
16
|
+
export function toWireError(info) {
|
|
17
|
+
return { code: info.code, message: info.message, retryable: info.retryable };
|
|
18
|
+
}
|
|
19
|
+
/** 把 `bigint` 最小单位折成八位小数字符串。 */
|
|
20
|
+
function amount(units) {
|
|
21
|
+
return formatMoney(units);
|
|
22
|
+
}
|
|
23
|
+
/** 把一个币种的阈值折成线上形状。 */
|
|
24
|
+
function thresholdPair(pair) {
|
|
25
|
+
return { warn: amount(pair.warn), critical: amount(pair.critical) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 把领域视图折成线上响应。
|
|
29
|
+
*
|
|
30
|
+
* 两处刻意的兜底,都是为了让响应仍能通过前端的字段类型:
|
|
31
|
+
* - 没有快照时 `fetchedAt` / `ageMs` 记 0(前端把 0 读成「刚刚」,
|
|
32
|
+
* 与 mock 在空态 / 错误态给的取值一致);
|
|
33
|
+
* - 没有快照时 `isAvailable` 记 `false`。
|
|
34
|
+
* @param view - 服务层给出的视图。
|
|
35
|
+
* @param accountTag8 - 当前账本标识前 8 位;没有快照时传空串。
|
|
36
|
+
* @param requestId - 本次请求的标识。
|
|
37
|
+
* @returns 契约 §8.3 的响应体。
|
|
38
|
+
*/
|
|
39
|
+
export function toWireBalanceView(view, accountTag8, requestId) {
|
|
40
|
+
const thresholds = {};
|
|
41
|
+
for (const [currency, pair] of Object.entries(view.thresholds)) {
|
|
42
|
+
thresholds[currency] = thresholdPair(pair);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
requestId,
|
|
46
|
+
schemaVersion: SCHEMA_VERSION,
|
|
47
|
+
state: view.state,
|
|
48
|
+
stale: view.stale,
|
|
49
|
+
fetchedAt: view.fetchedAt ?? 0,
|
|
50
|
+
ageMs: view.ageMs ?? 0,
|
|
51
|
+
isAvailable: view.isAvailable ?? false,
|
|
52
|
+
accountTag8,
|
|
53
|
+
balances: view.balances.map((item) => ({
|
|
54
|
+
currency: item.currency,
|
|
55
|
+
total: amount(item.total),
|
|
56
|
+
granted: amount(item.granted),
|
|
57
|
+
toppedUp: amount(item.toppedUp),
|
|
58
|
+
})),
|
|
59
|
+
selected: view.selected === null
|
|
60
|
+
? null
|
|
61
|
+
: { currency: view.selected.currency, total: amount(view.selected.total) },
|
|
62
|
+
severity: view.severity,
|
|
63
|
+
thresholds,
|
|
64
|
+
todayUsage: null,
|
|
65
|
+
error: view.error === null ? null : toWireError(view.error),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=wire.js.map
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半边入口。
|
|
3
|
+
*
|
|
4
|
+
* 组装点(Layer 5):登记设置命名空间,把端口实现接上,交出生命周期。
|
|
5
|
+
* **业务逻辑一律不在本文件** —— 它只做「谁实现哪个端口」这一个决定。
|
|
6
|
+
*
|
|
7
|
+
* 注意:宿主半边**没有模块热更**,改了这里要重建并让插件行重新挂载才生效。
|
|
8
|
+
* @module dsh-ds-balance
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import { type Config as ConfigShape } from './config.js';
|
|
12
|
+
export { Config, SETTINGS_NAMESPACE } from './config.js';
|
|
13
|
+
export { CURRENCY_AUTO } from './config.js';
|
|
14
|
+
/** 插件名,loader 诊断用。 */
|
|
15
|
+
export declare const name = "ds-balance";
|
|
16
|
+
/**
|
|
17
|
+
* 运行时服务门禁。**删任何一项都会让 `apply` 静默不跑。**
|
|
18
|
+
*
|
|
19
|
+
* - `settings`:登记配置命名空间。
|
|
20
|
+
* - `credentials`:复用官方模型页配好的 DeepSeek 凭据(阶段 0 已实测命中)。
|
|
21
|
+
*
|
|
22
|
+
* `connection` **不列在这里**:缺了它只该丢掉 HTTP 半边,不该让设置与调度一起消失。
|
|
23
|
+
* 它由 `apply` 内部的 `ctx.inject` 单独把门。
|
|
24
|
+
*/
|
|
25
|
+
export declare const inject: string[];
|
|
26
|
+
/** 服务端盐的文件名。与契约 §6.1 / §10.5 一致,落在 DSH home 下。 */
|
|
27
|
+
export declare const SALT_FILE_NAME = ".salt";
|
|
28
|
+
/**
|
|
29
|
+
* 组装并交出生命周期。
|
|
30
|
+
*
|
|
31
|
+
* 异步是因为盐要从磁盘读;读失败会降级,不阻断挂载。
|
|
32
|
+
* @param ctx - 宿主上下文。
|
|
33
|
+
* @param config - 组合层给的配置基线。
|
|
34
|
+
*/
|
|
35
|
+
export declare function apply(ctx: Context, config: ConfigShape): Promise<void>;
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半边入口。
|
|
3
|
+
*
|
|
4
|
+
* 组装点(Layer 5):登记设置命名空间,把端口实现接上,交出生命周期。
|
|
5
|
+
* **业务逻辑一律不在本文件** —— 它只做「谁实现哪个端口」这一个决定。
|
|
6
|
+
*
|
|
7
|
+
* 注意:宿主半边**没有模块热更**,改了这里要重建并让插件行重新挂载才生效。
|
|
8
|
+
* @module dsh-ds-balance
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
12
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
13
|
+
import { Config, SETTINGS_NAMESPACE, validateThresholds } from './config.js';
|
|
14
|
+
import { DomainCoreStore } from './adapters/domain-core-store.js';
|
|
15
|
+
import { HttpDeepSeekClient } from './adapters/http-deepseek-client.js';
|
|
16
|
+
import { SALT_BYTES, SALT_ENCODING, loadOrCreateSalt } from './adapters/salt-file.js';
|
|
17
|
+
import { createConsoleLogger } from './adapters/console-logger.js';
|
|
18
|
+
import { MemoryMetrics } from './adapters/memory-metrics.js';
|
|
19
|
+
import { registerHttpRoutes } from './http/routes.js';
|
|
20
|
+
import { BalanceService } from './services/balance-service.js';
|
|
21
|
+
import { ConfigService } from './services/config-service.js';
|
|
22
|
+
import { KeyResolver } from './services/key-resolver.js';
|
|
23
|
+
import { Scheduler } from './services/scheduler.js';
|
|
24
|
+
export { Config, SETTINGS_NAMESPACE } from './config.js';
|
|
25
|
+
export { CURRENCY_AUTO } from './config.js';
|
|
26
|
+
/** 插件名,loader 诊断用。 */
|
|
27
|
+
export const name = 'ds-balance';
|
|
28
|
+
/**
|
|
29
|
+
* 运行时服务门禁。**删任何一项都会让 `apply` 静默不跑。**
|
|
30
|
+
*
|
|
31
|
+
* - `settings`:登记配置命名空间。
|
|
32
|
+
* - `credentials`:复用官方模型页配好的 DeepSeek 凭据(阶段 0 已实测命中)。
|
|
33
|
+
*
|
|
34
|
+
* `connection` **不列在这里**:缺了它只该丢掉 HTTP 半边,不该让设置与调度一起消失。
|
|
35
|
+
* 它由 `apply` 内部的 `ctx.inject` 单独把门。
|
|
36
|
+
*/
|
|
37
|
+
export const inject = ['settings', 'credentials'];
|
|
38
|
+
/** 服务端盐的文件名。与契约 §6.1 / §10.5 一致,落在 DSH home 下。 */
|
|
39
|
+
export const SALT_FILE_NAME = '.salt';
|
|
40
|
+
/**
|
|
41
|
+
* 这些字段变了才需要重排调度。
|
|
42
|
+
*
|
|
43
|
+
* **阈值与展示币种不在里面**:它们不改变上游取数节奏,而 severity 是后端按缓存快照
|
|
44
|
+
* 现算的。把它们也算进去,用户改一次阈值就会顺带打一次官方接口 ——
|
|
45
|
+
* 界面只要重新读一次缓存就够了。
|
|
46
|
+
*/
|
|
47
|
+
const SCHEDULE_FIELDS = ['serverRefreshSeconds', 'baseUrl', 'apiKey', 'apiKeyRef'];
|
|
48
|
+
/**
|
|
49
|
+
* 这次配置变更要不要重排调度。
|
|
50
|
+
* @param previous - 变更前的配置。
|
|
51
|
+
* @param next - 变更后的配置。
|
|
52
|
+
* @returns 需要重排时为真。
|
|
53
|
+
*/
|
|
54
|
+
function affectsSchedule(previous, next) {
|
|
55
|
+
return SCHEDULE_FIELDS.some((field) => previous[field] !== next[field]);
|
|
56
|
+
}
|
|
57
|
+
/** 系统时钟。 */
|
|
58
|
+
const systemClock = {
|
|
59
|
+
now: () => Date.now(),
|
|
60
|
+
timezone: () => 'Asia/Shanghai',
|
|
61
|
+
};
|
|
62
|
+
/** 把未知异常压成一行。 */
|
|
63
|
+
function describe(error) {
|
|
64
|
+
return error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 读服务端盐;读不出来就退回一份进程内临时盐。
|
|
68
|
+
*
|
|
69
|
+
* **临时盐意味着重启后 `accountTag` 全变、旧快照读不回来** —— 降级但不致命,
|
|
70
|
+
* 所以只记 error 不抛。契约 §19 第 5 条把这件事的后果写进了文档。
|
|
71
|
+
* @param logger - 日志端口。
|
|
72
|
+
* @returns 十六进制盐。
|
|
73
|
+
*/
|
|
74
|
+
async function resolveSalt(logger) {
|
|
75
|
+
try {
|
|
76
|
+
return await loadOrCreateSalt({ path: dshHomePath(SALT_FILE_NAME) });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.error('ds-balance: salt file unavailable, using an ephemeral salt', { error: describe(error) });
|
|
80
|
+
return randomBytes(SALT_BYTES).toString(SALT_ENCODING);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** 把设置作用域包成配置端口。**每次现读**,不在构造期缓存。 */
|
|
84
|
+
function adaptSettingsScope(scope) {
|
|
85
|
+
return {
|
|
86
|
+
get: () => scope.get(),
|
|
87
|
+
watch: (listener) => scope.watch(listener),
|
|
88
|
+
update: (patch) => scope.update(patch),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 把官方凭据服务收窄成 {@link Credentials}。
|
|
93
|
+
*
|
|
94
|
+
* 真实签名要 `CredentialRef`(带 brand 的字符串),端口只认普通 `string`;
|
|
95
|
+
* 这里用 `credentialRef()` 补上 brand。引用名形状已由 KeyResolver 先校验过。
|
|
96
|
+
* @param ctx - 宿主上下文。
|
|
97
|
+
* @returns 端口实现;服务缺席时返回 `undefined`。
|
|
98
|
+
*/
|
|
99
|
+
function credentialsPort(ctx) {
|
|
100
|
+
let service;
|
|
101
|
+
try {
|
|
102
|
+
service = ctx.credentials;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (service === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
async resolve(ref) {
|
|
111
|
+
const resolved = await service.resolve(credentialRef(ref));
|
|
112
|
+
// 未配置时官方回 `undefined`;端口的约定是「值可能为空串」,交给上游判空。
|
|
113
|
+
return resolved === undefined ? { value: '', source: 'unset' } : { value: resolved.value, source: resolved.source };
|
|
114
|
+
},
|
|
115
|
+
describe: (ref) => service.describe(credentialRef(ref)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 接上 `storageDomain` 并交出打开域的函数。
|
|
120
|
+
*
|
|
121
|
+
* 它是**可选服务**,而 cordis 不许读没 `inject` 过的服务(直接访问会抛
|
|
122
|
+
* 「cannot get property ... without inject」),所以必须由 `ctx.inject` 把门;
|
|
123
|
+
* 服务缺席时句柄保持 `undefined`,存储层据此降级,插件其余部分照常跑。
|
|
124
|
+
*
|
|
125
|
+
* 官方 `Domain<S>.table()` 的键类型由 spec 推导,而 {@link DomainLike} 只声明
|
|
126
|
+
* 「按字符串取表」这一件事;运行时行为一致,所以在这里收窄一次,
|
|
127
|
+
* 而不是把官方泛型签名抄进端口。
|
|
128
|
+
* @param ctx - 宿主上下文。
|
|
129
|
+
* @returns 打开域的函数;服务缺席时抛错,由存储适配器吸收成降级。
|
|
130
|
+
*/
|
|
131
|
+
function connectStorage(ctx) {
|
|
132
|
+
const handle = {};
|
|
133
|
+
ctx.inject(['storageDomain'], (storageCtx) => {
|
|
134
|
+
storageCtx.effect(() => {
|
|
135
|
+
handle.facility = storageCtx.storageDomain;
|
|
136
|
+
return () => { handle.facility = undefined; };
|
|
137
|
+
}, 'ds-balance: storage facility');
|
|
138
|
+
});
|
|
139
|
+
return async (spec) => {
|
|
140
|
+
const facility = handle.facility;
|
|
141
|
+
if (facility === undefined)
|
|
142
|
+
throw new Error('storageDomain service is unavailable');
|
|
143
|
+
return await facility.open(spec);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 组装并交出生命周期。
|
|
148
|
+
*
|
|
149
|
+
* 异步是因为盐要从磁盘读;读失败会降级,不阻断挂载。
|
|
150
|
+
* @param ctx - 宿主上下文。
|
|
151
|
+
* @param config - 组合层给的配置基线。
|
|
152
|
+
*/
|
|
153
|
+
export async function apply(ctx, config) {
|
|
154
|
+
const logger = createConsoleLogger();
|
|
155
|
+
const salt = await resolveSalt(logger);
|
|
156
|
+
// validate 是跨字段约束的落点:schemastery 表达不了「critical < warn」,
|
|
157
|
+
// 而它拿到的是合并后的完整候选值,抛错即拒绝写入。见 config.ts 的 validateThresholds。
|
|
158
|
+
const scope = ctx.settings.register(SETTINGS_NAMESPACE, Config, {
|
|
159
|
+
base: config,
|
|
160
|
+
validate: validateThresholds,
|
|
161
|
+
});
|
|
162
|
+
const configService = new ConfigService({ source: adaptSettingsScope(scope) });
|
|
163
|
+
// 端口只建一次:KeyResolver 与 HTTP 端点读的是同一份「可不可写」的事实。
|
|
164
|
+
const credentials = credentialsPort(ctx);
|
|
165
|
+
const keys = new KeyResolver({
|
|
166
|
+
readConfig: () => {
|
|
167
|
+
const current = configService.current();
|
|
168
|
+
return { apiKey: current.apiKey, apiKeyRef: current.apiKeyRef };
|
|
169
|
+
},
|
|
170
|
+
credentials,
|
|
171
|
+
logger,
|
|
172
|
+
});
|
|
173
|
+
const client = new HttpDeepSeekClient();
|
|
174
|
+
const store = new DomainCoreStore({ open: connectStorage(ctx), logger });
|
|
175
|
+
// 默认组合没有指标 sink,所以用内存登记表把聚合值留下来,
|
|
176
|
+
// 由 healthz 的 metrics 段暴露(见 .agents/notes 的决策)。
|
|
177
|
+
const metrics = new MemoryMetrics();
|
|
178
|
+
const service = new BalanceService({
|
|
179
|
+
client, store, keys, config: configService, clock: systemClock, salt, logger, metrics,
|
|
180
|
+
});
|
|
181
|
+
const scheduler = new Scheduler({ target: service, logger });
|
|
182
|
+
ctx.effect(() => {
|
|
183
|
+
const stopWatching = scope.watch((next, previous) => {
|
|
184
|
+
if (affectsSchedule(previous, next))
|
|
185
|
+
scheduler.reset();
|
|
186
|
+
});
|
|
187
|
+
let stopped = false;
|
|
188
|
+
// 先恢复落盘快照再起调度,否则首拉之前的窗口里界面会闪一次空态。
|
|
189
|
+
void service.restore().then(() => {
|
|
190
|
+
if (!stopped)
|
|
191
|
+
scheduler.start();
|
|
192
|
+
});
|
|
193
|
+
return () => {
|
|
194
|
+
stopped = true;
|
|
195
|
+
stopWatching();
|
|
196
|
+
scheduler.stop();
|
|
197
|
+
// 句柄由调用方关闭:facility 不绑消费方 fiber(契约 §10.0)。
|
|
198
|
+
void store.close();
|
|
199
|
+
};
|
|
200
|
+
}, 'ds-balance: lifecycle');
|
|
201
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
202
|
+
connectionCtx.effect(() => {
|
|
203
|
+
const disposeRoutes = registerHttpRoutes(connectionCtx, {
|
|
204
|
+
service, config: configService, keys, client, store, scheduler, logger, metrics, credentials,
|
|
205
|
+
});
|
|
206
|
+
return () => { void disposeRoutes(); };
|
|
207
|
+
}, 'ds-balance: http routes');
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快照存储端口。实现走 dsh 官方存储接缝,见 docs/backend-architecture.md §10。
|
|
3
|
+
* @module dsh-ds-balance/ports/core-store
|
|
4
|
+
*/
|
|
5
|
+
import type { BalanceSnapshot } from '../domain/balance.js';
|
|
6
|
+
/** 端口健康。 */
|
|
7
|
+
export interface StoreHealth {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
detail?: string;
|
|
10
|
+
}
|
|
11
|
+
/** 快照持久化。 */
|
|
12
|
+
export interface CoreStore {
|
|
13
|
+
/** 追加一条快照。 */
|
|
14
|
+
saveSnapshot(snapshot: BalanceSnapshot): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* 读取该账本最近一条快照。
|
|
17
|
+
*
|
|
18
|
+
* **必须按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
|
|
19
|
+
* @param accountTag - 账本作用域标识。
|
|
20
|
+
* @returns 最近一条快照,或 `null` 表示该账本还没有记录。
|
|
21
|
+
*/
|
|
22
|
+
loadLatestSnapshot(accountTag: string): Promise<BalanceSnapshot | null>;
|
|
23
|
+
/** 端口自检。 */
|
|
24
|
+
health(): Promise<StoreHealth>;
|
|
25
|
+
/** 释放存储句柄。**必须由调用方在 `ctx.effect` 的 disposer 里调用。** */
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=core-store.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 凭据端口。
|
|
3
|
+
*
|
|
4
|
+
* 契约来自 dsh 的 credentials 服务:`resolve` 回值,`describe` **只回三片**、
|
|
5
|
+
* 永不回值。装配里可能**没有** credentials seam,消费方必须自己兜。
|
|
6
|
+
* @module dsh-ds-balance/ports/credentials
|
|
7
|
+
*/
|
|
8
|
+
/** 解析结果。 */
|
|
9
|
+
export interface ResolvedCredential {
|
|
10
|
+
value: string;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
/** 描述结果:**类型里根本没有装值的槽**。 */
|
|
14
|
+
export interface CredentialDescription {
|
|
15
|
+
configured: boolean;
|
|
16
|
+
source?: string;
|
|
17
|
+
writable: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** 凭据服务的最小面。 */
|
|
20
|
+
export interface Credentials {
|
|
21
|
+
/**
|
|
22
|
+
* 解析一个引用名。
|
|
23
|
+
* @param ref - 环境变量名形状的引用名。
|
|
24
|
+
* @throws 引用名非法或没有该凭据时抛错。
|
|
25
|
+
*/
|
|
26
|
+
resolve(ref: string): Promise<ResolvedCredential>;
|
|
27
|
+
/** 描述一个引用名。 */
|
|
28
|
+
describe(ref: string): Promise<CredentialDescription>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek 上游端口。
|
|
3
|
+
* @module dsh-ds-balance/ports/deepseek-client
|
|
4
|
+
*/
|
|
5
|
+
import type { ErrorCode } from '../domain/errors.js';
|
|
6
|
+
import type { RawBalanceResponse } from '../domain/balance.js';
|
|
7
|
+
/** 默认端点。契约 §3.2:**端点独立**,不继承对话适配器的 baseURL。 */
|
|
8
|
+
export declare const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
9
|
+
/** 一次调用的公共参数。 */
|
|
10
|
+
export interface DeepSeekCallOptions {
|
|
11
|
+
/** 端点基址,可覆盖默认值。 */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** 已解析出的明文密钥。**只在本进程内存里流转,不落盘、不记日志。** */
|
|
14
|
+
apiKey: string;
|
|
15
|
+
/** 超时毫秒数。 */
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
/** 调用方的取消信号。 */
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
/** 测连接的结果,形状对齐契约 §8.7。 */
|
|
21
|
+
export interface TestConnectionResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
latencyMs: number;
|
|
24
|
+
isAvailable?: boolean;
|
|
25
|
+
balances?: Array<{
|
|
26
|
+
currency: string;
|
|
27
|
+
total: string;
|
|
28
|
+
}>;
|
|
29
|
+
code?: ErrorCode;
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
/** 余额上游。 */
|
|
33
|
+
export interface DeepSeekClient {
|
|
34
|
+
/**
|
|
35
|
+
* 抓一次余额。
|
|
36
|
+
* @throws {UpstreamError} 上游返回非 2xx。
|
|
37
|
+
* @throws {TimeoutError} 超时。
|
|
38
|
+
* @throws {NetworkError} 连不上。
|
|
39
|
+
* @throws {ParseError} 响应不是 JSON。
|
|
40
|
+
*/
|
|
41
|
+
fetchBalance(options: DeepSeekCallOptions): Promise<RawBalanceResponse>;
|
|
42
|
+
/**
|
|
43
|
+
* 测连接。**不动活动缓存。** 失败时返回 `ok: false` 而不抛错。
|
|
44
|
+
*/
|
|
45
|
+
testConnection(options: DeepSeekCallOptions): Promise<TestConnectionResult>;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=deepseek-client.d.ts.map
|