dsh-ds-balance 2.1.3 → 2.2.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 (48) hide show
  1. package/README.md +68 -18
  2. package/README_en.md +72 -18
  3. package/lib/adapters/domain-core-store.d.ts +2 -2
  4. package/lib/adapters/domain-core-store.js +3 -1
  5. package/lib/adapters/host-account.d.ts +66 -0
  6. package/lib/adapters/host-account.js +68 -0
  7. package/lib/client.js +437 -95
  8. package/lib/domain/account.d.ts +40 -0
  9. package/lib/domain/account.js +74 -0
  10. package/lib/domain/balance.d.ts +20 -1
  11. package/lib/domain/normalize.d.ts +3 -2
  12. package/lib/domain/normalize.js +3 -2
  13. package/lib/domain/severity.d.ts +8 -2
  14. package/lib/domain/severity.js +11 -4
  15. package/lib/domain/situation.d.ts +104 -0
  16. package/lib/domain/situation.js +93 -0
  17. package/lib/http/handlers.d.ts +1 -1
  18. package/lib/http/handlers.js +29 -5
  19. package/lib/http/wire.d.ts +11 -1
  20. package/lib/http/wire.js +2 -0
  21. package/lib/index.js +122 -6
  22. package/lib/ports/account.d.ts +27 -0
  23. package/lib/ports/account.js +9 -0
  24. package/lib/services/balance-service.d.ts +66 -94
  25. package/lib/services/balance-service.js +132 -196
  26. package/lib/services/balance-source.d.ts +174 -0
  27. package/lib/services/balance-source.js +294 -0
  28. package/lib/services/scheduler.d.ts +8 -2
  29. package/lib/services/scheduler.js +1 -1
  30. package/lib/services/source-readers.d.ts +26 -0
  31. package/lib/services/source-readers.js +73 -0
  32. package/lib/services/source-selector.d.ts +50 -0
  33. package/lib/services/source-selector.js +64 -0
  34. package/lib/types/client/api-types.d.ts +16 -0
  35. package/lib/types/client/data.d.ts +12 -3
  36. package/lib/types/client/index.d.ts +6 -0
  37. package/lib/types/client/locales.d.ts +10 -18
  38. package/lib/types/client/mock/index.d.ts +12 -11
  39. package/lib/types/client/mock/scenarios.d.ts +13 -5
  40. package/lib/types/client/model.d.ts +24 -25
  41. package/lib/types/client/route-hint.d.ts +61 -0
  42. package/lib/types/client/sidebar/BalancePopover.d.ts +7 -0
  43. package/lib/types/client/sidebar/PercentRing.d.ts +13 -5
  44. package/lib/types/client/sidebar/SidebarBalance.d.ts +9 -1
  45. package/lib/types/client/situation.d.ts +164 -0
  46. package/lib/version.d.ts +1 -1
  47. package/lib/version.js +1 -1
  48. package/package.json +25 -23
