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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 归一化与错误体解析。
|
|
3
|
+
*
|
|
4
|
+
* 两条铁律:结构不符抛 {@link ShapeError},金额解析失败抛 {@link ParseError}。
|
|
5
|
+
* **绝不静默归 0。**
|
|
6
|
+
* @module dsh-ds-balance/domain/normalize
|
|
7
|
+
*/
|
|
8
|
+
import type { BalanceSnapshot } from './balance.js';
|
|
9
|
+
/**
|
|
10
|
+
* 生成单调递增、可按字典序排序的快照 id。
|
|
11
|
+
* @param now - 当前时刻。
|
|
12
|
+
* @returns 形如 `<时间戳 base36>-<序号 base36>` 的 id。
|
|
13
|
+
*/
|
|
14
|
+
export declare function nextSnapshotId(now: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* 把上游响应归一化成快照。
|
|
17
|
+
* @param raw - 上游 JSON,未经信任。
|
|
18
|
+
* @param accountTag - 账本作用域标识。
|
|
19
|
+
* @param now - 抓取时刻。
|
|
20
|
+
* @returns 不可变快照。
|
|
21
|
+
* @throws {ShapeError} 结构不符。
|
|
22
|
+
* @throws {ParseError} 金额不是十进制定点。
|
|
23
|
+
*/
|
|
24
|
+
export declare function normalize(raw: unknown, accountTag: string, now: number): BalanceSnapshot;
|
|
25
|
+
/** 从错误体里抽出的可读信息。 */
|
|
26
|
+
export interface ParsedErrorBody {
|
|
27
|
+
code?: string;
|
|
28
|
+
message?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 容错解析上游错误体。
|
|
32
|
+
*
|
|
33
|
+
* 官方未文档化,实测至少三种形状,逐条尝试后兜底成截断的原文。
|
|
34
|
+
* @param text - 响应正文。
|
|
35
|
+
* @returns 尽力抽出的 code 与 message。
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseErrorBody(text: string): ParsedErrorBody;
|
|
38
|
+
//# sourceMappingURL=normalize.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 归一化与错误体解析。
|
|
3
|
+
*
|
|
4
|
+
* 两条铁律:结构不符抛 {@link ShapeError},金额解析失败抛 {@link ParseError}。
|
|
5
|
+
* **绝不静默归 0。**
|
|
6
|
+
* @module dsh-ds-balance/domain/normalize
|
|
7
|
+
*/
|
|
8
|
+
import { ParseError, ShapeError } from './errors.js';
|
|
9
|
+
import { parseMoney } from './money.js';
|
|
10
|
+
let lastStamp = 0;
|
|
11
|
+
let counter = 0;
|
|
12
|
+
/**
|
|
13
|
+
* 生成单调递增、可按字典序排序的快照 id。
|
|
14
|
+
* @param now - 当前时刻。
|
|
15
|
+
* @returns 形如 `<时间戳 base36>-<序号 base36>` 的 id。
|
|
16
|
+
*/
|
|
17
|
+
export function nextSnapshotId(now) {
|
|
18
|
+
if (now === lastStamp)
|
|
19
|
+
counter += 1;
|
|
20
|
+
else {
|
|
21
|
+
lastStamp = now;
|
|
22
|
+
counter = 0;
|
|
23
|
+
}
|
|
24
|
+
return `${now.toString(36).padStart(9, '0')}-${counter.toString(36).padStart(3, '0')}`;
|
|
25
|
+
}
|
|
26
|
+
/** 校验一个币种条目。 */
|
|
27
|
+
function toBalanceInfo(entry, index) {
|
|
28
|
+
if (entry === null || typeof entry !== 'object') {
|
|
29
|
+
throw new ShapeError(`balance_infos[${index}] is not an object`);
|
|
30
|
+
}
|
|
31
|
+
const row = entry;
|
|
32
|
+
if (typeof row.currency !== 'string' || row.currency.trim() === '') {
|
|
33
|
+
throw new ShapeError(`balance_infos[${index}].currency is not a non-empty string`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
currency: row.currency,
|
|
37
|
+
total: parseAmount(row.total_balance, `balance_infos[${index}].total_balance`),
|
|
38
|
+
granted: parseAmount(row.granted_balance, `balance_infos[${index}].granted_balance`),
|
|
39
|
+
toppedUp: parseAmount(row.topped_up_balance, `balance_infos[${index}].topped_up_balance`),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** 解析一个金额字段,带字段名以便定位。 */
|
|
43
|
+
function parseAmount(value, field) {
|
|
44
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
45
|
+
throw new ShapeError(`${field} is not a string`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return parseMoney(value);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof ParseError)
|
|
52
|
+
throw new ParseError(`${field}: ${error.message}`, { cause: error });
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 把上游响应归一化成快照。
|
|
58
|
+
* @param raw - 上游 JSON,未经信任。
|
|
59
|
+
* @param accountTag - 账本作用域标识。
|
|
60
|
+
* @param now - 抓取时刻。
|
|
61
|
+
* @returns 不可变快照。
|
|
62
|
+
* @throws {ShapeError} 结构不符。
|
|
63
|
+
* @throws {ParseError} 金额不是十进制定点。
|
|
64
|
+
*/
|
|
65
|
+
export function normalize(raw, accountTag, now) {
|
|
66
|
+
if (raw === null || typeof raw !== 'object')
|
|
67
|
+
throw new ShapeError('response is not an object');
|
|
68
|
+
const body = raw;
|
|
69
|
+
if (typeof body.is_available !== 'boolean')
|
|
70
|
+
throw new ShapeError('is_available is not a boolean');
|
|
71
|
+
if (!Array.isArray(body.balance_infos))
|
|
72
|
+
throw new ShapeError('balance_infos is not an array');
|
|
73
|
+
return {
|
|
74
|
+
snapshotId: nextSnapshotId(now),
|
|
75
|
+
accountTag,
|
|
76
|
+
fetchedAt: now,
|
|
77
|
+
isAvailable: body.is_available,
|
|
78
|
+
balances: body.balance_infos.map((entry, index) => toBalanceInfo(entry, index)),
|
|
79
|
+
source: 'deepseek-http',
|
|
80
|
+
raw,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 容错解析上游错误体。
|
|
85
|
+
*
|
|
86
|
+
* 官方未文档化,实测至少三种形状,逐条尝试后兜底成截断的原文。
|
|
87
|
+
* @param text - 响应正文。
|
|
88
|
+
* @returns 尽力抽出的 code 与 message。
|
|
89
|
+
*/
|
|
90
|
+
export function parseErrorBody(text) {
|
|
91
|
+
const trimmed = text.trim();
|
|
92
|
+
if (trimmed === '')
|
|
93
|
+
return {};
|
|
94
|
+
let body;
|
|
95
|
+
try {
|
|
96
|
+
body = JSON.parse(trimmed);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { message: trimmed.slice(0, 200) };
|
|
100
|
+
}
|
|
101
|
+
if (body === null || typeof body !== 'object')
|
|
102
|
+
return { message: String(body).slice(0, 200) };
|
|
103
|
+
const record = body;
|
|
104
|
+
const nested = record.error;
|
|
105
|
+
if (nested !== null && typeof nested === 'object') {
|
|
106
|
+
const inner = nested;
|
|
107
|
+
const code = inner.type ?? inner.code;
|
|
108
|
+
const message = inner.message;
|
|
109
|
+
return {
|
|
110
|
+
...(typeof code === 'string' && code !== '' ? { code } : {}),
|
|
111
|
+
...(typeof message === 'string' ? { message } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (typeof record.detail === 'string')
|
|
115
|
+
return { message: record.detail };
|
|
116
|
+
if (typeof record.message === 'string')
|
|
117
|
+
return { message: record.message };
|
|
118
|
+
return { message: JSON.stringify(body).slice(0, 200) };
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=normalize.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多币种选择。
|
|
3
|
+
*
|
|
4
|
+
* **后端权威**:前端不再自己挑币种,只读 `selected`。数组顺序可能跳变,
|
|
5
|
+
* 所以这里先做一次稳定排序再走优先级链。
|
|
6
|
+
* @module dsh-ds-balance/domain/select
|
|
7
|
+
*/
|
|
8
|
+
import type { BalanceInfo } from './balance.js';
|
|
9
|
+
/** `displayCurrency` 的「跟随账户」取值。 */
|
|
10
|
+
export declare const AUTO_CURRENCY = "auto";
|
|
11
|
+
/**
|
|
12
|
+
* 稳定排序:`CNY` 排到最前,其余保持原有相对顺序。
|
|
13
|
+
* @param balances - 上游给的数组,顺序不可依赖。
|
|
14
|
+
* @returns 新数组,不改动入参。
|
|
15
|
+
*/
|
|
16
|
+
export declare function stableOrder(balances: readonly BalanceInfo[]): BalanceInfo[];
|
|
17
|
+
/**
|
|
18
|
+
* 按偏好挑选要展示的币种。
|
|
19
|
+
*
|
|
20
|
+
* 优先级:偏好币种(且余额 > 0)→ `CNY` 且 > 0 → 任一 > 0 → `CNY` → 第一个 → `null`。
|
|
21
|
+
* @param balances - 上游给的数组。
|
|
22
|
+
* @param preferred - 前端传来的 `displayCurrency`;`auto` 或空表示不指定。
|
|
23
|
+
* @returns 选中的余额项,或 `null` 表示账户没有任何币种。
|
|
24
|
+
*/
|
|
25
|
+
export declare function pickBalance(balances: readonly BalanceInfo[], preferred?: string): BalanceInfo | null;
|
|
26
|
+
//# sourceMappingURL=select.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多币种选择。
|
|
3
|
+
*
|
|
4
|
+
* **后端权威**:前端不再自己挑币种,只读 `selected`。数组顺序可能跳变,
|
|
5
|
+
* 所以这里先做一次稳定排序再走优先级链。
|
|
6
|
+
* @module dsh-ds-balance/domain/select
|
|
7
|
+
*/
|
|
8
|
+
import { isZeroMoney } from './money.js';
|
|
9
|
+
/** `displayCurrency` 的「跟随账户」取值。 */
|
|
10
|
+
export const AUTO_CURRENCY = 'auto';
|
|
11
|
+
/**
|
|
12
|
+
* 稳定排序:`CNY` 排到最前,其余保持原有相对顺序。
|
|
13
|
+
* @param balances - 上游给的数组,顺序不可依赖。
|
|
14
|
+
* @returns 新数组,不改动入参。
|
|
15
|
+
*/
|
|
16
|
+
export function stableOrder(balances) {
|
|
17
|
+
return [...balances].sort((left, right) => {
|
|
18
|
+
if (left.currency === right.currency)
|
|
19
|
+
return 0;
|
|
20
|
+
if (left.currency === 'CNY')
|
|
21
|
+
return -1;
|
|
22
|
+
if (right.currency === 'CNY')
|
|
23
|
+
return 1;
|
|
24
|
+
return 0;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 按偏好挑选要展示的币种。
|
|
29
|
+
*
|
|
30
|
+
* 优先级:偏好币种(且余额 > 0)→ `CNY` 且 > 0 → 任一 > 0 → `CNY` → 第一个 → `null`。
|
|
31
|
+
* @param balances - 上游给的数组。
|
|
32
|
+
* @param preferred - 前端传来的 `displayCurrency`;`auto` 或空表示不指定。
|
|
33
|
+
* @returns 选中的余额项,或 `null` 表示账户没有任何币种。
|
|
34
|
+
*/
|
|
35
|
+
export function pickBalance(balances, preferred) {
|
|
36
|
+
if (balances.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const ordered = stableOrder(balances);
|
|
39
|
+
const wanted = preferred?.trim() ?? '';
|
|
40
|
+
if (wanted !== '' && wanted !== AUTO_CURRENCY) {
|
|
41
|
+
const hit = ordered.find((item) => item.currency.toUpperCase() === wanted.toUpperCase() && !isZeroMoney(item.total));
|
|
42
|
+
if (hit !== undefined)
|
|
43
|
+
return hit;
|
|
44
|
+
}
|
|
45
|
+
return ordered.find((item) => item.currency === 'CNY' && !isZeroMoney(item.total))
|
|
46
|
+
?? ordered.find((item) => !isZeroMoney(item.total))
|
|
47
|
+
?? ordered.find((item) => item.currency === 'CNY')
|
|
48
|
+
?? ordered[0]
|
|
49
|
+
?? null;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=select.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 严重度判定。
|
|
3
|
+
*
|
|
4
|
+
* **阈值只在这里被读**:前端不参与任何金额比较,颜色完全来自本模块的输出。
|
|
5
|
+
* @module dsh-ds-balance/domain/severity
|
|
6
|
+
*/
|
|
7
|
+
import type { BalanceInfo, Currency, Severity, ThresholdPair } from './balance.js';
|
|
8
|
+
/** 判定阈值所需的配置切片。 */
|
|
9
|
+
export interface ThresholdConfig {
|
|
10
|
+
cnyWarn: number;
|
|
11
|
+
cnyCritical: number;
|
|
12
|
+
usdWarn: number;
|
|
13
|
+
usdCritical: number;
|
|
14
|
+
}
|
|
15
|
+
/** 阈值按币种取;没有专属配置的币种一律 `{ warn: 0, critical: 0 }`。 */
|
|
16
|
+
export declare function thresholdsOf(config: ThresholdConfig): Record<Currency, ThresholdPair>;
|
|
17
|
+
/**
|
|
18
|
+
* 取某个币种的阈值。
|
|
19
|
+
* @param currency - 币种代码。
|
|
20
|
+
* @param thresholds - {@link thresholdsOf} 的产物。
|
|
21
|
+
* @returns 该币种的阈值;没有配置时返回全零,使该币种永远判为 `ok`。
|
|
22
|
+
*/
|
|
23
|
+
export declare function thresholdsFor(currency: Currency, thresholds: Record<Currency, ThresholdPair>): ThresholdPair;
|
|
24
|
+
/**
|
|
25
|
+
* 判定严重度。
|
|
26
|
+
*
|
|
27
|
+
* 顺序即优先级:**不可用压过阈值**;没有任何选定币种时为 `unknown`。
|
|
28
|
+
* @param selected - 选定的币种余额;为 `null` 时返回 `unknown`。
|
|
29
|
+
* @param isAvailable - 上游 `is_available`。
|
|
30
|
+
* @param thresholds - 该币种的阈值。
|
|
31
|
+
* @returns 闭集内的严重度。
|
|
32
|
+
*/
|
|
33
|
+
export declare function severityOf(selected: BalanceInfo | null, isAvailable: boolean, thresholds: ThresholdPair): Severity;
|
|
34
|
+
//# sourceMappingURL=severity.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 严重度判定。
|
|
3
|
+
*
|
|
4
|
+
* **阈值只在这里被读**:前端不参与任何金额比较,颜色完全来自本模块的输出。
|
|
5
|
+
* @module dsh-ds-balance/domain/severity
|
|
6
|
+
*/
|
|
7
|
+
import { cmpMoney, parseMoney } from './money.js';
|
|
8
|
+
/** 阈值按币种取;没有专属配置的币种一律 `{ warn: 0, critical: 0 }`。 */
|
|
9
|
+
export function thresholdsOf(config) {
|
|
10
|
+
return {
|
|
11
|
+
CNY: { warn: parseMoney(config.cnyWarn), critical: parseMoney(config.cnyCritical) },
|
|
12
|
+
USD: { warn: parseMoney(config.usdWarn), critical: parseMoney(config.usdCritical) },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 取某个币种的阈值。
|
|
17
|
+
* @param currency - 币种代码。
|
|
18
|
+
* @param thresholds - {@link thresholdsOf} 的产物。
|
|
19
|
+
* @returns 该币种的阈值;没有配置时返回全零,使该币种永远判为 `ok`。
|
|
20
|
+
*/
|
|
21
|
+
export function thresholdsFor(currency, thresholds) {
|
|
22
|
+
return thresholds[currency] ?? { warn: 0n, critical: 0n };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 判定严重度。
|
|
26
|
+
*
|
|
27
|
+
* 顺序即优先级:**不可用压过阈值**;没有任何选定币种时为 `unknown`。
|
|
28
|
+
* @param selected - 选定的币种余额;为 `null` 时返回 `unknown`。
|
|
29
|
+
* @param isAvailable - 上游 `is_available`。
|
|
30
|
+
* @param thresholds - 该币种的阈值。
|
|
31
|
+
* @returns 闭集内的严重度。
|
|
32
|
+
*/
|
|
33
|
+
export function severityOf(selected, isAvailable, thresholds) {
|
|
34
|
+
if (selected === null)
|
|
35
|
+
return 'unknown';
|
|
36
|
+
if (!isAvailable)
|
|
37
|
+
return 'unavailable';
|
|
38
|
+
if (cmpMoney(selected.total, thresholds.critical) <= 0)
|
|
39
|
+
return 'critical';
|
|
40
|
+
if (cmpMoney(selected.total, thresholds.warn) <= 0)
|
|
41
|
+
return 'warn';
|
|
42
|
+
return 'ok';
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=severity.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 六个端点的处理实现。
|
|
3
|
+
*
|
|
4
|
+
* 依赖全部构造注入、**不碰 `ctx`** —— 因此它能脱离宿主直接测。
|
|
5
|
+
*
|
|
6
|
+
* 铁律(见 docs/backend-architecture.md §19 第 3、4 条):
|
|
7
|
+
* - **任何 handler 都不许把异常抛出去**。抛出去会被宿主包成 500,前端就拿不到
|
|
8
|
+
* `error` 结构了。余额端点的业务错误一律走 `200 + state: error`。
|
|
9
|
+
* - **每次请求现读配置**,不许在闭包里捕获旧值。
|
|
10
|
+
* @module dsh-ds-balance/http/handlers
|
|
11
|
+
*/
|
|
12
|
+
import type { CoreStore } from '../ports/core-store.js';
|
|
13
|
+
import type { DeepSeekClient } from '../ports/deepseek-client.js';
|
|
14
|
+
import type { Credentials } from '../ports/credentials.js';
|
|
15
|
+
import type { Logger } from '../ports/logger.js';
|
|
16
|
+
import type { ReadableMetrics } from '../ports/metrics.js';
|
|
17
|
+
import type { BalanceService } from '../services/balance-service.js';
|
|
18
|
+
import type { ConfigService } from '../services/config-service.js';
|
|
19
|
+
import type { KeyResolver } from '../services/key-resolver.js';
|
|
20
|
+
import type { Scheduler } from '../services/scheduler.js';
|
|
21
|
+
/** 一套 handler 需要的全部依赖。 */
|
|
22
|
+
export interface HttpDeps {
|
|
23
|
+
service: BalanceService;
|
|
24
|
+
config: ConfigService;
|
|
25
|
+
keys: KeyResolver;
|
|
26
|
+
client: DeepSeekClient;
|
|
27
|
+
store: CoreStore;
|
|
28
|
+
scheduler: Scheduler;
|
|
29
|
+
logger?: Logger | undefined;
|
|
30
|
+
/** 可读出聚合值的指标;没接线时返回 `null` 而不是编一份空的。 */
|
|
31
|
+
metrics?: ReadableMetrics | undefined;
|
|
32
|
+
/** 凭据端口。只用来读「配没配 / 可不可写」,**永远不读值**。 */
|
|
33
|
+
credentials?: Credentials | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** `GET /api/v1/balance`。**状态码始终 200**,业务错误走 `state` + `error`。 */
|
|
36
|
+
export declare function handleBalance(request: Request, deps: HttpDeps): Promise<Response>;
|
|
37
|
+
/** `POST /api/v1/balance/refresh`。请求体 `{ reason }`,可省略。 */
|
|
38
|
+
export declare function handleRefresh(request: Request, deps: HttpDeps): Promise<Response>;
|
|
39
|
+
/** `GET /api/v1/config`。**掩码是手动的** —— 本响应自己构造,不走 settings 读取。 */
|
|
40
|
+
export declare function handleConfigGet(_request: Request, deps: HttpDeps): Promise<Response>;
|
|
41
|
+
/** `POST /api/v1/config`。请求是任意字段子集;形状不合法或校验失败一律 `422`。 */
|
|
42
|
+
export declare function handleConfigUpdate(request: Request, deps: HttpDeps): Promise<Response>;
|
|
43
|
+
/** `POST /api/v1/test-connection`。**不动活动缓存。** */
|
|
44
|
+
export declare function handleTestConnection(request: Request, deps: HttpDeps): Promise<Response>;
|
|
45
|
+
/** `GET /api/v1/healthz`。 */
|
|
46
|
+
export declare function handleHealthz(_request: Request, deps: HttpDeps): Promise<Response>;
|
|
47
|
+
//# sourceMappingURL=handlers.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 六个端点的处理实现。
|
|
3
|
+
*
|
|
4
|
+
* 依赖全部构造注入、**不碰 `ctx`** —— 因此它能脱离宿主直接测。
|
|
5
|
+
*
|
|
6
|
+
* 铁律(见 docs/backend-architecture.md §19 第 3、4 条):
|
|
7
|
+
* - **任何 handler 都不许把异常抛出去**。抛出去会被宿主包成 500,前端就拿不到
|
|
8
|
+
* `error` 结构了。余额端点的业务错误一律走 `200 + state: error`。
|
|
9
|
+
* - **每次请求现读配置**,不许在闭包里捕获旧值。
|
|
10
|
+
* @module dsh-ds-balance/http/handlers
|
|
11
|
+
*/
|
|
12
|
+
import { classify } from '../domain/errors.js';
|
|
13
|
+
import { CONFIG_FIELDS } from '../config.js';
|
|
14
|
+
import { PLUGIN_VERSION, SCHEMA_VERSION } from '../version.js';
|
|
15
|
+
import { newRequestId, toWireBalanceView, toWireError, } from './wire.js';
|
|
16
|
+
/** `testConnection` 超时的可接受区间;越界一律回落默认值。 */
|
|
17
|
+
const TEST_TIMEOUT_RANGE = { min: 1000, max: 60_000 };
|
|
18
|
+
/** 未配置 API Key 时回给「测试连接」的错误文案。 */
|
|
19
|
+
const NO_KEY_MESSAGE = 'no API key configured';
|
|
20
|
+
/** 统一的 JSON 响应头。 */
|
|
21
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' };
|
|
22
|
+
/** 造一个 JSON 响应。 */
|
|
23
|
+
function json(body, status = 200) {
|
|
24
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
25
|
+
}
|
|
26
|
+
/** 是否是普通对象(数组与 `null` 都不算)。 */
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
/** 把未知异常压成一行。**绝不带凭据** —— 异常里不含密钥是上游的约定。 */
|
|
31
|
+
function describe(error) {
|
|
32
|
+
return error instanceof Error ? error.message : String(error);
|
|
33
|
+
}
|
|
34
|
+
/** 读一个查询参数;URL 坏掉时返回 `null`。 */
|
|
35
|
+
function queryParam(request, name) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(request.url).searchParams.get(name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 读一个 JSON 对象请求体;不是对象、不是 JSON、没有体一律 `null`。 */
|
|
44
|
+
async function readJsonObject(request) {
|
|
45
|
+
try {
|
|
46
|
+
const body = await request.json();
|
|
47
|
+
return isPlainObject(body) ? body : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 空视图:任何 handler 在彻底失败时都回它,保证响应形状仍然合法。
|
|
55
|
+
* @param requestId - 本次请求的标识。
|
|
56
|
+
* @param error - 触发失败的异常。
|
|
57
|
+
* @param state - 要报告的状态;余额端点固定 `error`。
|
|
58
|
+
* @returns 契约 §8.3 的响应体。
|
|
59
|
+
*/
|
|
60
|
+
function failureView(requestId, error, state = 'error') {
|
|
61
|
+
return {
|
|
62
|
+
requestId,
|
|
63
|
+
schemaVersion: SCHEMA_VERSION,
|
|
64
|
+
state,
|
|
65
|
+
stale: false,
|
|
66
|
+
fetchedAt: 0,
|
|
67
|
+
ageMs: 0,
|
|
68
|
+
isAvailable: false,
|
|
69
|
+
accountTag8: '',
|
|
70
|
+
balances: [],
|
|
71
|
+
selected: null,
|
|
72
|
+
severity: 'unknown',
|
|
73
|
+
thresholds: {},
|
|
74
|
+
todayUsage: null,
|
|
75
|
+
error: toWireError(classify(error)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** 从配置里挑出 `GET /api/v1/config` 要回传的字段。**`apiKey` 永不出现在这里。** */
|
|
79
|
+
function publicConfig(config) {
|
|
80
|
+
const { apiKey: _secret, ...rest } = config;
|
|
81
|
+
return rest;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `apiKey` 的掩码。
|
|
85
|
+
*
|
|
86
|
+
* **不回传密钥的任何片段**:只回答「配没配」。回一个固定长度的星号串是为了让
|
|
87
|
+
* 字段名 `apiKeyMasked` 名副其实,同时不给任何爆破线索。
|
|
88
|
+
* @param value - 当前生效的 `apiKey`。
|
|
89
|
+
* @returns 已配置时是固定长度的掩码串,未配置时是空串。
|
|
90
|
+
*/
|
|
91
|
+
function maskApiKey(value) {
|
|
92
|
+
return value.trim() === '' ? '' : '********';
|
|
93
|
+
}
|
|
94
|
+
/** `GET /api/v1/balance`。**状态码始终 200**,业务错误走 `state` + `error`。 */
|
|
95
|
+
export async function handleBalance(request, deps) {
|
|
96
|
+
const requestId = newRequestId();
|
|
97
|
+
try {
|
|
98
|
+
const currency = queryParam(request, 'currency');
|
|
99
|
+
const view = await deps.service.getView(currency === null || currency === '' ? {} : { currency });
|
|
100
|
+
return json(toWireBalanceView(view, deps.service.accountTag8() ?? '', requestId));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
deps.logger?.error('ds-balance: balance handler failed', { requestId, error: describe(error) });
|
|
104
|
+
return json(failureView(requestId, error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** `POST /api/v1/balance/refresh`。请求体 `{ reason }`,可省略。 */
|
|
108
|
+
export async function handleRefresh(request, deps) {
|
|
109
|
+
const requestId = newRequestId();
|
|
110
|
+
try {
|
|
111
|
+
const body = await readJsonObject(request);
|
|
112
|
+
const rawReason = body?.reason;
|
|
113
|
+
const reason = typeof rawReason === 'string' && rawReason !== '' ? rawReason : 'manual';
|
|
114
|
+
const result = await deps.service.forceRefresh(reason);
|
|
115
|
+
return json({ requestId, schemaVersion: SCHEMA_VERSION, ...result, error: null });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
deps.logger?.error('ds-balance: refresh handler failed', { requestId, error: describe(error) });
|
|
119
|
+
return json({
|
|
120
|
+
requestId,
|
|
121
|
+
schemaVersion: SCHEMA_VERSION,
|
|
122
|
+
triggered: false,
|
|
123
|
+
joined: false,
|
|
124
|
+
cooldownMs: 0,
|
|
125
|
+
state: 'error',
|
|
126
|
+
error: toWireError(classify(error)),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 读凭据的只读描述。
|
|
132
|
+
*
|
|
133
|
+
* 失败一律回 `null`:界面据此退化成「不知道,就先当只读」,而不是把整张卡片打挂。
|
|
134
|
+
* @param deps - 注入的依赖。
|
|
135
|
+
* @param ref - 当前生效的凭据引用名。
|
|
136
|
+
* @returns 三个事实,或 `null`。
|
|
137
|
+
*/
|
|
138
|
+
async function credentialInfo(deps, ref) {
|
|
139
|
+
const credentials = deps.credentials;
|
|
140
|
+
if (credentials === undefined)
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
const described = await credentials.describe(ref);
|
|
144
|
+
return {
|
|
145
|
+
ref,
|
|
146
|
+
configured: described.configured === true,
|
|
147
|
+
source: described.source ?? null,
|
|
148
|
+
writable: described.writable === true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
deps.logger?.debug('ds-balance: credential describe failed', { ref, error: describe(error) });
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** 组装配置响应体。**`apiKey` 永不出现;掩码是手动的。** */
|
|
157
|
+
async function configBody(requestId, deps) {
|
|
158
|
+
const config = deps.config.current();
|
|
159
|
+
return {
|
|
160
|
+
requestId,
|
|
161
|
+
schemaVersion: SCHEMA_VERSION,
|
|
162
|
+
config: publicConfig(config),
|
|
163
|
+
apiKeyMasked: maskApiKey(config.apiKey),
|
|
164
|
+
credential: await credentialInfo(deps, config.apiKeyRef),
|
|
165
|
+
timeoutMs: deps.config.timeoutMs(),
|
|
166
|
+
error: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** `GET /api/v1/config`。**掩码是手动的** —— 本响应自己构造,不走 settings 读取。 */
|
|
170
|
+
export async function handleConfigGet(_request, deps) {
|
|
171
|
+
const requestId = newRequestId();
|
|
172
|
+
try {
|
|
173
|
+
return json(await configBody(requestId, deps));
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
deps.logger?.error('ds-balance: config read handler failed', { requestId, error: describe(error) });
|
|
177
|
+
return json({ requestId, schemaVersion: SCHEMA_VERSION, error: toWireError(classify(error)) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** `POST /api/v1/config`。请求是任意字段子集;形状不合法或校验失败一律 `422`。 */
|
|
181
|
+
export async function handleConfigUpdate(request, deps) {
|
|
182
|
+
const requestId = newRequestId();
|
|
183
|
+
let patch;
|
|
184
|
+
try {
|
|
185
|
+
const body = await readJsonObject(request);
|
|
186
|
+
if (body === null)
|
|
187
|
+
return json({ requestId, error: { code: 'VALIDATION', message: 'body must be a JSON object', retryable: false } }, 422);
|
|
188
|
+
const unknown = Object.keys(body).filter((key) => !CONFIG_FIELDS.includes(key));
|
|
189
|
+
if (unknown.length > 0) {
|
|
190
|
+
return json({ requestId, error: { code: 'VALIDATION', message: `unknown config field: ${unknown.join(', ')}`, retryable: false } }, 422);
|
|
191
|
+
}
|
|
192
|
+
patch = body;
|
|
193
|
+
if (Object.keys(patch).length > 0)
|
|
194
|
+
await deps.config.update(patch);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
deps.logger?.warn('ds-balance: config write rejected', { requestId, error: describe(error) });
|
|
198
|
+
return json({ requestId, error: toWireError(classify(error)) }, 422);
|
|
199
|
+
}
|
|
200
|
+
// 写完立刻回读,让调用方看到落盘后的真实值(掩码同上)。
|
|
201
|
+
try {
|
|
202
|
+
return json(await configBody(requestId, deps));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
deps.logger?.error('ds-balance: config readback failed', { requestId, error: describe(error) });
|
|
206
|
+
return json({ requestId, schemaVersion: SCHEMA_VERSION, error: toWireError(classify(error)) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** `POST /api/v1/test-connection`。**不动活动缓存。** */
|
|
210
|
+
export async function handleTestConnection(request, deps) {
|
|
211
|
+
const requestId = newRequestId();
|
|
212
|
+
try {
|
|
213
|
+
const body = await readJsonObject(request);
|
|
214
|
+
const config = deps.config.current();
|
|
215
|
+
const rawBaseUrl = body?.baseUrl;
|
|
216
|
+
const baseUrl = typeof rawBaseUrl === 'string' && rawBaseUrl.trim() !== '' ? rawBaseUrl.trim() : config.baseUrl;
|
|
217
|
+
const rawTimeout = Number(body?.timeoutMs);
|
|
218
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout >= TEST_TIMEOUT_RANGE.min && rawTimeout <= TEST_TIMEOUT_RANGE.max
|
|
219
|
+
? Math.trunc(rawTimeout)
|
|
220
|
+
: deps.config.timeoutMs();
|
|
221
|
+
const rawApiKey = body?.apiKey;
|
|
222
|
+
const apiKey = typeof rawApiKey === 'string' && rawApiKey.trim() !== ''
|
|
223
|
+
? rawApiKey.trim()
|
|
224
|
+
: await deps.keys.resolve().catch(() => null);
|
|
225
|
+
if (apiKey === null)
|
|
226
|
+
return json({ requestId, ok: false, latencyMs: 0, code: 'NO_KEY', message: NO_KEY_MESSAGE });
|
|
227
|
+
const result = await deps.client.testConnection({ baseUrl, apiKey, timeoutMs });
|
|
228
|
+
return json({ requestId, schemaVersion: SCHEMA_VERSION, ...result });
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
deps.logger?.warn('ds-balance: test-connection handler failed', { requestId, error: describe(error) });
|
|
232
|
+
const info = classify(error);
|
|
233
|
+
return json({ requestId, schemaVersion: SCHEMA_VERSION, ok: false, latencyMs: 0, code: info.code, message: info.message });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** `GET /api/v1/healthz`。 */
|
|
237
|
+
export async function handleHealthz(_request, deps) {
|
|
238
|
+
const requestId = newRequestId();
|
|
239
|
+
const status = deps.service.status();
|
|
240
|
+
let store = { ok: false, detail: 'health check failed' };
|
|
241
|
+
try {
|
|
242
|
+
const health = await deps.store.health();
|
|
243
|
+
store = { ok: health.ok, detail: health.detail ?? null };
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
store = { ok: false, detail: describe(error) };
|
|
247
|
+
}
|
|
248
|
+
return json({
|
|
249
|
+
requestId,
|
|
250
|
+
schemaVersion: SCHEMA_VERSION,
|
|
251
|
+
version: PLUGIN_VERSION,
|
|
252
|
+
state: status.state,
|
|
253
|
+
lastSuccessAt: status.lastSuccessAt,
|
|
254
|
+
consecutiveFailures: status.consecutiveFailures,
|
|
255
|
+
scheduler: { running: deps.scheduler.isRunning(), nextRunAt: deps.scheduler.nextRunAt() },
|
|
256
|
+
store,
|
|
257
|
+
// 默认组合没有指标 sink,所以把聚合值挂在这里暴露;没接线时是 null。
|
|
258
|
+
metrics: deps.metrics?.snapshot() ?? null,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=handlers.js.map
|