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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/README_en.md +81 -0
  4. package/cordis.patch.yml +9 -0
  5. package/lib/adapters/console-logger.d.ts +25 -0
  6. package/lib/adapters/console-logger.js +35 -0
  7. package/lib/adapters/domain-core-store.d.ts +104 -0
  8. package/lib/adapters/domain-core-store.js +184 -0
  9. package/lib/adapters/http-deepseek-client.d.ts +36 -0
  10. package/lib/adapters/http-deepseek-client.js +101 -0
  11. package/lib/adapters/memory-metrics.d.ts +30 -0
  12. package/lib/adapters/memory-metrics.js +67 -0
  13. package/lib/adapters/salt-file.d.ts +32 -0
  14. package/lib/adapters/salt-file.js +40 -0
  15. package/lib/client.js +2120 -0
  16. package/lib/config.d.ts +127 -0
  17. package/lib/config.js +112 -0
  18. package/lib/domain/balance.d.ts +70 -0
  19. package/lib/domain/balance.js +8 -0
  20. package/lib/domain/errors.d.ts +84 -0
  21. package/lib/domain/errors.js +136 -0
  22. package/lib/domain/money.d.ts +39 -0
  23. package/lib/domain/money.js +74 -0
  24. package/lib/domain/normalize.d.ts +38 -0
  25. package/lib/domain/normalize.js +120 -0
  26. package/lib/domain/select.d.ts +26 -0
  27. package/lib/domain/select.js +51 -0
  28. package/lib/domain/severity.d.ts +34 -0
  29. package/lib/domain/severity.js +44 -0
  30. package/lib/http/handlers.d.ts +47 -0
  31. package/lib/http/handlers.js +261 -0
  32. package/lib/http/routes.d.ts +63 -0
  33. package/lib/http/routes.js +61 -0
  34. package/lib/http/wire.d.ts +82 -0
  35. package/lib/http/wire.js +68 -0
  36. package/lib/index.d.ts +36 -0
  37. package/lib/index.js +210 -0
  38. package/lib/ports/clock.d.ts +12 -0
  39. package/lib/ports/clock.js +6 -0
  40. package/lib/ports/core-store.d.ts +28 -0
  41. package/lib/ports/core-store.js +6 -0
  42. package/lib/ports/credentials.d.ts +30 -0
  43. package/lib/ports/credentials.js +9 -0
  44. package/lib/ports/deepseek-client.d.ts +47 -0
  45. package/lib/ports/deepseek-client.js +7 -0
  46. package/lib/ports/logger.d.ts +12 -0
  47. package/lib/ports/logger.js +6 -0
  48. package/lib/ports/metrics.d.ts +36 -0
  49. package/lib/ports/metrics.js +11 -0
  50. package/lib/services/account-tag.d.ts +24 -0
  51. package/lib/services/account-tag.js +29 -0
  52. package/lib/services/balance-service.d.ts +121 -0
  53. package/lib/services/balance-service.js +220 -0
  54. package/lib/services/config-service.d.ts +52 -0
  55. package/lib/services/config-service.js +51 -0
  56. package/lib/services/key-resolver.d.ts +42 -0
  57. package/lib/services/key-resolver.js +62 -0
  58. package/lib/services/scheduler.d.ts +93 -0
  59. package/lib/services/scheduler.js +142 -0
  60. package/lib/types/client/api-types.d.ts +54 -0
  61. package/lib/types/client/data.d.ts +103 -0
  62. package/lib/types/client/index.d.ts +18 -0
  63. package/lib/types/client/locales.d.ts +98 -0
  64. package/lib/types/client/mock/index.d.ts +36 -0
  65. package/lib/types/client/mock/scenarios.d.ts +51 -0
  66. package/lib/types/client/model.d.ts +93 -0
  67. package/lib/types/client/settings/BalanceSettingsCard.d.ts +22 -0
  68. package/lib/types/client/settings/fields.d.ts +191 -0
  69. package/lib/types/client/settings/use-config-form.d.ts +212 -0
  70. package/lib/types/client/settings/use-credential-state.d.ts +36 -0
  71. package/lib/types/client/sidebar/BalancePopover.d.ts +46 -0
  72. package/lib/types/client/sidebar/PercentRing.d.ts +36 -0
  73. package/lib/types/client/sidebar/SidebarBalance.d.ts +44 -0
  74. package/lib/version.d.ts +12 -0
  75. package/lib/version.js +12 -0
  76. package/package.json +112 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * DeepSeek 上游的 HTTP 实现。