@@ -0,0 +1,294 @@
1
+ /**
2
+ * 一个取数来源的账本:快照、状态机、抓取与落盘。
3
+ *
4
+ * 职责:把「读一次 → 归一化 → 落快照」包成一次可合并的请求,并维护这一条路自己的缓存状态。
5
+ * **对外永不抛错**:一切失败都变成 `view.state` 与 `view.error`。
6
+ *
7
+ * 两条路(API Key / 账号登录)各持一份账本,互不影响 —— 谁的失败都不该拖累另一条。
8
+ * @module dsh-ds-balance/services/balance-source
9
+ */
10
+ import { classify, describeError, parseRetryAfter, } from '../domain/errors.js';
11
+ import { normalize } from '../domain/normalize.js';
12
+ import { pickBalance } from '../domain/select.js';
13
+ import { severityOf, thresholdsFor } from '../domain/severity.js';
14
+ import { situationOf } from '../domain/situation.js';
15
+ import { noopMetrics } from '../ports/metrics.js';
16
+ import { accountTag8 } from './account-tag.js';
17
+ /** 一条路的账本。 */
18
+ export class SourceLedger {
19
+ options;
20
+ metrics;
21
+ snapshot = null;
22
+ state = 'empty';
23
+ error = null;
24
+ failures = 0;
25
+ retryAfterMs = null;
26
+ /** 上一次真的触发过手动刷新的时刻;`null` 表示本账本还没有过。 */
27
+ lastManualRefreshAt = null;
28
+ inflight = null;
29
+ /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
30
+ persistWarned = false;
31
+ constructor(options) {
32
+ this.options = options;
33
+ this.metrics = options.metrics ?? noopMetrics;
34
+ }
35
+ /** 这条路的 id。 */
36
+ source() {
37
+ return this.options.source;
38
+ }
39
+ /** 这条路的凭据此刻可用吗。 */
40
+ available() {
41
+ return this.options.reader.available();
42
+ }
43
+ /** 当前状态切片,供调度与健康检查使用。 */
44
+ status() {
45
+ // 处境判定的四个事实与 `toView` 同源:这里也现算一次挑币种,不走第二套规则。
46
+ const preference = this.options.config.current().displayCurrency;
47
+ const selected = this.snapshot === null ? null : pickBalance(this.snapshot.balances, preference);
48
+ return {
49
+ source: this.options.source,
50
+ state: this.state,
51
+ errorCode: this.error?.code ?? null,
52
+ consecutiveFailures: this.failures,
53
+ hasSnapshot: this.snapshot !== null,
54
+ serverRefreshSeconds: this.options.config.current().serverRefreshSeconds,
55
+ retryAfterMs: this.retryAfterMs,
56
+ lastSuccessAt: this.snapshot?.fetchedAt ?? null,
57
+ isAvailable: this.snapshot?.isAvailable ?? false,
58
+ hasSelected: selected !== null,
59
+ severity: this.severityOf(selected),
60
+ };
61
+ }
62
+ /**
63
+ * 当前账本标识的前 8 位;还没有快照时是 `null`。
64
+ *
65
+ * **只回前 8 位**:完整 tag 是账本作用域标识,没有对外的理由。
66
+ */
67
+ accountTag8() {
68
+ const tag = this.snapshot?.accountTag;
69
+ return tag === undefined ? null : accountTag8(tag);
70
+ }
71
+ /**
72
+ * 从存储恢复最近快照(这条账本第一次被用到时调一次)。
73
+ *
74
+ * **按 `accountTag` 过滤**:凭据轮换后 tag 变了,旧快照视为不存在,不混用。
75
+ * 没有凭据可算 tag 时静默跳过。
76
+ */
77
+ async restore() {
78
+ try {
79
+ const accountTag = await this.options.reader.tag();
80
+ const stored = await this.options.store.loadLatestSnapshot(accountTag);
81
+ if (stored === null)
82
+ return;
83
+ this.snapshot = stored;
84
+ this.state = this.withinWindow() ? 'ok' : 'stale';
85
+ this.publishGauge();
86
+ }
87
+ catch (error) {
88
+ this.options.logger?.debug('ds-balance: no snapshot restored', {
89
+ source: this.options.source,
90
+ error: describeError(error),
91
+ });
92
+ }
93
+ }
94
+ /**
95
+ * 取当前视图。
96
+ *
97
+ * 有在飞的请求就合并;不 force 且未过期就返回缓存;否则拉一次。
98
+ */
99
+ async getView(options = {}) {
100
+ if (this.inflight !== null)
101
+ return this.inflight;
102
+ if (options.force !== true && this.canServeCache())
103
+ return this.toView(options.currency);
104
+ const run = this.fetchOnce(options.currency).finally(() => {
105
+ this.inflight = null;
106
+ });
107
+ this.inflight = run;
108
+ return run;
109
+ }
110
+ /**
111
+ * 手动刷新。
112
+ *
113
+ * 冷却中不触发;已有请求在飞时合并并回报 `joined`。
114
+ *
115
+ * **冷却的锚点是「上一次手动刷新」,不是「上一次抓取」**:调度与轮询也在抓,
116
+ * 拿 `snapshot.fetchedAt` 当锚点的话,一次自动刷新就会把用户刚按下的一下吞掉 ——
117
+ * 界面转了圈、倒计时也走了,上游却一次没打。
118
+ *
119
+ * 时刻取**决定触发那一刻**(不是抓完那一刻):客户端从「拿到结果那一刻」起算,
120
+ * 两者相差一个往返 ⇒ 客户端更保守,按钮亮起时按下去一定真的会抓。
121
+ */
122
+ async forceRefresh(reason) {
123
+ const now = this.options.clock.now();
124
+ const cooldownMs = this.options.config.current().manualRefreshCooldownSeconds * 1000;
125
+ const last = this.lastManualRefreshAt;
126
+ if (last !== null && now - last < cooldownMs) {
127
+ const remaining = cooldownMs - (now - last);
128
+ this.metrics.counter('force_rejected_total', { source: this.options.source });
129
+ this.options.logger?.debug('ds-balance: manual refresh rejected by cooldown', {
130
+ source: this.options.source,
131
+ reason,
132
+ cooldownMs: remaining,
133
+ });
134
+ return { triggered: false, joined: false, cooldownMs: remaining, state: this.state };
135
+ }
136
+ this.lastManualRefreshAt = now;
137
+ if (this.inflight !== null) {
138
+ await this.inflight;
139
+ return { triggered: true, joined: true, cooldownMs: 0, state: this.state };
140
+ }
141
+ await this.getView({ force: true });
142
+ return { triggered: true, joined: false, cooldownMs: 0, state: this.state };
143
+ }
144
+ /** 快照是否还在 `serverRefreshSeconds` 窗口内。**只看时间,不看状态。** */
145
+ withinWindow() {
146
+ if (this.snapshot === null)
147
+ return false;
148
+ const window = this.options.config.current().serverRefreshSeconds * 1000;
149
+ return this.options.clock.now() - this.snapshot.fetchedAt < window;
150
+ }
151
+ /**
152
+ * 能否直接拿缓存顶上。
153
+ *
154
+ * 与 {@link withinWindow} 分开:`restore` 要在状态还是 `empty` 时用纯时间判据,
155
+ * 而这里必须要求 `ok` —— 否则一次失败之后,窗口内的旧快照会让 `getView`
156
+ * 永远不再重试。
157
+ */
158
+ canServeCache() {
159
+ return this.state === 'ok' && this.withinWindow();
160
+ }
161
+ /** 跑一次真实抓取。**所有异常都在这里被吸收。** */
162
+ async fetchOnce(currency) {
163
+ const startedAt = this.options.clock.now();
164
+ try {
165
+ const { raw, accountTag } = await this.options.reader.read();
166
+ const snapshot = normalize(raw, accountTag, this.options.clock.now(), this.options.source);
167
+ // 落盘失败不算这次抓取失败:快照留在内存里,界面照常显示,
168
+ // 代价只是重启后不恢复。存储是可降级的一层。
169
+ await this.persist(snapshot);
170
+ this.snapshot = snapshot;
171
+ this.state = 'ok';
172
+ this.error = null;
173
+ this.failures = 0;
174
+ this.retryAfterMs = null;
175
+ this.count('ok');
176
+ this.observe(this.options.clock.now() - startedAt);
177
+ this.publishGauge();
178
+ return this.toView(currency);
179
+ }
180
+ catch (error) {
181
+ this.applyFailure(error, startedAt);
182
+ return this.toView(currency);
183
+ }
184
+ }
185
+ /**
186
+ * 把快照落盘。
187
+ *
188
+ * **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
189
+ * @param snapshot - 刚归一化出来的快照。
190
+ */
191
+ async persist(snapshot) {
192
+ try {
193
+ await this.options.store.saveSnapshot(snapshot);
194
+ }
195
+ catch (error) {
196
+ if (this.persistWarned)
197
+ return;
198
+ this.persistWarned = true;
199
+ this.options.logger?.warn('ds-balance: snapshot not persisted, continuing in memory', {
200
+ source: this.options.source,
201
+ error: describeError(error),
202
+ });
203
+ }
204
+ }
205
+ /** 记录一次失败,并把状态推到 `stale` 或 `error`。 */
206
+ applyFailure(error, startedAt) {
207
+ const info = classify(error);
208
+ this.failures += 1;
209
+ this.error = info;
210
+ this.state = this.snapshot === null ? 'error' : 'stale';
211
+ this.retryAfterMs = retryAfterOf(error, this.options.clock.now());
212
+ // 只在首次失败打 warn,避免日志刷屏。
213
+ if (this.failures === 1) {
214
+ this.options.logger?.warn('ds-balance: balance fetch failed', {
215
+ source: this.options.source,
216
+ code: info.code,
217
+ });
218
+ }
219
+ this.count('error');
220
+ this.observe(this.options.clock.now() - startedAt);
221
+ this.publishGauge();
222
+ }
223
+ /** 记一次抓取耗时(带上来源标签,两条路各自可看)。 */
224
+ observe(durationMs) {
225
+ this.metrics.histogram('balance_fetch_duration_ms', { source: this.options.source }, durationMs);
226
+ }
227
+ /** 记一次抓取结果。 */
228
+ count(result) {
229
+ this.metrics.counter('balance_fetch_total', { result, source: this.options.source });
230
+ }
231
+ /** 把缓存状态推成指标。 */
232
+ publishGauge() {
233
+ this.metrics.gauge('cache_state', { state: this.state, source: this.options.source }, 1);
234
+ }
235
+ /** 把缓存折成对外视图。 */
236
+ toView(currency) {
237
+ const config = this.options.config.current();
238
+ const thresholds = this.options.config.thresholds();
239
+ const preference = currency ?? config.displayCurrency;
240
+ const selected = this.snapshot === null ? null : pickBalance(this.snapshot.balances, preference);
241
+ const severity = this.severityOf(selected);
242
+ return {
243
+ source: this.options.source,
244
+ // 处境由事实一次判出;界面拿它当唯一分支入口。
245
+ situation: situationOf({
246
+ hasSnapshot: this.snapshot !== null,
247
+ stale: this.state === 'stale',
248
+ isAvailable: this.snapshot?.isAvailable ?? false,
249
+ hasSelected: selected !== null,
250
+ severity,
251
+ errorCode: this.error?.code ?? null,
252
+ }),
253
+ state: this.state,
254
+ stale: this.state === 'stale',
255
+ fetchedAt: this.snapshot?.fetchedAt ?? null,
256
+ ageMs: this.snapshot === null
257
+ ? null
258
+ : Math.max(0, this.options.clock.now() - this.snapshot.fetchedAt),
259
+ isAvailable: this.snapshot?.isAvailable ?? null,
260
+ balances: this.snapshot?.balances ?? [],
261
+ selected: selected === null ? null : { currency: selected.currency, total: selected.total },
262
+ severity,
263
+ thresholds,
264
+ error: this.state === 'ok' ? null : this.error,
265
+ };
266
+ }
267
+ /**
268
+ * 这一份视图的严重度。
269
+ *
270
+ * **只服务环的颜色与弧长**;「处境」是另一个维度,由 {@link situationOf} 判,
271
+ * 两者不再各自决定界面形态(历史上它们是两条互相打架的链)。
272
+ * @param selected - 选中币种的那条余额;没快照时是 `null`。
273
+ * @returns 严重度。
274
+ */
275
+ severityOf(selected) {
276
+ if (this.snapshot === null) {
277
+ // 没有快照时只有两种事实:没接入(空环)与读不到(叉)。
278
+ // 具体是哪一种由处境层判,这里给的 severity 只保证颜色不撒谎。
279
+ return this.error?.code === 'NO_KEY' ? 'unknown' : 'unavailable';
280
+ }
281
+ const thresholds = this.options.config.thresholds();
282
+ return severityOf(selected, this.snapshot?.isAvailable ?? false, thresholdsFor(selected?.currency ?? '', thresholds));
283
+ }
284
+ }
285
+ /** 从上游错误里抽 `Retry-After`;抽不到返回 `null`。 */
286
+ function retryAfterOf(error, now) {
287
+ if (typeof error !== 'object' || error === null || !('headers' in error))
288
+ return null;
289
+ const headers = error.headers;
290
+ if (!(headers instanceof Headers))
291
+ return null;
292
+ return parseRetryAfter(headers, now) ?? null;
293
+ }
294
+ //# sourceMappingURL=balance-source.js.map
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { BalanceView } from '../domain/balance.js';
9
9
  import type { Logger } from '../ports/logger.js';
