dsh-ds-balance 2.1.3 → 2.3.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 (50) hide show
  1. package/README.md +71 -18
  2. package/README_en.md +76 -18
  3. package/lib/adapters/domain-core-store.d.ts +46 -2
  4. package/lib/adapters/domain-core-store.js +144 -4
  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 +40 -1
  11. package/lib/domain/balance.js +20 -1
  12. package/lib/domain/normalize.d.ts +3 -2
  13. package/lib/domain/normalize.js +3 -2
  14. package/lib/domain/severity.d.ts +8 -2
  15. package/lib/domain/severity.js +11 -4
  16. package/lib/domain/situation.d.ts +104 -0
  17. package/lib/domain/situation.js +93 -0
  18. package/lib/http/handlers.d.ts +1 -1
  19. package/lib/http/handlers.js +29 -5
  20. package/lib/http/wire.d.ts +11 -1
  21. package/lib/http/wire.js +2 -0
  22. package/lib/index.js +122 -6
  23. package/lib/ports/account.d.ts +27 -0
  24. package/lib/ports/account.js +9 -0
  25. package/lib/ports/core-store.d.ts +15 -0
  26. package/lib/services/balance-service.d.ts +66 -94
  27. package/lib/services/balance-service.js +132 -196
  28. package/lib/services/balance-source.d.ts +187 -0
  29. package/lib/services/balance-source.js +320 -0
  30. package/lib/services/scheduler.d.ts +8 -2
  31. package/lib/services/scheduler.js +1 -1
  32. package/lib/services/source-readers.d.ts +28 -0
  33. package/lib/services/source-readers.js +83 -0
  34. package/lib/services/source-selector.d.ts +50 -0
  35. package/lib/services/source-selector.js +64 -0
  36. package/lib/types/client/api-types.d.ts +16 -0
  37. package/lib/types/client/data.d.ts +12 -3
  38. package/lib/types/client/index.d.ts +6 -0
  39. package/lib/types/client/locales.d.ts +10 -18
  40. package/lib/types/client/mock/index.d.ts +12 -11
  41. package/lib/types/client/mock/scenarios.d.ts +13 -5
  42. package/lib/types/client/model.d.ts +24 -25
  43. package/lib/types/client/route-hint.d.ts +61 -0
  44. package/lib/types/client/sidebar/BalancePopover.d.ts +7 -0
  45. package/lib/types/client/sidebar/PercentRing.d.ts +13 -5
  46. package/lib/types/client/sidebar/SidebarBalance.d.ts +9 -1
  47. package/lib/types/client/situation.d.ts +164 -0
  48. package/lib/version.d.ts +1 -1
  49. package/lib/version.js +1 -1
  50. package/package.json +25 -23
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 账号钱包的领域形状与投影。
3
+ *
4
+ * 账号那条路(`ctx.deepseekAccount`)给的是**钱包列表**,不是官方余额端点的形状。
5
+ * 投影成 {@link RawBalanceResponse} 之后,下游(归一化 → 存储 → 视图 → 界面)一行都不用改 ——
6
+ * 这也是为什么这个函数放在领域层:它是纯的,且对着的是我们自己那条线上形状。
7
+ * @module dsh-ds-balance/domain/account
8
+ */
9
+ import type { RawBalanceResponse } from './balance.js';
10
+ /** 一条钱包。金额是官方给的十进制字符串,原样带着,不改精度。 */
11
+ export interface AccountWallet {
12
+ readonly currency: string;
13
+ readonly balance: string;
14
+ }
15
+ /** 一次账号余额查询的结果:充值钱包与赠送钱包分开给。 */
16
+ export interface AccountBalance {
17
+ readonly wallets: readonly AccountWallet[];
18
+ readonly bonusWallets: readonly AccountWallet[];
19
+ }
20
+ /**
21
+ * 把官方允许的科学计数法展开成十进制定点。
22
+ *
23
+ * 账号侧的钱包金额走 Platform 的 Web 客户端语法,允许 `1e+3` / `0E-16` / `.5` 这类写法;
24
+ * 本仓的 {@link parseMoney} **只认十进制定点**(这是刻意的:线上金额从来不带指数)。
25
+ * 所以先把形状补齐,再交给领域层 —— 缺了这一步,一个 `1e+3` 会让整次抓取报 ParseError。
26
+ * @param value - 官方给的金额字符串。
27
+ * @returns 定点字符串;形状认不出来时原样返回,让下游的 `parseMoney` 去报错。
28
+ */
29
+ export declare function toPlainDecimal(value: string): string;
30
+ /**
31
+ * 把账号钱包投影成官方余额端点的线上形状。
32
+ *
33
+ * 口径对齐官方账号页:**总余额 = 充值钱包 + 赠送钱包**,两个分项各自照给。
34
+ * 两条路都出现同一个币种时合并,只在一侧出现的币种也照出。
35
+ * @param balance - 账号服务给出的钱包。
36
+ * @returns `GET /user/balance` 的形状,可直接进 {@link normalize}。
37
+ * @throws {ParseError} 金额不是十进制定点(含展开后仍不合法的形状)。
38
+ */
39
+ export declare function projectAccountBalance(balance: AccountBalance): RawBalanceResponse;
40
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 账号钱包的领域形状与投影。
3
+ *
4
+ * 账号那条路(`ctx.deepseekAccount`)给的是**钱包列表**,不是官方余额端点的形状。
5
+ * 投影成 {@link RawBalanceResponse} 之后,下游(归一化 → 存储 → 视图 → 界面)一行都不用改 ——
6
+ * 这也是为什么这个函数放在领域层:它是纯的,且对着的是我们自己那条线上形状。
7
+ * @module dsh-ds-balance/domain/account
8
+ */
9
+ import { formatMoney, parseMoney } from './money.js';
10
+ /**
11
+ * 把官方允许的科学计数法展开成十进制定点。
12
+ *
13
+ * 账号侧的钱包金额走 Platform 的 Web 客户端语法,允许 `1e+3` / `0E-16` / `.5` 这类写法;
14
+ * 本仓的 {@link parseMoney} **只认十进制定点**(这是刻意的:线上金额从来不带指数)。
15
+ * 所以先把形状补齐,再交给领域层 —— 缺了这一步,一个 `1e+3` 会让整次抓取报 ParseError。
16
+ * @param value - 官方给的金额字符串。
17
+ * @returns 定点字符串;形状认不出来时原样返回,让下游的 `parseMoney` 去报错。
18
+ */
19
+ export function toPlainDecimal(value) {
20
+ const text = value.trim();
21
+ const match = /^(-?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text);
22
+ if (match === null)
23
+ return text;
24
+ const [, sign = '', whole = '', fraction = '', exponent] = match;
25
+ if (exponent === undefined) {
26
+ return `${sign}${whole === '' ? '0' : whole}${fraction === '' ? '' : `.${fraction}`}`;
27
+ }
28
+ const digits = `${whole}${fraction}`;
29
+ if (digits === '')
30
+ return text;
31
+ const pointAt = whole.length + Number(exponent);
32
+ if (pointAt <= 0)
33
+ return `${sign}0.${'0'.repeat(-pointAt)}${digits}`;
34
+ if (pointAt >= digits.length)
35
+ return `${sign}${digits}${'0'.repeat(pointAt - digits.length)}`;
36
+ return `${sign}${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`;
37
+ }
38
+ /** 按币种把钱包折成最小单位;同币种出现多次时后者覆盖前者(官方不会给重复项)。 */
39
+ function walletTotals(wallets) {
40
+ const totals = new Map();
41
+ for (const wallet of wallets) {
42
+ totals.set(wallet.currency, parseMoney(toPlainDecimal(wallet.balance)));
43
+ }
44
+ return totals;
45
+ }
46
+ /**
47
+ * 把账号钱包投影成官方余额端点的线上形状。
48
+ *
49
+ * 口径对齐官方账号页:**总余额 = 充值钱包 + 赠送钱包**,两个分项各自照给。
50
+ * 两条路都出现同一个币种时合并,只在一侧出现的币种也照出。
51
+ * @param balance - 账号服务给出的钱包。
52
+ * @returns `GET /user/balance` 的形状,可直接进 {@link normalize}。
53
+ * @throws {ParseError} 金额不是十进制定点(含展开后仍不合法的形状)。
54
+ */
55
+ export function projectAccountBalance(balance) {
56
+ const toppedUp = walletTotals(balance.wallets);
57
+ const granted = walletTotals(balance.bonusWallets);
58
+ const currencies = [...new Set([...toppedUp.keys(), ...granted.keys()])];
59
+ return {
60
+ // 能读到钱包就说明账号可用;这与 `/user/balance` 的 `is_available` 同义。
61
+ is_available: true,
62
+ balance_infos: currencies.map((currency) => {
63
+ const topped = toppedUp.get(currency) ?? 0n;
64
+ const bonus = granted.get(currency) ?? 0n;
65
+ return {
66
+ currency,
67
+ total_balance: formatMoney(topped + bonus),
68
+ granted_balance: formatMoney(bonus),
69
+ topped_up_balance: formatMoney(topped),
70
+ };
71
+ }),
72
+ };
73
+ }
74
+ //# sourceMappingURL=account.js.map
@@ -6,8 +6,18 @@
6
6
  */