3
+ * @module dsh-ds-balance/adapters/http-deepseek-client
4
+ */
5
+ import { NetworkError, ParseError, TimeoutError, UpstreamError, classify } from '../domain/errors.js';
6
+ import { parseErrorBody } from '../domain/normalize.js';
7
+ /** 去掉尾部斜杠,避免拼出双斜杠路径。 */
8
+ function joinUrl(baseUrl, path) {
9
+ return `${baseUrl.replace(/\/+$/, '')}${path}`;
10
+ }
11
+ /** 余额端点路径。 */
12
+ const BALANCE_PATH = '/user/balance';
13
+ /**
14
+ * 用 WHATWG fetch 调 DeepSeek 的 `GET /user/balance`。
15
+ *
16
+ * 超时用自建的 `AbortController + setTimeout`(而不是 `AbortSignal.timeout`),
17
+ * 这样测试可以在不睡真实时间的前提下驱动超时分支。
18
+ */
19
+ export class HttpDeepSeekClient {
20
+ fetchImpl;
21
+ now;
22
+ constructor(options = {}) {
23
+ this.fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
24
+ this.now = options.now ?? (() => Date.now());
25
+ }
26
+ async fetchBalance(options) {
27
+ const response = await this.request(options);
28
+ const text = await response.text();
29
+ try {
30
+ return JSON.parse(text);
31
+ }
32
+ catch (error) {
33
+ throw new ParseError('balance response is not JSON', { cause: error });
34
+ }
35
+ }
36
+ async testConnection(options) {
37
+ const startedAt = this.now();
38
+ try {
39
+ const raw = await this.fetchBalance(options);
40
+ const latencyMs = this.now() - startedAt;
41
+ const balances = Array.isArray(raw?.balance_infos)
42
+ ? raw.balance_infos.map((item) => ({ currency: String(item.currency), total: String(item.total_balance) }))
43
+ : [];
44
+ return { ok: true, latencyMs, isAvailable: raw?.is_available === true, balances };
45
+ }
46
+ catch (error) {
47
+ const info = classify(error);
48
+ return { ok: false, latencyMs: this.now() - startedAt, code: info.code, message: info.message };
49
+ }
50
+ }
51
+ /**
52
+ * 发一次请求并处理状态码。
53
+ * @throws {UpstreamError} 非 2xx。
54
+ * @throws {TimeoutError} 超时。
55
+ * @throws {NetworkError} 连不上。
56
+ */
57
+ async request(options) {
58
+ const controller = new AbortController();
59
+ let timedOut = false;
60
+ const timer = setTimeout(() => {
61
+ timedOut = true;
62
+ controller.abort();
63
+ }, options.timeoutMs);
64
+ const forwardAbort = () => { controller.abort(); };
65
+ options.signal?.addEventListener('abort', forwardAbort, { once: true });
66
+ let response;
67
+ try {
68
+ response = await this.fetchImpl(joinUrl(options.baseUrl, BALANCE_PATH), {
69
+ method: 'GET',
70
+ headers: {
71
+ authorization: `Bearer ${options.apiKey}`,
72
+ accept: 'application/json',
73
+ },
74
+ signal: controller.signal,
75
+ });
76
+ }
77
+ catch (error) {
78
+ if (timedOut)
79
+ throw new TimeoutError(`upstream timeout after ${options.timeoutMs}ms`, { cause: error });
80
+ if (options.signal?.aborted === true)
81
+ throw error;
82
+ if (error instanceof Error && error.name === 'AbortError') {
83
+ throw new TimeoutError(`upstream aborted after ${options.timeoutMs}ms`, { cause: error });
84
+ }
85
+ throw new NetworkError(error instanceof Error ? error.message : String(error), { cause: error });
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ options.signal?.removeEventListener('abort', forwardAbort);
90
+ }
91
+ if (!response.ok) {
92
+ const text = await response.text().catch(() => '');
93
+ const parsed = parseErrorBody(text);
94
+ throw new UpstreamError(response.status, parsed.message ?? parsed.code ?? `upstream ${response.status}`, {
95
+ headers: response.headers,
96
+ });
97
+ }
98
+ return response;
99
+ }
100
+ }
101
+ //# sourceMappingURL=http-deepseek-client.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 内存指标登记表。
3
+ *
4
+ * dsh 的默认组合里**没有指标 sink**,所以这个实现把聚合值留在内存里,
5
+ * 由 `GET /api/v1/healthz` 的 `metrics` 段暴露出去 —— 否则 §12 的四个指标
6
+ * 就只是「调用了一个空函数」,谁也看不见。
7
+ *
8
+ * 键的构造规则:`名字{标签=值,...}`,标签按键名排序,保证同一组标签得到同一个键。
9
+ * @module dsh-ds-balance/adapters/memory-metrics
10
+ */
11
+ import type { Metrics, MetricsSnapshot } from '../ports/metrics.js';
12
+ /**
13
+ * 组装指标键。
14
+ * @param name - 指标名。
15
+ * @param labels - 标签;省略或为空时键就是名字本身。
16
+ * @returns 稳定的键。
17
+ */
18
+ export declare function metricKey(name: string, labels?: Record<string, string>): string;
19
+ /** 内存指标登记表。 */
20
+ export declare class MemoryMetrics implements Metrics {
21
+ private readonly counters;
22
+ private readonly gauges;
23
+ private readonly histograms;
24
+ counter(name: string, labels?: Record<string, string>): void;
25
+ gauge(name: string, labels: Record<string, string>, value: number): void;
26
+ histogram(name: string, labels: Record<string, string>, value: number): void;
27
+ /** 当前聚合值的一份拷贝。 */
28
+ snapshot(): MetricsSnapshot;
29
+ }
30
+ //# sourceMappingURL=memory-metrics.d.ts.map
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 内存指标登记表。
3
+ *
4
+ * dsh 的默认组合里**没有指标 sink**,所以这个实现把聚合值留在内存里,
5
+ * 由 `GET /api/v1/healthz` 的 `metrics` 段暴露出去 —— 否则 §12 的四个指标
6
+ * 就只是「调用了一个空函数」,谁也看不见。
7
+ *
8
+ * 键的构造规则:`名字{标签=值,...}`,标签按键名排序,保证同一组标签得到同一个键。
9
+ * @module dsh-ds-balance/adapters/memory-metrics
10
+ */
11
+ /** 键与标签值里不能出现的字符。出现时替换成下划线,避免键互相吞并。 */
12
+ const UNSAFE = /[^A-Za-z0-9_.:-]/g;
13
+ /** 转义一段键或标签值。 */
14
+ function escape(part) {
15
+ return part.replace(UNSAFE, '_');
16
+ }
17
+ /**
18
+ * 组装指标键。
19
+ * @param name - 指标名。
20
+ * @param labels - 标签;省略或为空时键就是名字本身。
21
+ * @returns 稳定的键。
22
+ */
23
+ export function metricKey(name, labels) {
24
+ const entries = Object.entries(labels ?? {});
25
+ if (entries.length === 0)
26
+ return escape(name);
27
+ const rendered = entries
28
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
29
+ .map(([key, value]) => `${escape(key)}=${escape(value)}`)
30
+ .join(',');
31
+ return `${escape(name)}{${rendered}}`;
32
+ }
33
+ /** 内存指标登记表。 */
34
+ export class MemoryMetrics {
35
+ counters = new Map();
36
+ gauges = new Map();
37
+ histograms = new Map();
38
+ counter(name, labels) {
39
+ const key = metricKey(name, labels);
40
+ this.counters.set(key, (this.counters.get(key) ?? 0) + 1);
41
+ }
42
+ gauge(name, labels, value) {
43
+ this.gauges.set(metricKey(name, labels), value);
44
+ }
45
+ histogram(name, labels, value) {
46
+ const key = metricKey(name, labels);
47
+ const current = this.histograms.get(key);
48
+ this.histograms.set(key, current === undefined
49
+ ? { count: 1, sum: value, min: value, max: value, last: value }
50
+ : {
51
+ count: current.count + 1,
52
+ sum: current.sum + value,
53
+ min: Math.min(current.min, value),
54
+ max: Math.max(current.max, value),
55
+ last: value,
56
+ });
57
+ }
58
+ /** 当前聚合值的一份拷贝。 */
59
+ snapshot() {
60
+ return {
61
+ counters: Object.fromEntries(this.counters),
62
+ gauges: Object.fromEntries(this.gauges),
63
+ histograms: Object.fromEntries([...this.histograms].map(([key, value]) => [key, { ...value }])),
64
+ };
65
+ }
66
+ }
67
+ //# sourceMappingURL=memory-metrics.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 服务端盐的读写。
3
+ *
4
+ * `accountTag` 由它派生,所以**它丢了账本就会孤立**:见 docs/backend-architecture.md §19 第 5 条。
5
+ * 文件权限 0600;路径由组装点用 `@deepseek-ai/dsh-home-paths` 的 `dshHomePath()` 拼出来。
6
+ * @module dsh-ds-balance/adapters/salt-file
7
+ */
8
+ /** 盐的字节数与编码。 */
9
+ export declare const SALT_BYTES = 32;
10
+ export declare const SALT_ENCODING: "hex";
11
+ /** 构造参数。 */
12
+ export interface SaltFileOptions {
13
+ /** 盐文件绝对路径。 */
14
+ path: string;
15
+ /** 注入随机源,测试用。 */
16
+ random?: (bytes: number) => string;
17
+ /** 注入读,测试用。 */
18
+ read?: (path: string) => Promise<string>;
19
+ /** 注入写,测试用。 */
20
+ write?: (path: string, data: string, mode: number) => Promise<void>;
21
+ /** 注入建目录,测试用。 */
22
+ makeDir?: (path: string) => Promise<void>;
23
+ }
24
+ /**
25
+ * 读取盐;不存在则生成并落盘。
26
+ *
27
+ * 空文件与空白内容视为缺失(与 `$DSH_HOME` 的空白处理保持一致)。
28
+ * @param options - 路径与可注入依赖。
29
+ * @returns 十六进制盐字符串。
30
+ */
31
+ export declare function loadOrCreateSalt(options: SaltFileOptions): Promise<string>;
32
+ //# sourceMappingURL=salt-file.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 服务端盐的读写。
3
+ *
4
+ * `accountTag` 由它派生,所以**它丢了账本就会孤立**:见 docs/backend-architecture.md §19 第 5 条。
5
+ * 文件权限 0600;路径由组装点用 `@deepseek-ai/dsh-home-paths` 的 `dshHomePath()` 拼出来。
6
+ * @module dsh-ds-balance/adapters/salt-file
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { dirname } from 'node:path';
11
+ /** 盐的字节数与编码。 */
12
+ export const SALT_BYTES = 32;
13
+ export const SALT_ENCODING = 'hex';
14
+ /**
15
+ * 读取盐;不存在则生成并落盘。
16
+ *
17
+ * 空文件与空白内容视为缺失(与 `$DSH_HOME` 的空白处理保持一致)。
18
+ * @param options - 路径与可注入依赖。
19
+ * @returns 十六进制盐字符串。
20
+ */
21
+ export async function loadOrCreateSalt(options) {
22
+ const read = options.read ?? (async (path) => readFile(path, 'utf8'));
23
+ const write = options.write ?? (async (path, data, mode) => writeFile(path, data, { mode }));
24
+ const makeDir = options.makeDir ?? (async (path) => { await mkdir(path, { recursive: true }); });
25
+ const random = options.random ?? ((bytes) => randomBytes(bytes).toString(SALT_ENCODING));
26
+ try {
27
+ const existing = (await read(options.path)).trim();
28
+ if (existing !== '')
29
+ return existing;
30
+ }
31
+ catch {
32
+ // 不存在或读不动:落到生成分支。
33
+ }
34
+ const created = random(SALT_BYTES);
35
+ await makeDir(dirname(options.path));
36
+ // 0600:只有本用户可读写。
37
+ await write(options.path, created, 0o600);
38
+ return created;
39
+ }
40
+ //# sourceMappingURL=salt-file.js.map