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,12 @@
1
+ /**
2
+ * 日志端口。字段化的结构化日志,便于机器筛。
3
+ * @module dsh-ds-balance/ports/logger
4
+ */
5
+ /** 结构化日志。 */
6
+ export interface Logger {
7
+ debug(message: string, fields?: Record<string, unknown>): void;
8
+ info(message: string, fields?: Record<string, unknown>): void;
9
+ warn(message: string, fields?: Record<string, unknown>): void;
10
+ error(message: string, fields?: Record<string, unknown>): void;
11
+ }
12
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 日志端口。字段化的结构化日志,便于机器筛。
3
+ * @module dsh-ds-balance/ports/logger
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 指标端口。
3
+ * @module dsh-ds-balance/ports/metrics
4
+ */
5
+ /** 计数、仪表与直方图。 */
6
+ export interface Metrics {
7
+ counter(name: string, labels?: Record<string, string>): void;
8
+ gauge(name: string, labels: Record<string, string>, value: number): void;
9
+ histogram(name: string, labels: Record<string, string>, value: number): void;
10
+ }
11
+ /** 一个直方图的聚合值。 */
12
+ export interface HistogramSummary {
13
+ count: number;
14
+ sum: number;
15
+ min: number;
16
+ max: number;
17
+ /** 最近一次的观测值。 */
18
+ last: number;
19
+ }
20
+ /**
21
+ * 指标快照。
22
+ *
23
+ * 键是 `名字{标签=值,...}` 的形式,标签按键名排序 —— 同一组标签永远得到同一个键。
24
+ */
25
+ export interface MetricsSnapshot {
26
+ counters: Record<string, number>;
27
+ gauges: Record<string, number>;
28
+ histograms: Record<string, HistogramSummary>;
29
+ }
30
+ /** 能读出聚合值的指标实现。没有外部 sink 的装配靠它把数字暴露出去。 */
31
+ export interface ReadableMetrics extends Metrics {
32
+ snapshot(): MetricsSnapshot;
33
+ }
34
+ /** 什么都不做的实现,用于测试与未接线的装配。 */
35
+ export declare const noopMetrics: Metrics;
36
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 指标端口。
3
+ * @module dsh-ds-balance/ports/metrics
4
+ */
5
+ /** 什么都不做的实现,用于测试与未接线的装配。 */
6
+ export const noopMetrics = {
7
+ counter: () => { },
8
+ gauge: () => { },
9
+ histogram: () => { },
10
+ };
11
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 账本作用域标识。
3
+ *
4
+ * `accountTag` 只用于把快照按凭据分组,**不含明文**,也不可逆推出密钥。
5
+ * @module dsh-ds-balance/services/account-tag
6
+ */
7
+ /** 取前多少位十六进制。够区分账本,又不至于过长。 */
8
+ export declare const ACCOUNT_TAG_LENGTH = 32;
9
+ /** 日志里只记前 8 位。 */
10
+ export declare const ACCOUNT_TAG_LOG_LENGTH = 8;
11
+ /**
12
+ * 计算账本标识。
13
+ * @param salt - 进程内稳定的服务端盐。
14
+ * @param apiKey - 明文密钥;**只在内存里流转**。
15
+ * @returns 32 位十六进制标识。
16
+ */
17
+ export declare function computeAccountTag(salt: string, apiKey: string): string;
18
+ /**
19
+ * 日志用的短标识。
20
+ * @param accountTag - {@link computeAccountTag} 的产物。
21
+ * @returns 前 8 位。
22
+ */
23
+ export declare function accountTag8(accountTag: string): string;
24
+ //# sourceMappingURL=account-tag.d.ts.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 账本作用域标识。
3
+ *
4
+ * `accountTag` 只用于把快照按凭据分组,**不含明文**,也不可逆推出密钥。
5
+ * @module dsh-ds-balance/services/account-tag
6
+ */
7
+ import { createHmac } from 'node:crypto';
8
+ /** 取前多少位十六进制。够区分账本,又不至于过长。 */
9
+ export const ACCOUNT_TAG_LENGTH = 32;
10
+ /** 日志里只记前 8 位。 */
11
+ export const ACCOUNT_TAG_LOG_LENGTH = 8;
12
+ /**
13
+ * 计算账本标识。
14
+ * @param salt - 进程内稳定的服务端盐。
15
+ * @param apiKey - 明文密钥;**只在内存里流转**。
16
+ * @returns 32 位十六进制标识。
17
+ */
18
+ export function computeAccountTag(salt, apiKey) {
19
+ return createHmac('sha256', salt).update(apiKey).digest('hex').slice(0, ACCOUNT_TAG_LENGTH);
20
+ }
21
+ /**
22
+ * 日志用的短标识。
23
+ * @param accountTag - {@link computeAccountTag} 的产物。
24
+ * @returns 前 8 位。
25
+ */
26
+ export function accountTag8(accountTag) {
27
+ return accountTag.slice(0, ACCOUNT_TAG_LOG_LENGTH);
28
+ }
29
+ //# sourceMappingURL=account-tag.js.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 余额缓存与状态机。
3
+ *
4
+ * 职责:把「拿密钥 → 调上游 → 归一化 → 落快照」包成一次可合并的请求,并维护缓存状态。
5
+ * **对外永不抛错**:一切失败都变成 `view.state` 与 `view.error`。
6
+ * @module dsh-ds-balance/services/balance-service
7
+ */
8
+ import type { BalanceView, CacheState } from '../domain/balance.js';
9
+ import { type ErrorCode } from '../domain/errors.js';
10
+ import type { Clock } from '../ports/clock.js';
11
+ import type { CoreStore } from '../ports/core-store.js';
12
+ import type { DeepSeekClient } from '../ports/deepseek-client.js';
13
+ import type { Logger } from '../ports/logger.js';
14
+ import { type Metrics } from '../ports/metrics.js';
15
+ import type { ConfigService } from './config-service.js';
16
+ import type { KeyResolver } from './key-resolver.js';
17
+ /** `forceRefresh` 的返回值,形状对齐契约 §8.4。 */
18
+ export interface RefreshResult {
19
+ triggered: boolean;
20
+ joined: boolean;
21
+ cooldownMs: number;
22
+ state: CacheState;
23
+ }
24
+ /** 调度器需要的状态切片。 */
25
+ export interface BalanceStatus {
26
+ state: CacheState;
27
+ errorCode: ErrorCode | null;
28
+ consecutiveFailures: number;
29
+ hasSnapshot: boolean;
30
+ serverRefreshSeconds: number;
31
+ /** 上游给的 `Retry-After`,优先于自算退避。 */
32
+ retryAfterMs: number | null;
33
+ /** 最近一次成功抓取的时刻;从未成功过是 `null`。健康检查要用。 */
34
+ lastSuccessAt: number | null;
35
+ }
36
+ /** `getView` 的可选参数。 */
37
+ export interface GetViewOptions {
38
+ /** 跳过新鲜度判据强制拉一次。 */
39
+ force?: boolean;
40
+ /** 覆盖展示币种偏好;缺省读配置。 */
41
+ currency?: string;
42
+ }
43
+ /** 构造参数。 */
44
+ export interface BalanceServiceOptions {
45
+ client: DeepSeekClient;
46
+ store: CoreStore;
47
+ keys: KeyResolver;
48
+ config: ConfigService;
49
+ clock: Clock;
50
+ /** 服务端盐,用来算 `accountTag`。 */
51
+ salt: string;
52
+ logger?: Logger | undefined;
53
+ metrics?: Metrics | undefined;
54
+ }
55
+ /** 余额缓存与状态机。 */
56
+ export declare class BalanceService {
57
+ private readonly options;
58
+ private readonly metrics;
59
+ private snapshot;
60
+ private state;
61
+ private error;
62
+ private failures;
63
+ private retryAfterMs;
64
+ private inflight;
65
+ /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
66
+ private persistWarned;
67
+ constructor(options: BalanceServiceOptions);
68
+ /** 当前状态切片,供调度与健康检查使用。 */
69
+ status(): BalanceStatus;
70
+ /**
71
+ * 当前账本标识的前 8 位;还没有快照时是 `null`。
72
+ *
73
+ * **只回前 8 位**:完整 tag 是账本作用域标识,没有对外的理由。
74
+ */
75
+ accountTag8(): string | null;
76
+ /**
77
+ * 从存储恢复最近快照(挂载时调一次)。
78
+ *
79
+ * **按 `accountTag` 过滤**:凭据轮换后 tag 变了,旧快照视为不存在,不混用。
80
+ * 没有密钥可算 tag 时静默跳过。
81
+ */
82
+ restore(): Promise<void>;
83
+ /**
84
+ * 取当前视图。
85
+ *
86
+ * 有在飞的请求就合并;不 force 且未过期就返回缓存;否则拉一次。
87
+ */
88
+ getView(options?: GetViewOptions): Promise<BalanceView>;
89
+ /**
90
+ * 手动刷新。
91
+ *
92
+ * 冷却中不触发;已有请求在飞时合并并回报 `joined`。
93
+ */
94
+ forceRefresh(reason: string): Promise<RefreshResult>;
95
+ /** 快照是否还在 `serverRefreshSeconds` 窗口内。**只看时间,不看状态。** */
96
+ private withinWindow;
97
+ /**
98
+ * 能否直接拿缓存顶上。
99
+ *
100
+ * 与 {@link withinWindow} 分开:`restore` 要在状态还是 `empty` 时用纯时间判据,
101
+ * 而这里必须要求 `ok` —— 否则一次失败之后,窗口内的旧快照会让 `getView`
102
+ * 永远不再重试。
103
+ */
104
+ private canServeCache;
105
+ /** 跑一次真实抓取。**所有异常都在这里被吸收。** */
106
+ private fetchOnce;
107
+ /**
108
+ * 把快照落盘。
109
+ *
110
+ * **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
111
+ * @param snapshot - 刚归一化出来的快照。
112
+ */
113
+ private persist;
114
+ /** 记录一次失败,并把状态推到 `stale` 或 `error`。 */
115
+ private applyFailure;
116
+ /** 把缓存状态推成指标。 */
117
+ private publishGauge;
118
+ /** 把缓存折成对外视图。 */
119
+ private toView;
120
+ }
121
+ //# sourceMappingURL=balance-service.d.ts.map
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 余额缓存与状态机。
3
+ *
4
+ * 职责:把「拿密钥 → 调上游 → 归一化 → 落快照」包成一次可合并的请求,并维护缓存状态。
5
+ * **对外永不抛错**:一切失败都变成 `view.state` 与 `view.error`。
6
+ * @module dsh-ds-balance/services/balance-service
7
+ */
8
+ import { classify, parseRetryAfter } from '../domain/errors.js';
9
+ import { normalize } from '../domain/normalize.js';
10
+ import { pickBalance } from '../domain/select.js';
11
+ import { severityOf, thresholdsFor } from '../domain/severity.js';
12
+ import { noopMetrics } from '../ports/metrics.js';
13
+ import { accountTag8, computeAccountTag } from './account-tag.js';
14
+ /** 余额缓存与状态机。 */
15
+ export class BalanceService {
16
+ options;
17
+ metrics;
18
+ snapshot = null;
19
+ state = 'empty';
20
+ error = null;
21
+ failures = 0;
22
+ retryAfterMs = null;
23
+ inflight = null;
24
+ /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
25
+ persistWarned = false;
26
+ constructor(options) {
27
+ this.options = options;
28
+ this.metrics = options.metrics ?? noopMetrics;
29
+ }
30
+ /** 当前状态切片,供调度与健康检查使用。 */
31
+ status() {
32
+ return {
33
+ state: this.state,
34
+ errorCode: this.error?.code ?? null,
35
+ consecutiveFailures: this.failures,
36
+ hasSnapshot: this.snapshot !== null,
37
+ serverRefreshSeconds: this.options.config.current().serverRefreshSeconds,
38
+ retryAfterMs: this.retryAfterMs,
39
+ lastSuccessAt: this.snapshot?.fetchedAt ?? null,
40
+ };
41
+ }
42
+ /**
43
+ * 当前账本标识的前 8 位;还没有快照时是 `null`。
44
+ *
45
+ * **只回前 8 位**:完整 tag 是账本作用域标识,没有对外的理由。
46
+ */
47
+ accountTag8() {
48
+ const tag = this.snapshot?.accountTag;
49
+ return tag === undefined ? null : accountTag8(tag);
50
+ }
51
+ /**
52
+ * 从存储恢复最近快照(挂载时调一次)。
53
+ *
54
+ * **按 `accountTag` 过滤**:凭据轮换后 tag 变了,旧快照视为不存在,不混用。
55
+ * 没有密钥可算 tag 时静默跳过。
56
+ */
57
+ async restore() {
58
+ try {
59
+ const apiKey = await this.options.keys.resolve();
60
+ const accountTag = computeAccountTag(this.options.salt, apiKey);
61
+ const stored = await this.options.store.loadLatestSnapshot(accountTag);
62
+ if (stored === null)
63
+ return;
64
+ this.snapshot = stored;
65
+ this.state = this.withinWindow() ? 'ok' : 'stale';
66
+ this.publishGauge();
67
+ }
68
+ catch (error) {
69
+ this.options.logger?.debug('ds-balance: no snapshot restored', { error: describe(error) });
70
+ }
71
+ }
72
+ /**
73
+ * 取当前视图。
74
+ *
75
+ * 有在飞的请求就合并;不 force 且未过期就返回缓存;否则拉一次。
76
+ */
77
+ async getView(options = {}) {
78
+ if (this.inflight !== null)
79
+ return this.inflight;
80
+ if (options.force !== true && this.canServeCache())
81
+ return this.toView(options.currency);
82
+ const run = this.fetchOnce(options.currency).finally(() => { this.inflight = null; });
83
+ this.inflight = run;
84
+ return run;
85
+ }
86
+ /**
87
+ * 手动刷新。
88
+ *
89
+ * 冷却中不触发;已有请求在飞时合并并回报 `joined`。
90
+ */
91
+ async forceRefresh(reason) {
92
+ const cooldownMs = this.options.config.current().manualRefreshCooldownSeconds * 1000;
93
+ const since = this.options.clock.now() - (this.snapshot?.fetchedAt ?? 0);
94
+ if (this.state === 'ok' && since < cooldownMs) {
95
+ this.metrics.counter('force_rejected_total');
96
+ this.options.logger?.debug('ds-balance: manual refresh rejected by cooldown', { reason });
97
+ return { triggered: false, joined: false, cooldownMs: cooldownMs - since, state: this.state };
98
+ }
99
+ if (this.inflight !== null) {
100
+ await this.inflight;
101
+ return { triggered: true, joined: true, cooldownMs: 0, state: this.state };
102
+ }
103
+ await this.getView({ force: true });
104
+ return { triggered: true, joined: false, cooldownMs: 0, state: this.state };
105
+ }
106
+ /** 快照是否还在 `serverRefreshSeconds` 窗口内。**只看时间,不看状态。** */
107
+ withinWindow() {
108
+ if (this.snapshot === null)
109
+ return false;
110
+ const window = this.options.config.current().serverRefreshSeconds * 1000;
111
+ return this.options.clock.now() - this.snapshot.fetchedAt < window;
112
+ }
113
+ /**
114
+ * 能否直接拿缓存顶上。
115
+ *
116
+ * 与 {@link withinWindow} 分开:`restore` 要在状态还是 `empty` 时用纯时间判据,
117
+ * 而这里必须要求 `ok` —— 否则一次失败之后,窗口内的旧快照会让 `getView`
118
+ * 永远不再重试。
119
+ */
120
+ canServeCache() {
121
+ return this.state === 'ok' && this.withinWindow();
122
+ }
123
+ /** 跑一次真实抓取。**所有异常都在这里被吸收。** */
124
+ async fetchOnce(currency) {
125
+ const startedAt = this.options.clock.now();
126
+ try {
127
+ const apiKey = await this.options.keys.resolve();
128
+ const config = this.options.config.current();
129
+ const raw = await this.options.client.fetchBalance({
130
+ baseUrl: config.baseUrl,
131
+ apiKey,
132
+ timeoutMs: this.options.config.timeoutMs(),
133
+ });
134
+ const snapshot = normalize(raw, computeAccountTag(this.options.salt, apiKey), this.options.clock.now());
135
+ // 落盘失败不算这次抓取失败:快照留在内存里,界面照常显示,
136
+ // 代价只是重启后不恢复。存储是可降级的一层。
137
+ await this.persist(snapshot);
138
+ this.snapshot = snapshot;
139
+ this.state = 'ok';
140
+ this.error = null;
141
+ this.failures = 0;
142
+ this.retryAfterMs = null;
143
+ this.metrics.counter('balance_fetch_total', { result: 'ok' });
144
+ this.metrics.histogram('balance_fetch_duration_ms', {}, this.options.clock.now() - startedAt);
145
+ this.publishGauge();
146
+ return this.toView(currency);
147
+ }
148
+ catch (error) {
149
+ this.applyFailure(error, startedAt);
150
+ return this.toView(currency);
151
+ }
152
+ }
153
+ /**
154
+ * 把快照落盘。
155
+ *
156
+ * **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
157
+ * @param snapshot - 刚归一化出来的快照。
158
+ */
159
+ async persist(snapshot) {
160
+ try {
161
+ await this.options.store.saveSnapshot(snapshot);
162
+ }
163
+ catch (error) {
164
+ if (this.persistWarned)
165
+ return;
166
+ this.persistWarned = true;
167
+ this.options.logger?.warn('ds-balance: snapshot not persisted, continuing in memory', { error: describe(error) });
168
+ }
169
+ }
170
+ /** 记录一次失败,并把状态推到 `stale` 或 `error`。 */
171
+ applyFailure(error, startedAt) {
172
+ const info = classify(error);
173
+ this.failures += 1;
174
+ this.error = info;
175
+ this.state = this.snapshot === null ? 'error' : 'stale';
176
+ this.retryAfterMs = retryAfterOf(error, this.options.clock.now());
177
+ // 只在首次失败打 warn,避免日志刷屏。
178
+ if (this.failures === 1) {
179
+ this.options.logger?.warn('ds-balance: balance fetch failed', { code: info.code });
180
+ }
181
+ this.metrics.counter('balance_fetch_total', { result: 'error' });
182
+ this.metrics.histogram('balance_fetch_duration_ms', {}, this.options.clock.now() - startedAt);
183
+ this.publishGauge();
184
+ }
185
+ /** 把缓存状态推成指标。 */
186
+ publishGauge() {
187
+ this.metrics.gauge('cache_state', { state: this.state }, 1);
188
+ }
189
+ /** 把缓存折成对外视图。 */
190
+ toView(currency) {
191
+ const config = this.options.config.current();
192
+ const thresholds = this.options.config.thresholds();
193
+ const preference = currency ?? config.displayCurrency;
194
+ const selected = this.snapshot === null ? null : pickBalance(this.snapshot.balances, preference);
195
+ return {
196
+ state: this.state,
197
+ stale: this.state === 'stale',
198
+ fetchedAt: this.snapshot?.fetchedAt ?? null,
199
+ ageMs: this.snapshot === null ? null : Math.max(0, this.options.clock.now() - this.snapshot.fetchedAt),
200
+ isAvailable: this.snapshot?.isAvailable ?? null,
201
+ balances: this.snapshot?.balances ?? [],
202
+ selected: selected === null ? null : { currency: selected.currency, total: selected.total },
203
+ severity: severityOf(selected, this.snapshot?.isAvailable ?? false, thresholdsFor(selected?.currency ?? '', thresholds)),
204
+ thresholds,
205
+ error: this.state === 'ok' ? null : this.error,
206
+ };
207
+ }
208
+ }
209
+ /** 从上游错误里抽 `Retry-After`;抽不到返回 `null`。 */
210
+ function retryAfterOf(error, now) {
211
+ const headers = error.headers;
212
+ if (headers === undefined)
213
+ return null;
214
+ return parseRetryAfter(headers, now) ?? null;
215
+ }
216
+ /** 把未知异常压成一行。 */
217
+ function describe(error) {
218
+ return error instanceof Error ? error.message : String(error);
219
+ }
220
+ //# sourceMappingURL=balance-service.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 配置读取。
3
+ *
4
+ * 薄封装:把设置作用域包成「现读 + 订阅」两件事,并给出派生值(阈值、超时)。
5
+ * **不缓存** —— 用户改设置要立刻生效。
6
+ * @module dsh-ds-balance/services/config-service
7
+ */
8
+ import type { Currency, ThresholdPair } from '../domain/balance.js';
9
+ import { type Config } from '../config.js';
10
+ /** 配置来源。生产里包 `ctx.settings` 的 scope,测试里给替身。 */
11
+ export interface ConfigSource {
12
+ /** 现读当前解析值。 */
13
+ get(): Config;
14
+ /** 订阅变更,返回退订函数。 */
15
+ watch(listener: (next: Config, previous: Config) => void): () => void;
16
+ /**
17
+ * 把补丁合并进用户层并持久化。
18
+ *
19
+ * 可选:装配里可能没有设置服务,此时配置只读。**schema 校验失败会 reject**,
20
+ * 调用方要把它翻成 `422`。
21
+ */
22
+ update?(patch: Record<string, unknown>): Promise<void>;
23
+ }
24
+ /** 构造参数。 */
25
+ export interface ConfigServiceOptions {
26
+ source: ConfigSource;
27
+ /** 环境变量表;默认 `process.env`。超时**每次现读**,改完立刻生效。 */
28
+ env?: Record<string, string | undefined>;
29
+ }
30
+ /** 配置读取与派生值。 */
31
+ export declare class ConfigService {
32
+ private readonly source;
33
+ private readonly env;
34
+ constructor(options: ConfigServiceOptions);
35
+ /** 现读整份配置。 */
36
+ current(): Config;
37
+ /** 订阅配置变更。 */
38
+ watch(listener: (next: Config, previous: Config) => void): () => void;
39
+ /**
40
+ * 写回一份配置补丁。
41
+ * @param patch - 字段子集;键必须已被调用方过滤过。
42
+ * @throws {ValidationError} 配置源只读,或 schema 拒绝了这份补丁。
43
+ */
44
+ update(patch: Record<string, unknown>): Promise<void>;
45
+ /** 当前生效的阈值表(按币种)。 */
46
+ thresholds(): Record<Currency, ThresholdPair>;
47
+ /** 当前生效的上游超时(毫秒)。**每次现读环境变量。** */
48
+ timeoutMs(): number;
49
+ /** 当前展示币种偏好。 */
50
+ displayCurrency(): string;
51
+ }
52
+ //# sourceMappingURL=config-service.d.ts.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 配置读取。
3
+ *
4
+ * 薄封装:把设置作用域包成「现读 + 订阅」两件事,并给出派生值(阈值、超时)。
5
+ * **不缓存** —— 用户改设置要立刻生效。
6
+ * @module dsh-ds-balance/services/config-service
7
+ */
8
+ import { ValidationError } from '../domain/errors.js';
9
+ import { thresholdsOf } from '../domain/severity.js';
10
+ import { resolveTimeoutMs } from '../config.js';
11
+ /** 配置读取与派生值。 */
12
+ export class ConfigService {
13
+ source;
14
+ env;
15
+ constructor(options) {
16
+ this.source = options.source;
17
+ this.env = options.env ?? process.env;
18
+ }
19
+ /** 现读整份配置。 */
20
+ current() {
21
+ return this.source.get();
22
+ }
23
+ /** 订阅配置变更。 */
24
+ watch(listener) {
25
+ return this.source.watch(listener);
26
+ }
27
+ /**
28
+ * 写回一份配置补丁。
29
+ * @param patch - 字段子集;键必须已被调用方过滤过。
30
+ * @throws {ValidationError} 配置源只读,或 schema 拒绝了这份补丁。
31
+ */
32
+ async update(patch) {
33
+ const update = this.source.update;
34
+ if (update === undefined)
35
+ throw new ValidationError('configuration store is not writable');
36
+ await update(patch);
37
+ }
38
+ /** 当前生效的阈值表(按币种)。 */
39
+ thresholds() {
40
+ return thresholdsOf(this.current());
41
+ }
42
+ /** 当前生效的上游超时(毫秒)。**每次现读环境变量。** */
43
+ timeoutMs() {
44
+ return resolveTimeoutMs(this.env);
45
+ }
46
+ /** 当前展示币种偏好。 */
47
+ displayCurrency() {
48
+ return this.current().displayCurrency;
49
+ }
50
+ }
51
+ //# sourceMappingURL=config-service.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 密钥解析。
3
+ *
4
+ * 解析链(优先级从高到低):配置 `apiKey` → `credentials.resolve(apiKeyRef)` →
5
+ * `process.env[apiKeyRef]` → 抛 {@link NoKeyError}。
6
+ *
7
+ * **无 credentials seam 时不抛错**:吞掉后继续往下走,让 env 层有机会接手。
8
+ * @module dsh-ds-balance/services/key-resolver
9
+ */
10
+ import type { Credentials } from '../ports/credentials.js';
11
+ import type { Logger } from '../ports/logger.js';
12
+ /** 每次解析时现读的配置切片。 */
13
+ export interface KeyResolverConfig {
14
+ apiKey: string;
15
+ apiKeyRef: string;
16
+ }
17
+ /** 构造参数。 */
18
+ export interface KeyResolverOptions {
19
+ /** 现读配置。**不许缓存** —— 用户改了要立刻生效。 */
20
+ readConfig: () => KeyResolverConfig;
21
+ /** 凭据服务;装配里可能没有。 */
22
+ credentials?: Credentials | undefined;
23
+ /** 环境变量表;默认 `process.env`。 */
24
+ env?: Record<string, string | undefined>;
25
+ logger?: Logger | undefined;
26
+ /** 引用名合法性校验;默认用契约里的正则。 */
27
+ isValidRef?: (ref: string) => boolean;
28
+ }
29
+ /** 契约要求的引用名形状。 */
30
+ export declare const CREDENTIAL_REF_PATTERN: RegExp;
31
+ /** 解析当前可用的明文密钥。 */
32
+ export declare class KeyResolver {
33
+ private readonly options;
34
+ constructor(options: KeyResolverOptions);
35
+ /**
36
+ * 走完整条解析链。
37
+ * @returns 明文密钥。**调用方负责不把它写进日志或消息。**
38
+ * @throws {NoKeyError} 整条链都没取到值。
39
+ */
40
+ resolve(): Promise<string>;
41
+ }
42
+ //# sourceMappingURL=key-resolver.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 密钥解析。
3
+ *
4
+ * 解析链(优先级从高到低):配置 `apiKey` → `credentials.resolve(apiKeyRef)` →
5
+ * `process.env[apiKeyRef]` → 抛 {@link NoKeyError}。
6
+ *
7
+ * **无 credentials seam 时不抛错**:吞掉后继续往下走,让 env 层有机会接手。
8
+ * @module dsh-ds-balance/services/key-resolver
9
+ */
10
+ import { NoKeyError } from '../domain/errors.js';
11
+ /** 契约要求的引用名形状。 */
12
+ export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ /** 把未知异常压成一行。 */
14
+ function describe(error) {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+ /** 解析当前可用的明文密钥。 */
18
+ export class KeyResolver {
19
+ options;
20
+ constructor(options) {
21
+ this.options = options;
22
+ }
23
+ /**
24
+ * 走完整条解析链。
25
+ * @returns 明文密钥。**调用方负责不把它写进日志或消息。**
26
+ * @throws {NoKeyError} 整条链都没取到值。
27
+ */
28
+ async resolve() {
29
+ const config = this.options.readConfig();
30
+ const override = config.apiKey.trim();
31
+ if (override !== '')
32
+ return override;
33
+ const ref = config.apiKeyRef.trim();
34
+ if (ref === '')
35
+ throw new NoKeyError('no API key: both apiKey and apiKeyRef are empty');
36
+ const isValid = this.options.isValidRef ?? ((value) => CREDENTIAL_REF_PATTERN.test(value));
37
+ if (!isValid(ref)) {
38
+ throw new NoKeyError(`no API key: apiKeyRef ${JSON.stringify(ref)} is not a valid credential reference`);
39
+ }
40
+ if (this.options.credentials !== undefined) {
41
+ try {
42
+ const resolved = await this.options.credentials.resolve(ref);
43
+ const value = (resolved?.value ?? '').trim();
44
+ if (value !== '')
45
+ return value;
46
+ }
47
+ catch (error) {
48
+ // 没有 seam、引用名不存在、远程拒绝 —— 都只是「这一档没取到」,继续往下走。
49
+ this.options.logger?.debug('ds-balance: credentials.resolve did not yield a key, falling back to env', {
50
+ ref,
51
+ error: describe(error),
52
+ });
53
+ }
54
+ }
55
+ const env = this.options.env ?? process.env;
56
+ const fromEnv = (env[ref] ?? '').trim();
57
+ if (fromEnv !== '')
58
+ return fromEnv;
59
+ throw new NoKeyError(`no API key: ${ref} is not configured`);
60
+ }
61
+ }
62
+ //# sourceMappingURL=key-resolver.js.map