7
7
  import type { ErrorInfo } from './errors.js';
8
8
  import type { Units } from './money.js';
9
+ import type { Situation } from './situation.js';
9
10
  /** 币种代码,例如 `CNY` / `USD`。 */
10
11
  export type Currency = string;
12
+ /**
13
+ * 余额来源:官方的两条取数路。
14
+ *
15
+ * - `deepseek-http`:官方余额端点 `GET /user/balance`,凭据是 API Key(解析链见 `KeyResolver`)。
16
+ * - `deepseek-account`:宿主账号服务的钱包查询,凭据是账号登录态。
17
+ *
18
+ * **取值是追加式的**:已落盘的记录里只有前者,所以它必须一直是合法值(域版本不抬)。
19
+ */
20
+ export type BalanceSource = 'deepseek-http' | 'deepseek-account';
11
21
  /** 缓存状态。闭集。 */
12
22
  export type CacheState = 'empty' | 'ok' | 'stale' | 'error';
13
23
  /** 颜色严重度。闭集,前端只做机械映射。 */
@@ -32,10 +42,30 @@ export interface BalanceSnapshot {
32
42
  /** 全币种余额。 */
33
43
  balances: BalanceInfo[];
34
44
  /** 数据来源。 */
35
- source: 'deepseek-http';
45
+ source: BalanceSource;
36
46
  /** 原始响应,审计用。 */
37
47
  raw: unknown;
38
48
  }