10
- import type { BalanceStatus, GetViewOptions } from './balance-service.js';
10
+ import type { BalanceStatus } from './balance-service.js';
11
11
  /** 首拉延迟。 */
12
12
  export declare const INITIAL_DELAY_MS = 1000;
13
13
  /** 缺密钥时的快速重试间隔。 */
@@ -33,7 +33,13 @@ export interface SchedulerTimers {
33
33
  }
34
34
  /** 调度器需要的最小服务面。 */
35
35
  export interface SchedulerTarget {
36
- getView(options?: GetViewOptions): Promise<BalanceView>;
36
+ /**
37
+ * 抓一轮**当前活跃来源**。
38
+ *
39
+ * 调度**不重新解析来源**:节拍属于「这份数据」,不属于某一次请求 —— 每轮都按全局默认重算的话,
40
+ * 会话级的来源会被无声地顶掉。
41
+ */
42
+ refreshActive(): Promise<BalanceView>;
37
43
  status(): BalanceStatus;
38
44
  }
39
45
  /** 构造参数。 */
@@ -158,7 +158,7 @@ export class Scheduler {
158
158
  if (!this.running)
159
159
  return;
160
160
  try {
161
- await this.options.target.getView({ force: true });
161
+ await this.options.target.refreshActive();
162
162
  }
163
163
  catch (error) {
164
164
  // 服务承诺永不抛错;这里是防御,不让调度链断掉。
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 两条取数路的读取策略。
3
+ *
4
+ * 账本({@link SourceLedger})只认这个接口,不认凭据、不认 HTTP —— 换来源就是换一个 reader。
5
+ * 三个方法都是**本地读**,只有 `read()` 会打上游。
6
+ * @module dsh-ds-balance/services/source-readers
7
+ */
8
+ import type { AccountSource } from '../ports/account.js';
9
+ import type { DeepSeekClient } from '../ports/deepseek-client.js';
10
+ import type { SourceReader } from './balance-source.js';
11
+ import type { ConfigService } from './config-service.js';
12
+ import type { KeyResolver } from './key-resolver.js';
13
+ /** Key 那条路:解析链拿明文密钥 → 官方余额端点。 */
14
+ export declare function keyReader(options: {
15
+ keys: KeyResolver;
16
+ client: DeepSeekClient;
17
+ config: ConfigService;
18
+ salt: string;
19
+ }): SourceReader;
20
+ /** 账号那条路:登录态 → 钱包查询 → 投影成官方余额端点同一个形状。 */
21
+ export declare function accountReader(options: {
22
+ /** 取当前端口实现;服务可能晚到,所以这里必须是函数而不是实例。 */
23
+ account: () => AccountSource | undefined;
24
+ salt: string;
25
+ }): SourceReader;
26
+ //# sourceMappingURL=source-readers.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 两条取数路的读取策略。
3
+ *
4
+ * 账本({@link SourceLedger})只认这个接口,不认凭据、不认 HTTP —— 换来源就是换一个 reader。
5
+ * 三个方法都是**本地读**,只有 `read()` 会打上游。
6
+ * @module dsh-ds-balance/services/source-readers
7
+ */
8
+ import { endpointOf } from '../config.js';
9
+ import { projectAccountBalance } from '../domain/account.js';
10
+ import { NoKeyError } from '../domain/errors.js';
11
+ import { computeAccountTag } from './account-tag.js';
12
+ /** Key 那条路:解析链拿明文密钥 → 官方余额端点。 */
13
+ export function keyReader(options) {
14
+ const { keys, client, config, salt } = options;
15
+ return {
16
+ async available() {
17
+ try {
18
+ await keys.resolve();
19
+ return true;
20
+ }
21
+ catch {
22
+ // 解析链没取到值就是「这条路此刻不可用」,不是错误:上层据此回落另一条。
23
+ return false;
24
+ }
25
+ },
26
+ async tag() {
27
+ return computeAccountTag(salt, await keys.resolve());
28
+ },
29
+ async read() {
30
+ const apiKey = await keys.resolve();
31
+ const current = config.current();
32
+ const raw = await client.fetchBalance({
33
+ baseUrl: endpointOf(current),
34
+ apiKey,
35
+ timeoutMs: config.timeoutMs(),
36
+ });
37
+ return { raw, accountTag: computeAccountTag(salt, apiKey) };
38
+ },
39
+ };
40
+ }
41
+ /** 账号那条路:登录态 → 钱包查询 → 投影成官方余额端点同一个形状。 */
42
+ export function accountReader(options) {
43
+ const { account, salt } = options;
44
+ const require = () => {
45
+ const current = account();
46
+ if (current === undefined)
47
+ throw new NoKeyError('no credential: account service is unavailable');
48
+ return current;
49
+ };
50
+ /**
51
+ * 账号账本的派生输入。
52
+ *
53
+ * 用 `account:` 前缀把两个键空间分开:账号 id 与明文密钥因此不可能撞到同一个账本。
54
+ * 宿主本进程还没读过 profile 时 id 拿不到,退回 `unknown` —— 代价是那种情况下换账号不换账本,
55
+ * 由下一轮抓取修正(见决策记录的「已知代价」)。
56
+ */
57
+ const tagOf = async () => computeAccountTag(salt, `account:${(await require().accountId()) ?? 'unknown'}`);
58
+ return {
59
+ async available() {
60
+ const current = account();
61
+ return current !== undefined && (await current.signedIn());
62
+ },
63
+ tag: tagOf,
64
+ async read() {
65
+ const balance = await require().readBalance();
66
+ // `null` = 查询途中凭据被换掉/登出:此刻这条路的凭据没了,与「没有密钥」同一类事实。
67
+ if (balance === null)
68
+ throw new NoKeyError('no credential: account is not signed in');
69
+ return { raw: projectAccountBalance(balance), accountTag: await tagOf() };
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=source-readers.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 取数来源选择。纯函数,没有 `ctx`、没有 I/O。
3
+ *
4
+ * **本文件是「新增一个来源」要动的唯一一处逻辑**:加一行 `ROUTED`、把新来源排进
5
+ * `FALLBACK_ORDER`,再在装配处多建一份账本(reader + ledger)。账本状态机、调度、HTTP、
6
+ * 界面都不认识具体来源 —— 它们只认 {@link BalanceSource} 这个闭集与「哪条此刻活跃」。
7
+ *
8
+ * 规则是**四层回落**:
9
+ * 1. 当前会话的路由(客户端把会话级选择当提示带上来的);
10
+ * 2. 全局默认路由(提示缺席时宿主自己读);
11
+ * 3. 固定顺序(`FALLBACK_ORDER`);
12
+ * 4. 一条都不可用:回 {@link DEFAULT_SOURCE},让它自然报 `NO_KEY`(与只有一条路时逐字相同)。
13
+ *
14
+ * 完整判据见 [决策记录](../../.agents/notes/2026-09-30-account-balance-source.md)。
15
+ * @module dsh-ds-balance/services/source-selector
16
+ */
17
+ import type { BalanceSource } from '../domain/balance.js';
18
+ /** API Key 那条路的宿主 provider id。 */
19
+ export declare const KEY_PROVIDER = "deepseek-official";
20
+ /** 账号登录那条路的宿主 provider id。 */
21
+ export declare const ACCOUNT_PROVIDER = "deepseek-account";
22
+ /**
23
+ * 路由都指不出该走哪条时的固定顺序:**排在前的先被选中**。
24
+ *
25
+ * Key 优先是刻意的:它让「今天能用的用户」数字一字不变 —— 账号那条路是补上断掉的那条,
26
+ * 不是抢走已经在用的那条。
27
+ */
28
+ export declare const FALLBACK_ORDER: readonly BalanceSource[];
29
+ /** 一条都不可用时回落到谁。挑 Key 那条,因为它的 `NO_KEY` 文案是既有用户认识的那句。 */
30
+ export declare const DEFAULT_SOURCE: BalanceSource;
31
+ /** 路由判出来的来源;`'other'` 表示当前跑在别家供应商上(两条官方路都没在用)。 */
32
+ export type RoutedSource = BalanceSource | 'other' | null;
33
+ /**
34
+ * 把宿主 provider id 翻成我们认的来源。
35
+ *
36
+ * **只认 {@link ROUTED} 里列出的两条**:别家的余额插件够不着,也不该去猜。
37
+ * @param provider - 宿主给的路由 id;读不到时传 `null`。
38
+ * @returns 认得的来源,或 `'other'` / `null`。
39
+ */
40
+ export declare function routeOf(provider: string | null | undefined): RoutedSource;
41
+ /**
42
+ * 选这一轮走哪条路。
43
+ * @param input - 路由判据,以及逐来源的可用性(由门面从各账本问出来)。
44
+ * @returns 要用的来源。
45
+ */
46
+ export declare function pickSource(input: {
47
+ routed: RoutedSource;
48
+ available: (source: BalanceSource) => boolean;
49
+ }): BalanceSource;
50
+ //# sourceMappingURL=source-selector.d.ts.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 取数来源选择。纯函数,没有 `ctx`、没有 I/O。
3
+ *
4
+ * **本文件是「新增一个来源」要动的唯一一处逻辑**:加一行 `ROUTED`、把新来源排进
5
+ * `FALLBACK_ORDER`,再在装配处多建一份账本(reader + ledger)。账本状态机、调度、HTTP、
6
+ * 界面都不认识具体来源 —— 它们只认 {@link BalanceSource} 这个闭集与「哪条此刻活跃」。
7
+ *
8
+ * 规则是**四层回落**:
9
+ * 1. 当前会话的路由(客户端把会话级选择当提示带上来的);
10
+ * 2. 全局默认路由(提示缺席时宿主自己读);
11
+ * 3. 固定顺序(`FALLBACK_ORDER`);
12
+ * 4. 一条都不可用:回 {@link DEFAULT_SOURCE},让它自然报 `NO_KEY`(与只有一条路时逐字相同)。
13
+ *
14
+ * 完整判据见 [决策记录](../../.agents/notes/2026-09-30-account-balance-source.md)。
15
+ * @module dsh-ds-balance/services/source-selector
16
+ */
17
+ /** API Key 那条路的宿主 provider id。 */
18
+ export const KEY_PROVIDER = 'deepseek-official';
19
+ /** 账号登录那条路的宿主 provider id。 */
20
+ export const ACCOUNT_PROVIDER = 'deepseek-account';
21
+ /** 宿主 provider id → 本插件的取数来源。**认得的路只有这里列出的**。 */
22
+ const ROUTED = {
23
+ // 官方余额端点那条:凭据是 API Key(解析链见 KeyResolver)。
24
+ [KEY_PROVIDER]: 'deepseek-http',
25
+ // 宿主账号服务那条:凭据是账号登录态。
26
+ [ACCOUNT_PROVIDER]: 'deepseek-account',
27
+ };
28
+ /**
29
+ * 路由都指不出该走哪条时的固定顺序:**排在前的先被选中**。
30
+ *
31
+ * Key 优先是刻意的:它让「今天能用的用户」数字一字不变 —— 账号那条路是补上断掉的那条,
32
+ * 不是抢走已经在用的那条。
33
+ */
34
+ export const FALLBACK_ORDER = ['deepseek-http', 'deepseek-account'];
35
+ /** 一条都不可用时回落到谁。挑 Key 那条,因为它的 `NO_KEY` 文案是既有用户认识的那句。 */
36
+ export const DEFAULT_SOURCE = 'deepseek-http';
37
+ /**
38
+ * 把宿主 provider id 翻成我们认的来源。
39
+ *
40
+ * **只认 {@link ROUTED} 里列出的两条**:别家的余额插件够不着,也不该去猜。
41
+ * @param provider - 宿主给的路由 id;读不到时传 `null`。
42
+ * @returns 认得的来源,或 `'other'` / `null`。
43
+ */
44
+ export function routeOf(provider) {
45
+ if (provider === null || provider === undefined || provider === '')
46
+ return null;
47
+ return ROUTED[provider] ?? 'other';
48
+ }
49
+ /**
50
+ * 选这一轮走哪条路。
51
+ * @param input - 路由判据,以及逐来源的可用性(由门面从各账本问出来)。
52
+ * @returns 要用的来源。
53
+ */
54
+ export function pickSource(input) {
55
+ const { routed, available } = input;
56
+ if (routed !== null && routed !== 'other' && available(routed))
57
+ return routed;
58
+ for (const source of FALLBACK_ORDER) {
59
+ if (available(source))
60
+ return source;
61
+ }
62
+ return DEFAULT_SOURCE;
63
+ }
64
+ //# sourceMappingURL=source-selector.js.map
@@ -6,6 +6,13 @@ import type { ErrorCode } from '../domain/errors.ts';
6
6
  import type { UNREACHABLE_CODE } from './data.ts';
7
7
  /** 余额数据的状态维度。 */
8
8
  export type BalanceState = 'empty' | 'ok' | 'stale' | 'error';
9
+ /**
10
+ * 余额来源。
11
+ *
12
+ * 与宿主 `domain/balance.ts` 的 `BalanceSource` **逐字同形**:两个半体不共享值,
13
+ * 所以这里是自己抄的一份字面量,由 `test/client-data.test.ts` 对账。
14
+ */
15
+ export type BalanceSource = 'deepseek-http' | 'deepseek-account';
9
16
  /** 颜色严重度维度。前端只做机械映射,不比较金额。 */
10
17
  export type Severity = 'ok' | 'warn' | 'critical' | 'unavailable' | 'unknown';
11
18
  /** 估算来源。第一版 UI 不消费。 */
@@ -42,6 +49,15 @@ export interface BalanceError {
42
49
  export interface BalanceResponse {
43
50
  requestId: string;
44
51
  schemaVersion: number;
52
+ /** 这一份数字是哪条取数路给的。标题里的来源标签读它。 */
53
+ source: BalanceSource;
54
+ /**
55
+ * 处境:界面唯一的分支入口。
56
+ *
57
+ * **旧宿主没有这个字段**(客户端 HMR 换新、宿主重启才换),所以类型上可选,
58
+ * 读它必须过形状守卫 —— 见 [situation.ts](situation.ts) 的 `situationOfResponse`。
59
+ */
60
+ situation?: string;
45
61
  state: BalanceState;
46
62
  stale: boolean;
47
63
  fetchedAt: number;
@@ -50,13 +50,16 @@ export declare const UNREACHABLE_CODE = "PLUGIN_UNREACHABLE";
50
50
  /**
51
51
  * 「还没拿到数据」的占位视图。
52
52
  *
53
- * 空态而不是错误态:首拉还没回来时不该先闪一下「读取失败」。
53
+ * 处境是 `checking`:首帧**不该**先闪一下「尚未配置」—— 那是「没接入」的说法,
54
+ * 而我们此刻只是还没问到。
54
55
  * @returns 契约形状的响应。
55
56
  */
56
57
  export declare function pendingView(): BalanceResponse;
57
58
  /**
58
59
  * 「拿不到数据」的视图。
59
60
  *
61
+ * 处境是 `offline` —— **插件自己的端点不可达**,这与「接入了但上游读不到」
62
+ * (`fetch-failed`)是两件不同的事:后者我们至少知道自己问了谁。
60
63
  * 只在**一次都没成功过**的时候用;已经有数据时宁可继续显示旧值 ——
61
64
  * 后端自己的 `state: stale` 才是「数据过期」的权威表达。
62
65
  * @param message - 面向用户的一句话。
@@ -68,12 +71,16 @@ export declare function unreachableView(message: string): BalanceResponse;
68
71
  *
69
72
  * `displayCurrency` **总是**作为查询参数传出去,包括 `auto` —— 后端的挑选规则
70
73
  * 把 `auto` 当作「不指定」,所以传它不等于替后端做决定。
71
- * @param options - 显示币种、可注入的 fetch 与取消信号。
74
+ *
75
+ * `provider` 是**当前会话在用的路由**这条提示:后端据此选数据来源。读不到会话时
76
+ * 干脆不传,后端回落到全局默认路由 —— 提示缺席不该让界面失去来源。
77
+ * @param options - 显示币种、会话路由提示、可注入的 fetch 与取消信号。
72
78
  * @returns 后端响应。
73
79
  * @throws 端点不可达、非 2xx 或响应不是 JSON。
74
80
  */
75
81
  export declare function requestBalance(options: {
76
82
  currency: string;
83
+ provider?: string | undefined;
77
84
  fetchImpl?: FetchLike;
78
85
  signal?: AbortSignal;
79
86
  }): Promise<BalanceResponse>;
@@ -93,11 +100,13 @@ export declare function requestConfig(options?: {
93
100
  * 触发一次手动刷新。
94
101
  *
95
102
  * 后端的冷却还没过时它回 `triggered: false`,那是正常结果而不是错误。
96
- * @param options - 刷新原因与可注入的 fetch。
103
+ * `provider` 与读取余额时给的是同一条提示:刷新要刷**你正在看的那个来源**。
104
+ * @param options - 刷新原因、会话路由提示与可注入的 fetch。
97
105
  * @returns 后端的刷新结果。
98
106
  * @throws 端点不可达、非 2xx 或响应不是 JSON。
99
107
  */
100
108
  export declare function requestRefresh(options?: {
101
109
  reason?: string;
110
+ provider?: string | undefined;
102
111
  fetchImpl?: FetchLike;
103
112
  }): Promise<RefreshResult>;
@@ -16,6 +16,12 @@
16
16
  */
17
17
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
18
18
  import './sidebar/footer-stack.module.css';
19
+ import { type LocaleKey } from './locales.ts';
20
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
21
+ interface LocaleNamespaceMap {
22
+ 'ds-balance': LocaleKey;
23
+ }
24
+ }
19
25
  /**
20
26
  * 本插件那一行的 Loader 条目 id —— **逐字抄自宿主半边的 `src/config.ts`**。
21
27
  *