49
+ /**
50
+ * 每个账本保留多少条快照。**历史保留深度,不是功能开关。**
51
+ *
52
+ * **为什么住在 domain**:写入路径(`SourceLedger.persist`)与启动清理
53
+ * (`DomainCoreStore.initialize`)都要读它,而 adapters 与 services 互相不能依赖 ——
54
+ * `domain/` 是两层共同的下游,是唯一能放这个常量的地方。
55
+ * (最初它住在 `balance-source.ts` 顶部,理由是「紧挨唯一调用点」;启动清理加入后
56
+ * 调用点变成两个,那条理由就不再成立。)
57
+ *
58
+ * - 当前**没有任何功能读第二条以后的记录**:唯一读点是 `loadLatestSnapshot` 取最新一条,
59
+ * 仓内没有 `listSnapshots`,也没有历史端点(`docs/ARCHITECTURE.md` 的阶段边界
60
+ * 明写不做图表)。所以这个数**不是**为功能服务的,不要因为「用不到」就调到 1。
61
+ * - 它真正的作用是**故障回退余量**:最新那条写失败、写坏、或落盘途中崩了时还能退到上一条,
62
+ * 覆盖连续两次失败就要 ≥ 3。
63
+ * - **刻意不进配置面**:配置字段用户看得见、改得动,而这个数没有用户可感知的语义 ——
64
+ * 改小它用户看不出界面变化,只会在某天丢快照时才后悔。给一个看不出区别的旋钮比不给更糟。
65
+ *
66
+ * 量级:一条快照约 0.9 KB,20 条约 18 KB —— 余量与存储成本之间没有取舍压力。
67
+ */
68
+ export declare const SNAPSHOT_KEEP_N = 20;
39
69
  /** 一个币种的预警 / 告急阈值。 */
40
70
  export interface ThresholdPair {
41
71
  warn: Units;
@@ -43,6 +73,15 @@ export interface ThresholdPair {
43
73
  }
44
74
  /** 给前端的余额视图。 */
45
75
  export interface BalanceView {
76
+ /** 这一份数字是哪条取数路给的。界面据此标来源。 */
77
+ source: BalanceSource;
78
+ /**
79
+ * 处境:界面唯一的分支入口。
80
+ *
81
+ * 环、文案、来源标签都从它派生 —— 界面**不再**自己组合 `state` / `severity` / `error.code`。
82
+ * 判定只有一处([situation.ts](situation.ts) 的 `situationOf`)。
83
+ */
84
+ situation: Situation;
46
85
  state: CacheState;
47
86
  stale: boolean;
48
87
  fetchedAt: number | null;
@@ -4,5 +4,24 @@
4
4
  * 这里定义的是**契约形状**:金额一律 `bigint` 最小单位,序列化时才变字符串。
5
5
  * @module dsh-ds-balance/domain/balance
6
6
  */
7
- export {};
7
+ /**
8
+ * 每个账本保留多少条快照。**历史保留深度,不是功能开关。**
9
+ *
10
+ * **为什么住在 domain**:写入路径(`SourceLedger.persist`)与启动清理
11
+ * (`DomainCoreStore.initialize`)都要读它,而 adapters 与 services 互相不能依赖 ——
12
+ * `domain/` 是两层共同的下游,是唯一能放这个常量的地方。
13
+ * (最初它住在 `balance-source.ts` 顶部,理由是「紧挨唯一调用点」;启动清理加入后
14
+ * 调用点变成两个,那条理由就不再成立。)
15
+ *
16
+ * - 当前**没有任何功能读第二条以后的记录**:唯一读点是 `loadLatestSnapshot` 取最新一条,
17
+ * 仓内没有 `listSnapshots`,也没有历史端点(`docs/ARCHITECTURE.md` 的阶段边界
18
+ * 明写不做图表)。所以这个数**不是**为功能服务的,不要因为「用不到」就调到 1。
19
+ * - 它真正的作用是**故障回退余量**:最新那条写失败、写坏、或落盘途中崩了时还能退到上一条,
20
+ * 覆盖连续两次失败就要 ≥ 3。
21
+ * - **刻意不进配置面**:配置字段用户看得见、改得动,而这个数没有用户可感知的语义 ——
22
+ * 改小它用户看不出界面变化,只会在某天丢快照时才后悔。给一个看不出区别的旋钮比不给更糟。
23
+ *
24
+ * 量级:一条快照约 0.9 KB,20 条约 18 KB —— 余量与存储成本之间没有取舍压力。
25
+ */
26
+ export const SNAPSHOT_KEEP_N = 20;
8
27
  //# sourceMappingURL=balance.js.map
@@ -5,7 +5,7 @@
5
5
  * **绝不静默归 0。**
6
6
  * @module dsh-ds-balance/domain/normalize
7
7
  */
8
- import type { BalanceSnapshot } from './balance.js';
8
+ import type { BalanceSnapshot, BalanceSource } from './balance.js';
9
9
  /**
10
10
  * 生成单调递增、可按字典序排序的快照 id。
11
11
  * @param now - 当前时刻。
@@ -17,11 +17,12 @@ export declare function nextSnapshotId(now: number): string;
17
17
  * @param raw - 上游 JSON,未经信任。
18
18
  * @param accountTag - 账本作用域标识。
19
19
  * @param now - 抓取时刻。
20
+ * @param source - 这份数据是哪条取数路给的。
20
21
  * @returns 不可变快照。
21
22
  * @throws {ShapeError} 结构不符。
22
23
  * @throws {ParseError} 金额不是十进制定点。
23
24
  */
24
- export declare function normalize(raw: unknown, accountTag: string, now: number): BalanceSnapshot;
25
+ export declare function normalize(raw: unknown, accountTag: string, now: number, source: BalanceSource): BalanceSnapshot;
25
26
  /** 从错误体里抽出的可读信息。 */
26
27
  export interface ParsedErrorBody {
27
28
  code?: string;
@@ -58,11 +58,12 @@ function parseAmount(value, field) {
58
58
  * @param raw - 上游 JSON,未经信任。
59
59
  * @param accountTag - 账本作用域标识。
60
60
  * @param now - 抓取时刻。
61
+ * @param source - 这份数据是哪条取数路给的。
61
62
  * @returns 不可变快照。
62
63
  * @throws {ShapeError} 结构不符。
63
64
  * @throws {ParseError} 金额不是十进制定点。
64
65
  */
65
- export function normalize(raw, accountTag, now) {
66
+ export function normalize(raw, accountTag, now, source) {
66
67
  if (raw === null || typeof raw !== 'object')
67
68
  throw new ShapeError('response is not an object');
68
69
  const body = raw;
@@ -76,7 +77,7 @@ export function normalize(raw, accountTag, now) {
76
77
  fetchedAt: now,
77
78
  isAvailable: body.is_available,
78
79
  balances: body.balance_infos.map((entry, index) => toBalanceInfo(entry, index)),
79
- source: 'deepseek-http',
80
+ source,
80
81
  raw,
81
82
  };
82
83
  }
@@ -24,8 +24,14 @@ export declare function thresholdsFor(currency: Currency, thresholds: Record<Cur
24
24
  /**
25
25
  * 判定严重度。
26
26
  *
27
- * 顺序即优先级:**不可用压过阈值**;没有任何选定币种时为 `unknown`。
28
- * @param selected - 选定的币种余额;为 `null` 时返回 `unknown`。
27
+ * **顺序即优先级,且顺序本身是有意的**:
28
+ * 1. **账户停用压过一切**(`!isAvailable`)—— 它是账户维度的事实,与有没有选中币种无关。
29
+ * 欠费/停用的账户上游可能给**空的** `balance_infos`,那也会有 `selected === null`;
30
+ * 若先判 `selected === null` 就会返回 `unknown`,界面把「账户停用」画成灰环,
31
+ * 与「账户本来就没有余额」同形 —— 这条顺序就是为它定的。
32
+ * 2. 没有任何选定币种(且账户可用)→ `unknown`:真的一无所有,没有信息可表。
33
+ * 3. 其余按阈值分档,边界取等号。
34
+ * @param selected - 选定的币种余额;为 `null` 时若账户不可用仍返回 `unavailable`。
29
35
  * @param isAvailable - 上游 `is_available`。
30
36
  * @param thresholds - 该币种的阈值。
31
37
  * @returns 闭集内的严重度。
@@ -24,17 +24,24 @@ export function thresholdsFor(currency, thresholds) {
24
24
  /**
25
25
  * 判定严重度。
26
26
  *
27
- * 顺序即优先级:**不可用压过阈值**;没有任何选定币种时为 `unknown`。
28
- * @param selected - 选定的币种余额;为 `null` 时返回 `unknown`。
27
+ * **顺序即优先级,且顺序本身是有意的**:
28
+ * 1. **账户停用压过一切**(`!isAvailable`)—— 它是账户维度的事实,与有没有选中币种无关。
29
+ * 欠费/停用的账户上游可能给**空的** `balance_infos`,那也会有 `selected === null`;
30
+ * 若先判 `selected === null` 就会返回 `unknown`,界面把「账户停用」画成灰环,
31
+ * 与「账户本来就没有余额」同形 —— 这条顺序就是为它定的。
32
+ * 2. 没有任何选定币种(且账户可用)→ `unknown`:真的一无所有,没有信息可表。
33
+ * 3. 其余按阈值分档,边界取等号。
34
+ * @param selected - 选定的币种余额;为 `null` 时若账户不可用仍返回 `unavailable`。
29
35
  * @param isAvailable - 上游 `is_available`。
30
36
  * @param thresholds - 该币种的阈值。
31
37
  * @returns 闭集内的严重度。
32
38
  */
33
39
  export function severityOf(selected, isAvailable, thresholds) {
34
- if (selected === null)
35
- return 'unknown';
40
+ // 顺序不能换:见上面第 1 条(停用 + 空余额列表是一条真实路径)。
36
41
  if (!isAvailable)
37
42
  return 'unavailable';
43
+ if (selected === null)
44
+ return 'unknown';
38
45
  if (cmpMoney(selected.total, thresholds.critical) <= 0)
39
46
  return 'critical';
40
47
  if (cmpMoney(selected.total, thresholds.warn) <= 0)
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 处境:界面上每一种形态对应的「用户此刻的处境」。
3
+ *
4
+ * **为什么要有它**:之前环、文案、来源标签**各拼各的** —— 环只吃 `severity`,文案吃
5
+ * `state + error.code + isAvailable + selection`,标签吃 `state + source`。三套输入集不同、
6
+ * 优先级也不同,于是必然出现「环说 A、文案说 B」(例如旧快照 + `isAvailable: false` 时
7
+ * 环是叉、文案却是「数据已过期」),也必然出现「一个空环同时代表三种处境」。
8
+ *
9
+ * 现在只有这一处分支:`situationOf` 按**固定优先级**逐条判,第一个命中即返回。
10
+ * 环、文案、标签都从它的输出派生,不再各自读字段。
11
+ *
12
+ * **闭集,取值有意细**:内部枚举可以细(诊断、日志、指标都要它),
13
+ * 视觉可以粗(收起态只有 5 个族,见客户端 `client/situation.ts` 的形态表)。
14
+ * 两者不必一一对应 —— 但**每一处合并都必须是有意的、写在表里的**,不是默认掉进去的。
15
+ * @module dsh-ds-balance/domain/situation
16
+ */
17
+ import type { Severity } from './balance.js';
18
+ import type { ErrorCode } from './errors.js';
19
+ /**
20
+ * 处境闭集。
21
+ *
22
+ * 前两个**只有客户端能产生**(宿主不可能知道「插件自己的端点通不通」与「首帧到了没有」),
23
+ * 其余九个由宿主判定。两个生产方、一张消费表。
24
+ */
25
+ export type Situation =
26
+ /** 首帧:还没问到后端。客户端本地事实。 */
27
+ 'checking'
28
+ /** 插件自己的端点不可达。客户端本地事实。 */
29
+ | 'offline'
30
+ /** handler 自己抛错 —— 「我们坏了」,不是「上游坏了」。 */
31
+ | 'internal-error'
32
+ /** 两条官方取数路都没有凭据 —— 需要用户去配置。 */
33
+ | 'no-credential'
34
+ /** 接入过(或本该接入)却读不到:网络 / 上游 / 账号查询失败,且没有旧快照。 */
35
+ | 'fetch-failed'
36
+ /** 读到了,但上游说这个账户不可用(停用 / 欠费)。 */
37
+ | 'account-unavailable'
38
+ /** 有旧快照,但本次没读到。 */
39
+ | 'stale'
40
+ /** 连上了、账户可用,但没有任何可展示的币种。 */
41
+ | 'empty-wallet'
42
+ /** 有数字,正常。 */
43
+ | 'ok'
44
+ /** 有数字,偏低。 */
45
+ | 'low'
46
+ /** 有数字,告急。 */
47
+ | 'critical';
48
+ /**
49
+ * {@link situationOf} 可能返回的处境。
50
+ *
51
+ * **注意不含 `internal-error` 与两个客户端值**:前者由 HTTP 层的兜底视图直接给
52
+ * (`handlers.ts` 的 `failureView` —— handler 自己抛错时根本走不到 `situationOf`),
53
+ * 后者只有客户端能产生。生产方是三分而不是两分,这张表把它写清楚。
54
+ */
55
+ export declare const SITUATIONS_FROM_FACTS: readonly ["no-credential", "fetch-failed", "account-unavailable", "stale", "empty-wallet", "ok", "low", "critical"];
56
+ /** 宿主能产生的处境 = 事实判出来的 + 兜底视图那一个。 */
57
+ export declare const HOST_SITUATIONS: readonly ["internal-error", "no-credential", "fetch-failed", "account-unavailable", "stale", "empty-wallet", "ok", "low", "critical"];
58
+ /** 客户端本地产生的处境(宿主不可能知道这两件事)。 */
59
+ export declare const CLIENT_SITUATIONS: readonly ["checking", "offline"];
60
+ /** 全部处境。 */
61
+ export declare const SITUATIONS: readonly ["checking", "offline", "internal-error", "no-credential", "fetch-failed", "account-unavailable", "stale", "empty-wallet", "ok", "low", "critical"];
62
+ /**
63
+ * 判定处境所需的事实。
64
+ *
65
+ * 刻意**只收事实、不收 `BalanceView`**:这样它可以脱离视图结构单测,
66
+ * 也让「哪些输入参与判定」在类型上就一目了然。
67
+ */
68
+ export interface SituationFacts {
69
+ /** 有没有快照(旧快照也算有)。 */
70
+ hasSnapshot: boolean;
71
+ /** 本次抓取是否失败、但快照还在(`state === 'stale'`)。 */
72
+ stale: boolean;
73
+ /** 上游 `is_available`;没有快照时无意义。 */
74
+ isAvailable: boolean;
75
+ /** 有没有选中可展示的币种。 */
76
+ hasSelected: boolean;
77
+ /** 后端算好的严重度。 */
78
+ severity: Severity;
79
+ /** 本次失败的对外错误码;没失败时 `null`。 */
80
+ errorCode: ErrorCode | null;
81
+ }
82
+ /**
83
+ * 判定处境。**顺序即优先级**,第一个命中即返回。
84
+ *
85
+ * 三条刻意的优先级(都对旧写法做过修正):
86
+ * 1. **没接入压过一切**:一条凭据都没有时,说「读不到余额」是误导 —— 用户没配过任何东西,
87
+ * 该说的是「尚未配置凭据」。判据是 `NO_KEY` **且没有快照**(有快照说明曾经读到过,
88
+ * 那就不是「没接入」,而是「这轮没读到」)。
89
+ * 2. **账户停用压过「数据过期」**:`isAvailable: false` 是上游明确给的事实,比「数字旧了」
90
+ * 更强的信号,且那种快照的余额通常是 0 —— 说「数据已过期」会把真问题藏起来。
91
+ * 3. **`stale` 的前提是「真的有一份旧数字」**(`hasSelected`)。快照在、但里面一个可展示的
92
+ * 币种都没有、这轮又没读到 —— 用户手里没有任何数字,说「数据已过期」是假话
93
+ * (听着像「有旧数据可看」)。归 `fetch-failed`:读不到才是实话。
94
+ *
95
+ * **不变量(界面按它设计,测试钉着)**:
96
+ * - 返回 `stale` / `ok` / `low` / `critical` ⇒ **一定有可展示的币种**
97
+ * (所以「有数字那一族」的颜色永远由真实阈值算出来,不会是 `unknown`);
98
+ * - 返回 `account-unavailable` ⇒ `isAvailable` 一定是 `false`,颜色恒红。
99
+ *
100
+ * @param facts - 判定所需的六个事实。
101
+ * @returns 闭集内的处境。
102
+ */
103
+ export declare function situationOf(facts: SituationFacts): Situation;
104
+ //# sourceMappingURL=situation.d.ts.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * 处境:界面上每一种形态对应的「用户此刻的处境」。
3
+ *
4
+ * **为什么要有它**:之前环、文案、来源标签**各拼各的** —— 环只吃 `severity`,文案吃
5
+ * `state + error.code + isAvailable + selection`,标签吃 `state + source`。三套输入集不同、
6
+ * 优先级也不同,于是必然出现「环说 A、文案说 B」(例如旧快照 + `isAvailable: false` 时
7
+ * 环是叉、文案却是「数据已过期」),也必然出现「一个空环同时代表三种处境」。
8
+ *
9
+ * 现在只有这一处分支:`situationOf` 按**固定优先级**逐条判,第一个命中即返回。
10
+ * 环、文案、标签都从它的输出派生,不再各自读字段。
11
+ *
12
+ * **闭集,取值有意细**:内部枚举可以细(诊断、日志、指标都要它),
13
+ * 视觉可以粗(收起态只有 5 个族,见客户端 `client/situation.ts` 的形态表)。
14
+ * 两者不必一一对应 —— 但**每一处合并都必须是有意的、写在表里的**,不是默认掉进去的。
15
+ * @module dsh-ds-balance/domain/situation
16
+ */
17
+ /**
18
+ * {@link situationOf} 可能返回的处境。
19
+ *
20
+ * **注意不含 `internal-error` 与两个客户端值**:前者由 HTTP 层的兜底视图直接给
21
+ * (`handlers.ts` 的 `failureView` —— handler 自己抛错时根本走不到 `situationOf`),
22
+ * 后者只有客户端能产生。生产方是三分而不是两分,这张表把它写清楚。
23
+ */
24
+ export const SITUATIONS_FROM_FACTS = [
25
+ 'no-credential',
26
+ 'fetch-failed',
27
+ 'account-unavailable',
28
+ 'stale',
29
+ 'empty-wallet',
30
+ 'ok',
31
+ 'low',
32
+ 'critical',
33
+ ];
34
+ /** 宿主能产生的处境 = 事实判出来的 + 兜底视图那一个。 */
35
+ export const HOST_SITUATIONS = [
36
+ 'internal-error',
37
+ ...SITUATIONS_FROM_FACTS,
38
+ ];
39
+ /** 客户端本地产生的处境(宿主不可能知道这两件事)。 */
40
+ export const CLIENT_SITUATIONS = ['checking', 'offline'];
41
+ /** 全部处境。 */
42
+ export const SITUATIONS = [
43
+ ...CLIENT_SITUATIONS,
44
+ ...HOST_SITUATIONS,
45
+ ];
46
+ /**
47
+ * 判定处境。**顺序即优先级**,第一个命中即返回。
48
+ *
49
+ * 三条刻意的优先级(都对旧写法做过修正):
50
+ * 1. **没接入压过一切**:一条凭据都没有时,说「读不到余额」是误导 —— 用户没配过任何东西,
51
+ * 该说的是「尚未配置凭据」。判据是 `NO_KEY` **且没有快照**(有快照说明曾经读到过,
52
+ * 那就不是「没接入」,而是「这轮没读到」)。
53
+ * 2. **账户停用压过「数据过期」**:`isAvailable: false` 是上游明确给的事实,比「数字旧了」
54
+ * 更强的信号,且那种快照的余额通常是 0 —— 说「数据已过期」会把真问题藏起来。
55
+ * 3. **`stale` 的前提是「真的有一份旧数字」**(`hasSelected`)。快照在、但里面一个可展示的
56
+ * 币种都没有、这轮又没读到 —— 用户手里没有任何数字,说「数据已过期」是假话
57
+ * (听着像「有旧数据可看」)。归 `fetch-failed`:读不到才是实话。
58
+ *
59
+ * **不变量(界面按它设计,测试钉着)**:
60
+ * - 返回 `stale` / `ok` / `low` / `critical` ⇒ **一定有可展示的币种**
61
+ * (所以「有数字那一族」的颜色永远由真实阈值算出来,不会是 `unknown`);
62
+ * - 返回 `account-unavailable` ⇒ `isAvailable` 一定是 `false`,颜色恒红。
63
+ *
64
+ * @param facts - 判定所需的六个事实。
65
+ * @returns 闭集内的处境。
66
+ */
67
+ export function situationOf(facts) {
68
+ // ①② 没有快照:分清「没接入」与「接入了但读不到」。
69
+ if (!facts.hasSnapshot) {
70
+ return facts.errorCode === 'NO_KEY' ? 'no-credential' : 'fetch-failed';
71
+ }
72
+ // ③ 账户维度的事实压过新鲜度:停用比「数字旧」更该说。
73
+ if (!facts.isAvailable)
74
+ return 'account-unavailable';
75
+ // ④ 没有可展示的币种:分清「这轮没读到」与「账户本来就没有」。
76
+ // 前者说「读不到」才是实话 —— 用户手里一个数字都没有,没有「旧数据」可谈;
77
+ // 这也是「有数字那一族」颜色不会是 unknown 的结构保证(见上面不变量)。
78
+ if (!facts.hasSelected)
79
+ return facts.stale ? 'fetch-failed' : 'empty-wallet';
80
+ // ⑤ 有旧快照、也有数字,但本轮没读到:数字照常显示,文案说它旧。
81
+ if (facts.stale)
82
+ return 'stale';
83
+ // ⑥ 有数字,按阈值分档。`unavailable` / `unknown` 在上面已经被接走,
84
+ // 真漏到这里说明判定链有洞 —— 归到最保守的「读不到」,不假装正常。
85
+ if (facts.severity === 'unavailable' || facts.severity === 'unknown')
86
+ return 'fetch-failed';
87
+ if (facts.severity === 'critical')
88
+ return 'critical';
89
+ if (facts.severity === 'warn')
90
+ return 'low';
91
+ return 'ok';
92
+ }
93
+ //# sourceMappingURL=situation.js.map
@@ -34,7 +34,7 @@ export interface HttpDeps {
34
34
  }
35
35
  /** `GET /api/v1/balance`。**状态码始终 200**,业务错误走 `state` + `error`。 */
36
36
  export declare function handleBalance(request: Request, deps: HttpDeps): Promise<Response>;
37
- /** `POST /api/v1/balance/refresh`。请求体 `{ reason }`,可省略。 */
37
+ /** `POST /api/v1/balance/refresh`。请求体 `{ reason, provider }`,都可省略。 */
38
38
  export declare function handleRefresh(request: Request, deps: HttpDeps): Promise<Response>;
39
39
  /** `GET /api/v1/config`。**掩码是手动的** —— 本响应自己构造,不走 settings 读取。 */
40
40
  export declare function handleConfigGet(_request: Request, deps: HttpDeps): Promise<Response>;
@@ -10,6 +10,7 @@
10
10
  * @module dsh-ds-balance/http/handlers
11
11
  */
12
12
  import { classify, describeError } from '../domain/errors.js';
13
+ import { situationOf } from '../domain/situation.js';
13
14
  import { CONFIG_FIELDS, endpointOf, validateThresholds } from '../config.js';
14
15
  import { PLUGIN_VERSION, SCHEMA_VERSION } from '../version.js';
15
16
  import { newRequestId, toWireBalanceView, toWireError, } from './wire.js';
@@ -48,15 +49,21 @@ async function readJsonObject(request) {
48
49
  }
49
50
  /**
50
51
  * 空视图:任何 handler 在彻底失败时都回它,保证响应形状仍然合法。
52
+ *
53
+ * 处境固定 `internal-error`:走到这里说明**我们自己的代码抛了**(装配错误之类),
54
+ * 那是「我们坏了」而不是「上游坏了」—— 两者在界面上是不同的处境,不能混。
51
55
  * @param requestId - 本次请求的标识。
52
56
  * @param error - 触发失败的异常。
53
57
  * @param state - 要报告的状态;余额端点固定 `error`。
58
+ * @param source - 当时活跃的来源;取不到就按 Key 那条报(界面只拿它选来源标签)。
54
59
  * @returns 契约 §8.3 的响应体。
55
60
  */
56
- function failureView(requestId, error, state = 'error') {
61
+ function failureView(requestId, error, state = 'error', source = 'deepseek-http') {
57
62
  return {
58
63
  requestId,
59
64
  schemaVersion: SCHEMA_VERSION,
65
+ source,
66
+ situation: 'internal-error',
60
67
  state,
61
68
  stale: false,
62
69
  fetchedAt: 0,
@@ -92,7 +99,11 @@ export async function handleBalance(request, deps) {
92
99
  const requestId = newRequestId();
93
100
  try {
94
101
  const currency = queryParam(request, 'currency');
95
- const view = await deps.service.getView(currency === null || currency === '' ? {} : { currency });
102
+ const providerHint = queryParam(request, 'provider');
103
+ const view = await deps.service.getView({
104
+ ...(currency === null || currency === '' ? {} : { currency }),
105
+ providerHint,
106
+ });
96
107
  return json(toWireBalanceView(view, deps.service.accountTag8() ?? '', requestId));
97
108
  }
98
109
  catch (error) {
@@ -100,17 +111,19 @@ export async function handleBalance(request, deps) {
100
111
  requestId,
101
112
  error: describeError(error),
102
113
  });
103
- return json(failureView(requestId, error));
114
+ return json(failureView(requestId, error, 'error', deps.service.activeSource()));
104
115
  }
105
116
  }
106
- /** `POST /api/v1/balance/refresh`。请求体 `{ reason }`,可省略。 */
117
+ /** `POST /api/v1/balance/refresh`。请求体 `{ reason, provider }`,都可省略。 */
107
118
  export async function handleRefresh(request, deps) {
108
119
  const requestId = newRequestId();
109
120
  try {
110
121
  const body = await readJsonObject(request);
111
122
  const rawReason = body?.reason;
112
123
  const reason = typeof rawReason === 'string' && rawReason !== '' ? rawReason : 'manual';
113
- const result = await deps.service.forceRefresh(reason);
124
+ const rawProvider = body?.provider;
125
+ const providerHint = typeof rawProvider === 'string' ? rawProvider : null;
126
+ const result = await deps.service.forceRefresh(reason, providerHint);
114
127
  return json({ requestId, schemaVersion: SCHEMA_VERSION, ...result, error: null });
115
128
  }
116
129
  catch (error) {
@@ -294,7 +307,18 @@ export async function handleHealthz(_request, deps) {
294
307
  requestId,
295
308
  schemaVersion: SCHEMA_VERSION,
296
309
  version: PLUGIN_VERSION,
310
+ // 当前活跃来源:两条路并存时,这是唯一能一眼看出「现在服务的是哪条」的地方。
311
+ source: status.source,
297
312
  state: status.state,
313
+ /** 活跃账本此刻会画成什么 —— 不开浏览器也能知道界面长什么样。 */
314
+ situation: situationOf({
315
+ hasSnapshot: status.hasSnapshot,
316
+ stale: status.state === 'stale',
317
+ isAvailable: status.isAvailable,
318
+ hasSelected: status.hasSelected,
319
+ severity: status.severity,
320
+ errorCode: status.errorCode,
321
+ }),
298
322
  lastSuccessAt: status.lastSuccessAt,
299
323
  consecutiveFailures: status.consecutiveFailures,
300
324
  scheduler: { running: deps.scheduler.isRunning(), nextRunAt: deps.scheduler.nextRunAt() },
@@ -5,8 +5,9 @@
5
5
  * `bigint` 最小单位只活在进程内。本模块是纯函数,不碰 `ctx`、不发请求。
6
6
  * @module dsh-ds-balance/http/wire
7
7
  */
8
- import type { BalanceView, CacheState, Currency, Severity } from '../domain/balance.js';
8
+ import type { BalanceSource, BalanceView, CacheState, Currency, Severity } from '../domain/balance.js';
9
9
  import type { ErrorCode, ErrorInfo } from '../domain/errors.js';
10
+ import type { Situation } from '../domain/situation.js';
10
11
  /** 一个币种的余额,金额是八位小数字符串。 */
11
12
  export interface WireBalance {
12
13
  currency: Currency;
@@ -46,6 +47,15 @@ export interface WireCredentialInfo {
46
47
  export interface WireBalanceResponse {
47
48
  requestId: string;
48
49
  schemaVersion: number;
50
+ /** 这一份数字是哪条取数路给的;界面据此在标题里标来源。 */
51
+ source: BalanceSource;
52
+ /**
53
+ * 处境:界面**唯一**的分支入口。
54
+ *
55
+ * 环 / 文案 / 来源标签都由它派生。`state` 与 `severity` 仍留在契约里(旧客户端要活),
56
+ * 但新界面不再读它们做形态判断 —— 见 [domain/situation.ts](../domain/situation.ts)。
57
+ */
58
+ situation: Situation;
49
59
  state: CacheState;
50
60
  stale: boolean;
51
61
  fetchedAt: number;
package/lib/http/wire.js CHANGED
@@ -44,6 +44,8 @@ export function toWireBalanceView(view, accountTag8, requestId) {
44
44
  return {
45
45
  requestId,
46
46
  schemaVersion: SCHEMA_VERSION,
47
+ source: view.source,
48
+ situation: view.situation,
47
49
  state: view.state,
48
50
  stale: view.stale,
49
51
  fetchedAt: view.fetchedAt ?? 0,