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
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件配置:schemastery schema、常量与派生值。
|
|
3
|
+
*
|
|
4
|
+
* 字段名与 [UI 侧契约与移交](../docs/ui-handoff.md) 第六节逐字一致,共 **11** 个。
|
|
5
|
+
* `timeoutMs` **不在 schema 里**:它是常量加环境变量覆盖,UI 不暴露。
|
|
6
|
+
* @module dsh-ds-balance/config
|
|
7
|
+
*/
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
/** 设置命名空间:与浏览器半边逐字一致,是两半的配对键。 */
|
|
10
|
+
export declare const SETTINGS_NAMESPACE = "ds-balance";
|
|
11
|
+
/** `displayCurrency` 的「跟随账户」取值。 */
|
|
12
|
+
export declare const CURRENCY_AUTO = "auto";
|
|
13
|
+
/** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
|
|
14
|
+
export declare const DEFAULT_API_KEY_REF = "DEEPSEEK_API_KEY";
|
|
15
|
+
/** 超时常量。UI 不暴露,改环境变量即可。 */
|
|
16
|
+
export declare const DEFAULT_TIMEOUT_MS = 8000;
|
|
17
|
+
/** 覆盖超时的环境变量名。 */
|
|
18
|
+
export declare const TIMEOUT_ENV = "DS_BALANCE_TIMEOUT_MS";
|
|
19
|
+
/** 超时的合法区间。 */
|
|
20
|
+
export declare const TIMEOUT_RANGE: {
|
|
21
|
+
readonly min: 1000;
|
|
22
|
+
readonly max: 60000;
|
|
23
|
+
};
|
|
24
|
+
/** 插件配置。 */
|
|
25
|
+
export interface Config {
|
|
26
|
+
/** DeepSeek API Key。**用户显式覆盖**用;留空则走引用名解析。 */
|
|
27
|
+
apiKey: string;
|
|
28
|
+
/** 凭据引用名。必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`。 */
|
|
29
|
+
apiKeyRef: string;
|
|
30
|
+
/** 端点基址。**端点独立**,不继承对话适配器。 */
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** 服务端刷新频率(秒)。 */
|
|
33
|
+
serverRefreshSeconds: number;
|
|
34
|
+
/** 客户端轮询频率(秒)。 */
|
|
35
|
+
clientPollSeconds: number;
|
|
36
|
+
/** 手动刷新冷却(秒)。 */
|
|
37
|
+
manualRefreshCooldownSeconds: number;
|
|
38
|
+
/** 展示币种;`auto` 表示跟随账户。 */
|
|
39
|
+
displayCurrency: string;
|
|
40
|
+
/** CNY 预警阈值。 */
|
|
41
|
+
cnyWarn: number;
|
|
42
|
+
/** CNY 告急阈值。 */
|
|
43
|
+
cnyCritical: number;
|
|
44
|
+
/** USD 预警阈值。 */
|
|
45
|
+
usdWarn: number;
|
|
46
|
+
/** USD 告急阈值。 */
|
|
47
|
+
usdCritical: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 配置字段名闭集。
|
|
51
|
+
*
|
|
52
|
+
* 供 `POST /api/v1/config` 过滤请求体用:不在表里的键一律 `422`,避免脏键被
|
|
53
|
+
* 悄悄写进用户层。**顺序与数量由 `test/config-fields.test.ts` 对着 schema 兜底**,
|
|
54
|
+
* 不靠人记。
|
|
55
|
+
*/
|
|
56
|
+
export declare const CONFIG_FIELDS: readonly ["apiKey", "apiKeyRef", "baseUrl", "serverRefreshSeconds", "clientPollSeconds", "manualRefreshCooldownSeconds", "displayCurrency", "cnyWarn", "cnyCritical", "usdWarn", "usdCritical"];
|
|
57
|
+
/**
|
|
58
|
+
* 配置 schema。加载期校验,非法配置 fail loud。
|
|
59
|
+
*
|
|
60
|
+
* 阈值只在这里存储,前端不做金额比较:颜色由后端的 `severity` 决定。
|
|
61
|
+
*/
|
|
62
|
+
export declare const Config: z<Schemastery.ObjectS<{
|
|
63
|
+
apiKey: z<string, string>;
|
|
64
|
+
apiKeyRef: z<string, string>;
|
|
65
|
+
baseUrl: z<string, string>;
|
|
66
|
+
serverRefreshSeconds: z<number, number>;
|
|
67
|
+
clientPollSeconds: z<number, number>;
|
|
68
|
+
manualRefreshCooldownSeconds: z<number, number>;
|
|
69
|
+
displayCurrency: z<string, string>;
|
|
70
|
+
cnyWarn: z<number, number>;
|
|
71
|
+
cnyCritical: z<number, number>;
|
|
72
|
+
usdWarn: z<number, number>;
|
|
73
|
+
usdCritical: z<number, number>;
|
|
74
|
+
}>, Schemastery.ObjectT<{
|
|
75
|
+
apiKey: z<string, string>;
|
|
76
|
+
apiKeyRef: z<string, string>;
|
|
77
|
+
baseUrl: z<string, string>;
|
|
78
|
+
serverRefreshSeconds: z<number, number>;
|
|
79
|
+
clientPollSeconds: z<number, number>;
|
|
80
|
+
manualRefreshCooldownSeconds: z<number, number>;
|
|
81
|
+
displayCurrency: z<string, string>;
|
|
82
|
+
cnyWarn: z<number, number>;
|
|
83
|
+
cnyCritical: z<number, number>;
|
|
84
|
+
usdWarn: z<number, number>;
|
|
85
|
+
usdCritical: z<number, number>;
|
|
86
|
+
}>>;
|
|
87
|
+
/** 一对阈值:同一币种内的预警与告急。 */
|
|
88
|
+
export interface ThresholdPair {
|
|
89
|
+
readonly currency: string;
|
|
90
|
+
readonly warn: keyof Config;
|
|
91
|
+
readonly critical: keyof Config;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 阈值成对的清单。
|
|
95
|
+
*
|
|
96
|
+
* 字段名的约定是「币种代码小写 + Warn / Critical」;客户端按同一条约定把这两个字段
|
|
97
|
+
* 归成一对来判([use-config-form.ts](client/settings/use-config-form.ts) 的 `THRESHOLD_PAIRS`)。
|
|
98
|
+
* 两边各存一份是因为**宿主与浏览器两个半体不许值导入**;约定本身由测试对着本表兜底。
|
|
99
|
+
*/
|
|
100
|
+
export declare const THRESHOLD_PAIRS: readonly ThresholdPair[];
|
|
101
|
+
/**
|
|
102
|
+
* 跨字段校验:每个币种内 `critical` 必须**严格低于** `warn`。
|
|
103
|
+
*
|
|
104
|
+
* 挂在 `ctx.settings.register` 的 `validate` 上,不挂在 schema 上:
|
|
105
|
+
* schemastery 没有 refine / superRefine 这类跨字段钩子,而
|
|
106
|
+
* `SettingsRegisterOptions.validate` 正是为「schema 表达不了的约束」准备的 ——
|
|
107
|
+
* 它拿到的是**合并后、schema 已通过**的完整候选值,抛错即拒绝写入、什么都不落盘。
|
|
108
|
+
*
|
|
109
|
+
* **它在合并后的完整值上跑**,所以单字段写入会让中间态短暂非法;
|
|
110
|
+
* 客户端把成对的写入排过序(`orderPairWrites`),保证每一步中间态都合法。
|
|
111
|
+
*
|
|
112
|
+
* 为什么必须严格低于、不能相等:两者相等时余额恰好压线会被同时判成 warn 与 critical,
|
|
113
|
+
* 「预警」这一档就不存在了。措辞锚在**告急**上 —— 用户要调的是那个偏低的数。
|
|
114
|
+
* @param value - 合并后的完整配置。
|
|
115
|
+
* @throws {Error} 违反约束时抛出;消息指向具体币种,便于用户定位。
|
|
116
|
+
*/
|
|
117
|
+
export declare function validateThresholds(value: Config): void;
|
|
118
|
+
/**
|
|
119
|
+
* 解析当前生效的超时。
|
|
120
|
+
*
|
|
121
|
+
* **每次请求都调用它** —— 改环境变量后立即生效,不需要重启。
|
|
122
|
+
* 越界或不可解析一律回落常量。
|
|
123
|
+
* @param env - 环境变量表;默认 `process.env`,测试可注入。
|
|
124
|
+
* @returns 毫秒数。
|
|
125
|
+
*/
|
|
126
|
+
export declare function resolveTimeoutMs(env?: Record<string, string | undefined>): number;
|
|
127
|
+
//# sourceMappingURL=config.d.ts.map
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件配置:schemastery schema、常量与派生值。
|
|
3
|
+
*
|
|
4
|
+
* 字段名与 [UI 侧契约与移交](../docs/ui-handoff.md) 第六节逐字一致,共 **11** 个。
|
|
5
|
+
* `timeoutMs` **不在 schema 里**:它是常量加环境变量覆盖,UI 不暴露。
|
|
6
|
+
* @module dsh-ds-balance/config
|
|
7
|
+
*/
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
import { DEFAULT_BASE_URL } from './ports/deepseek-client.js';
|
|
10
|
+
/** 设置命名空间:与浏览器半边逐字一致,是两半的配对键。 */
|
|
11
|
+
export const SETTINGS_NAMESPACE = 'ds-balance';
|
|
12
|
+
/** `displayCurrency` 的「跟随账户」取值。 */
|
|
13
|
+
export const CURRENCY_AUTO = 'auto';
|
|
14
|
+
/** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
|
|
15
|
+
export const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY';
|
|
16
|
+
/** 超时常量。UI 不暴露,改环境变量即可。 */
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 8000;
|
|
18
|
+
/** 覆盖超时的环境变量名。 */
|
|
19
|
+
export const TIMEOUT_ENV = 'DS_BALANCE_TIMEOUT_MS';
|
|
20
|
+
/** 超时的合法区间。 */
|
|
21
|
+
export const TIMEOUT_RANGE = { min: 1000, max: 60_000 };
|
|
22
|
+
/**
|
|
23
|
+
* 配置字段名闭集。
|
|
24
|
+
*
|
|
25
|
+
* 供 `POST /api/v1/config` 过滤请求体用:不在表里的键一律 `422`,避免脏键被
|
|
26
|
+
* 悄悄写进用户层。**顺序与数量由 `test/config-fields.test.ts` 对着 schema 兜底**,
|
|
27
|
+
* 不靠人记。
|
|
28
|
+
*/
|
|
29
|
+
export const CONFIG_FIELDS = [
|
|
30
|
+
'apiKey',
|
|
31
|
+
'apiKeyRef',
|
|
32
|
+
'baseUrl',
|
|
33
|
+
'serverRefreshSeconds',
|
|
34
|
+
'clientPollSeconds',
|
|
35
|
+
'manualRefreshCooldownSeconds',
|
|
36
|
+
'displayCurrency',
|
|
37
|
+
'cnyWarn',
|
|
38
|
+
'cnyCritical',
|
|
39
|
+
'usdWarn',
|
|
40
|
+
'usdCritical',
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* 配置 schema。加载期校验,非法配置 fail loud。
|
|
44
|
+
*
|
|
45
|
+
* 阈值只在这里存储,前端不做金额比较:颜色由后端的 `severity` 决定。
|
|
46
|
+
*/
|
|
47
|
+
export const Config = z.object({
|
|
48
|
+
apiKey: z.string().role('secret').default(''),
|
|
49
|
+
apiKeyRef: z.string().role('credential-ref').default(DEFAULT_API_KEY_REF),
|
|
50
|
+
baseUrl: z.string().default(DEFAULT_BASE_URL),
|
|
51
|
+
serverRefreshSeconds: z.natural().min(10).max(3600).default(60),
|
|
52
|
+
clientPollSeconds: z.natural().min(5).max(600).default(30),
|
|
53
|
+
manualRefreshCooldownSeconds: z.natural().min(0).max(600).default(30),
|
|
54
|
+
displayCurrency: z.string().default(CURRENCY_AUTO),
|
|
55
|
+
cnyWarn: z.number().min(0).default(10),
|
|
56
|
+
cnyCritical: z.number().min(0).default(5),
|
|
57
|
+
usdWarn: z.number().min(0).default(2),
|
|
58
|
+
usdCritical: z.number().min(0).default(1),
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* 阈值成对的清单。
|
|
62
|
+
*
|
|
63
|
+
* 字段名的约定是「币种代码小写 + Warn / Critical」;客户端按同一条约定把这两个字段
|
|
64
|
+
* 归成一对来判([use-config-form.ts](client/settings/use-config-form.ts) 的 `THRESHOLD_PAIRS`)。
|
|
65
|
+
* 两边各存一份是因为**宿主与浏览器两个半体不许值导入**;约定本身由测试对着本表兜底。
|
|
66
|
+
*/
|
|
67
|
+
export const THRESHOLD_PAIRS = [
|
|
68
|
+
{ currency: 'CNY', warn: 'cnyWarn', critical: 'cnyCritical' },
|
|
69
|
+
{ currency: 'USD', warn: 'usdWarn', critical: 'usdCritical' },
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* 跨字段校验:每个币种内 `critical` 必须**严格低于** `warn`。
|
|
73
|
+
*
|
|
74
|
+
* 挂在 `ctx.settings.register` 的 `validate` 上,不挂在 schema 上:
|
|
75
|
+
* schemastery 没有 refine / superRefine 这类跨字段钩子,而
|
|
76
|
+
* `SettingsRegisterOptions.validate` 正是为「schema 表达不了的约束」准备的 ——
|
|
77
|
+
* 它拿到的是**合并后、schema 已通过**的完整候选值,抛错即拒绝写入、什么都不落盘。
|
|
78
|
+
*
|
|
79
|
+
* **它在合并后的完整值上跑**,所以单字段写入会让中间态短暂非法;
|
|
80
|
+
* 客户端把成对的写入排过序(`orderPairWrites`),保证每一步中间态都合法。
|
|
81
|
+
*
|
|
82
|
+
* 为什么必须严格低于、不能相等:两者相等时余额恰好压线会被同时判成 warn 与 critical,
|
|
83
|
+
* 「预警」这一档就不存在了。措辞锚在**告急**上 —— 用户要调的是那个偏低的数。
|
|
84
|
+
* @param value - 合并后的完整配置。
|
|
85
|
+
* @throws {Error} 违反约束时抛出;消息指向具体币种,便于用户定位。
|
|
86
|
+
*/
|
|
87
|
+
export function validateThresholds(value) {
|
|
88
|
+
for (const pair of THRESHOLD_PAIRS) {
|
|
89
|
+
const warn = value[pair.warn];
|
|
90
|
+
const critical = value[pair.critical];
|
|
91
|
+
if (critical < warn)
|
|
92
|
+
continue;
|
|
93
|
+
throw new Error(`${pair.currency} 告急必须低于预警(当前 预警 ${String(warn)} / 告急 ${String(critical)})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 解析当前生效的超时。
|
|
98
|
+
*
|
|
99
|
+
* **每次请求都调用它** —— 改环境变量后立即生效,不需要重启。
|
|
100
|
+
* 越界或不可解析一律回落常量。
|
|
101
|
+
* @param env - 环境变量表;默认 `process.env`,测试可注入。
|
|
102
|
+
* @returns 毫秒数。
|
|
103
|
+
*/
|
|
104
|
+
export function resolveTimeoutMs(env = process.env) {
|
|
105
|
+
const raw = Number(env[TIMEOUT_ENV]);
|
|
106
|
+
if (!Number.isFinite(raw))
|
|
107
|
+
return DEFAULT_TIMEOUT_MS;
|
|
108
|
+
if (raw < TIMEOUT_RANGE.min || raw > TIMEOUT_RANGE.max)
|
|
109
|
+
return DEFAULT_TIMEOUT_MS;
|
|
110
|
+
return Math.trunc(raw);
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 余额领域类型。
|
|
3
|
+
*
|
|
4
|
+
* 这里定义的是**契约形状**:金额一律 `bigint` 最小单位,序列化时才变字符串。
|
|
5
|
+
* @module dsh-ds-balance/domain/balance
|
|
6
|
+
*/
|
|
7
|
+
import type { ErrorInfo } from './errors.js';
|
|
8
|
+
import type { Units } from './money.js';
|
|
9
|
+
/** 币种代码,例如 `CNY` / `USD`。 */
|
|
10
|
+
export type Currency = string;
|
|
11
|
+
/** 缓存状态。闭集。 */
|
|
12
|
+
export type CacheState = 'empty' | 'ok' | 'stale' | 'error';
|
|
13
|
+
/** 颜色严重度。闭集,前端只做机械映射。 */
|
|
14
|
+
export type Severity = 'ok' | 'warn' | 'critical' | 'unavailable' | 'unknown';
|
|
15
|
+
/** 单个币种的余额。 */
|
|
16
|
+
export interface BalanceInfo {
|
|
17
|
+
currency: Currency;
|
|
18
|
+
total: Units;
|
|
19
|
+
granted: Units;
|
|
20
|
+
toppedUp: Units;
|
|
21
|
+
}
|
|
22
|
+
/** 一次成功抓取的不可变快照。 */
|
|
23
|
+
export interface BalanceSnapshot {
|
|
24
|
+
/** 快照标识,单调可排序。 */
|
|
25
|
+
snapshotId: string;
|
|
26
|
+
/** 账本作用域标识:HMAC(serverSalt, apiKey) 前缀,不含明文。 */
|
|
27
|
+
accountTag: string;
|
|
28
|
+
/** 抓取时刻(毫秒)。 */
|
|
29
|
+
fetchedAt: number;
|
|
30
|
+
/** 上游 `is_available`。 */
|
|
31
|
+
isAvailable: boolean;
|
|
32
|
+
/** 全币种余额。 */
|
|
33
|
+
balances: BalanceInfo[];
|
|
34
|
+
/** 数据来源。 */
|
|
35
|
+
source: 'deepseek-http';
|
|
36
|
+
/** 原始响应,审计用。 */
|
|
37
|
+
raw: unknown;
|
|
38
|
+
}
|
|
39
|
+
/** 一个币种的预警 / 告急阈值。 */
|
|
40
|
+
export interface ThresholdPair {
|
|
41
|
+
warn: Units;
|
|
42
|
+
critical: Units;
|
|
43
|
+
}
|
|
44
|
+
/** 给前端的余额视图。 */
|
|
45
|
+
export interface BalanceView {
|
|
46
|
+
state: CacheState;
|
|
47
|
+
stale: boolean;
|
|
48
|
+
fetchedAt: number | null;
|
|
49
|
+
ageMs: number | null;
|
|
50
|
+
isAvailable: boolean | null;
|
|
51
|
+
balances: BalanceInfo[];
|
|
52
|
+
selected: {
|
|
53
|
+
currency: Currency;
|
|
54
|
+
total: Units;
|
|
55
|
+
} | null;
|
|
56
|
+
severity: Severity;
|
|
57
|
+
thresholds: Record<Currency, ThresholdPair>;
|
|
58
|
+
error: ErrorInfo | null;
|
|
59
|
+
}
|
|
60
|
+
/** 上游 `GET /user/balance` 的原始响应形状。字段名保持上游拼写。 */
|
|
61
|
+
export interface RawBalanceResponse {
|
|
62
|
+
is_available: boolean;
|
|
63
|
+
balance_infos: Array<{
|
|
64
|
+
currency: string;
|
|
65
|
+
total_balance: string;
|
|
66
|
+
granted_balance: string;
|
|
67
|
+
topped_up_balance: string;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=balance.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 领域错误与错误分类。
|
|
3
|
+
*
|
|
4
|
+
* 宿主半边的对外错误一律映射成 {@link ErrorInfo}:合约规定余额错误走
|
|
5
|
+
* `200 + state: error`,HTTP 层不得把异常抛出去让宿主包成 500。
|
|
6
|
+
* @module dsh-ds-balance/domain/errors
|
|
7
|
+
*/
|
|
8
|
+
/** 对外可见的错误码闭集。 */
|
|
9
|
+
export type ErrorCode = 'NO_KEY' | 'NO_NETWORK' | 'UPSTREAM_TIMEOUT' | 'UPSTREAM_401' | 'UPSTREAM_402' | 'UPSTREAM_422' | 'UPSTREAM_429' | 'UPSTREAM_4XX' | 'UPSTREAM_5XX' | 'UPSTREAM_503' | 'PARSE_ERROR' | 'SHAPE_ERROR' | 'COOLDOWN' | 'CONFLICT' | 'VALIDATION' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'STORAGE_ERROR';
|
|
10
|
+
/** 跨线传给前端的错误形状。 */
|
|
11
|
+
export interface ErrorInfo {
|
|
12
|
+
code: ErrorCode;
|
|
13
|
+
message: string;
|
|
14
|
+
retryable: boolean;
|
|
15
|
+
details?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/** 构造 {@link AppError} 的可选参数。 */
|
|
18
|
+
export interface AppErrorOptions {
|
|
19
|
+
retryable?: boolean;
|
|
20
|
+
details?: Record<string, unknown>;
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
}
|
|
23
|
+
/** 所有本插件错误的基类:带错误码与可重试标记。 */
|
|
24
|
+
export declare class AppError extends Error {
|
|
25
|
+
readonly code: ErrorCode;
|
|
26
|
+
readonly retryable: boolean;
|
|
27
|
+
readonly details: Record<string, unknown> | undefined;
|
|
28
|
+
constructor(code: ErrorCode, message: string, options?: AppErrorOptions);
|
|
29
|
+
}
|
|
30
|
+
/** 没有可用的 API Key。解析链全空时抛这个。 */
|
|
31
|
+
export declare class NoKeyError extends AppError {
|
|
32
|
+
constructor(message?: string);
|
|
33
|
+
}
|
|
34
|
+
/** 请求超时。 */
|
|
35
|
+
export declare class TimeoutError extends AppError {
|
|
36
|
+
constructor(message?: string, options?: AppErrorOptions);
|
|
37
|
+
}
|
|
38
|
+
/** 网络不可达(DNS / 连接失败)。 */
|
|
39
|
+
export declare class NetworkError extends AppError {
|
|
40
|
+
constructor(message?: string, options?: AppErrorOptions);
|
|
41
|
+
}
|
|
42
|
+
/** 把 HTTP 状态码映射到错误码。 */
|
|
43
|
+
export declare function upstreamCodeOf(status: number): ErrorCode;
|
|
44
|
+
/** 上游返回了非 2xx。 */
|
|
45
|
+
export declare class UpstreamError extends AppError {
|
|
46
|
+
readonly status: number;
|
|
47
|
+
readonly headers: Headers | undefined;
|
|
48
|
+
constructor(status: number, message: string, options?: {
|
|
49
|
+
headers?: Headers;
|
|
50
|
+
cause?: unknown;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** 金额或响应体解析失败。绝不静默归 0。 */
|
|
54
|
+
export declare class ParseError extends AppError {
|
|
55
|
+
constructor(message: string, options?: AppErrorOptions);
|
|
56
|
+
}
|
|
57
|
+
/** 响应结构与契约不符。 */
|
|
58
|
+
export declare class ShapeError extends AppError {
|
|
59
|
+
constructor(message?: string, options?: AppErrorOptions);
|
|
60
|
+
}
|
|
61
|
+
/** 入参或配置没通过校验。**不可重试** —— 同一份输入重放还是失败。 */
|
|
62
|
+
export declare class ValidationError extends AppError {
|
|
63
|
+
constructor(message?: string, options?: AppErrorOptions);
|
|
64
|
+
}
|
|
65
|
+
/** 存储层失败。可重试。 */
|
|
66
|
+
export declare class StorageError extends AppError {
|
|
67
|
+
constructor(message: string, options?: AppErrorOptions);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 解析 `Retry-After`。支持秒数与 HTTP-date 两种写法。
|
|
71
|
+
* @param headers - 上游响应头;缺失时返回 `undefined`。
|
|
72
|
+
* @param now - 当前时刻,HTTP-date 用它算差值;便于测试注入。
|
|
73
|
+
* @returns 毫秒数,或 `undefined` 表示没给出可用的提示。
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseRetryAfter(headers: Headers | undefined, now?: number): number | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* 把任意异常映射成对外错误。
|
|
78
|
+
*
|
|
79
|
+
* 顺序重要:先认自己的类型,再认带 `status` 的外部错误,最后兜底。
|
|
80
|
+
* @param error - 任意抛出的值。
|
|
81
|
+
* @returns 契约里的错误形状。
|
|
82
|
+
*/
|
|
83
|
+
export declare function classify(error: unknown): ErrorInfo;
|
|
84
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 领域错误与错误分类。
|
|
3
|
+
*
|
|
4
|
+
* 宿主半边的对外错误一律映射成 {@link ErrorInfo}:合约规定余额错误走
|
|
5
|
+
* `200 + state: error`,HTTP 层不得把异常抛出去让宿主包成 500。
|
|
6
|
+
* @module dsh-ds-balance/domain/errors
|
|
7
|
+
*/
|
|
8
|
+
/** 所有本插件错误的基类:带错误码与可重试标记。 */
|
|
9
|
+
export class AppError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
retryable;
|
|
12
|
+
details;
|
|
13
|
+
constructor(code, message, options = {}) {
|
|
14
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
15
|
+
this.name = new.target.name;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.retryable = options.retryable ?? false;
|
|
18
|
+
this.details = options.details;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** 没有可用的 API Key。解析链全空时抛这个。 */
|
|
22
|
+
export class NoKeyError extends AppError {
|
|
23
|
+
constructor(message = 'API key not configured') {
|
|
24
|
+
super('NO_KEY', message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** 请求超时。 */
|
|
28
|
+
export class TimeoutError extends AppError {
|
|
29
|
+
constructor(message = 'upstream timeout', options = {}) {
|
|
30
|
+
super('UPSTREAM_TIMEOUT', message, { ...options, retryable: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 网络不可达(DNS / 连接失败)。 */
|
|
34
|
+
export class NetworkError extends AppError {
|
|
35
|
+
constructor(message = 'network unreachable', options = {}) {
|
|
36
|
+
super('NO_NETWORK', message, { ...options, retryable: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 把 HTTP 状态码映射到错误码。 */
|
|
40
|
+
export function upstreamCodeOf(status) {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 401: return 'UPSTREAM_401';
|
|
43
|
+
case 402: return 'UPSTREAM_402';
|
|
44
|
+
case 422: return 'UPSTREAM_422';
|
|
45
|
+
case 429: return 'UPSTREAM_429';
|
|
46
|
+
case 503: return 'UPSTREAM_503';
|
|
47
|
+
default:
|
|
48
|
+
if (status >= 500)
|
|
49
|
+
return 'UPSTREAM_5XX';
|
|
50
|
+
return 'UPSTREAM_4XX';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** 上游返回了非 2xx。 */
|
|
54
|
+
export class UpstreamError extends AppError {
|
|
55
|
+
status;
|
|
56
|
+
headers;
|
|
57
|
+
constructor(status, message, options = {}) {
|
|
58
|
+
super(upstreamCodeOf(status), message, {
|
|
59
|
+
retryable: status >= 500 || status === 429,
|
|
60
|
+
cause: options.cause,
|
|
61
|
+
});
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.headers = options.headers;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** 金额或响应体解析失败。绝不静默归 0。 */
|
|
67
|
+
export class ParseError extends AppError {
|
|
68
|
+
constructor(message, options = {}) {
|
|
69
|
+
super('PARSE_ERROR', message, options);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** 响应结构与契约不符。 */
|
|
73
|
+
export class ShapeError extends AppError {
|
|
74
|
+
constructor(message = 'response shape mismatch', options = {}) {
|
|
75
|
+
super('SHAPE_ERROR', message, options);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 入参或配置没通过校验。**不可重试** —— 同一份输入重放还是失败。 */
|
|
79
|
+
export class ValidationError extends AppError {
|
|
80
|
+
constructor(message = 'validation failed', options = {}) {
|
|
81
|
+
super('VALIDATION', message, options);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** 存储层失败。可重试。 */
|
|
85
|
+
export class StorageError extends AppError {
|
|
86
|
+
constructor(message, options = {}) {
|
|
87
|
+
super('STORAGE_ERROR', message, { ...options, retryable: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 解析 `Retry-After`。支持秒数与 HTTP-date 两种写法。
|
|
92
|
+
* @param headers - 上游响应头;缺失时返回 `undefined`。
|
|
93
|
+
* @param now - 当前时刻,HTTP-date 用它算差值;便于测试注入。
|
|
94
|
+
* @returns 毫秒数,或 `undefined` 表示没给出可用的提示。
|
|
95
|
+
*/
|
|
96
|
+
export function parseRetryAfter(headers, now = Date.now()) {
|
|
97
|
+
const raw = headers?.get('retry-after');
|
|
98
|
+
if (raw === undefined || raw === null)
|
|
99
|
+
return undefined;
|
|
100
|
+
const text = raw.trim();
|
|
101
|
+
if (text === '')
|
|
102
|
+
return undefined;
|
|
103
|
+
if (/^\d+$/.test(text)) {
|
|
104
|
+
const seconds = Number(text);
|
|
105
|
+
return Number.isFinite(seconds) ? seconds * 1000 : undefined;
|
|
106
|
+
}
|
|
107
|
+
const at = Date.parse(text);
|
|
108
|
+
if (Number.isNaN(at))
|
|
109
|
+
return undefined;
|
|
110
|
+
return Math.max(0, at - now);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 把任意异常映射成对外错误。
|
|
114
|
+
*
|
|
115
|
+
* 顺序重要:先认自己的类型,再认带 `status` 的外部错误,最后兜底。
|
|
116
|
+
* @param error - 任意抛出的值。
|
|
117
|
+
* @returns 契约里的错误形状。
|
|
118
|
+
*/
|
|
119
|
+
export function classify(error) {
|
|
120
|
+
if (error instanceof AppError) {
|
|
121
|
+
const info = { code: error.code, message: error.message, retryable: error.retryable };
|
|
122
|
+
return error.details === undefined ? info : { ...info, details: error.details };
|
|
123
|
+
}
|
|
124
|
+
if (error instanceof Error) {
|
|
125
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
|
|
126
|
+
return { code: 'UPSTREAM_TIMEOUT', message: error.message, retryable: true };
|
|
127
|
+
}
|
|
128
|
+
const code = error.code;
|
|
129
|
+
if (typeof code === 'string' && /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|UND_ERR)/.test(code)) {
|
|
130
|
+
return { code: 'NO_NETWORK', message: error.message, retryable: true };
|
|
131
|
+
}
|
|
132
|
+
return { code: 'STORAGE_ERROR', message: error.message, retryable: true };
|
|
133
|
+
}
|
|
134
|
+
return { code: 'STORAGE_ERROR', message: String(error), retryable: true };
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定点金额。
|
|
3
|
+
*
|
|
4
|
+
* 全程 `bigint`,绝不经过浮点 —— 合约要求金额是字符串、比较与累加只在后端做。
|
|
5
|
+
* @module dsh-ds-balance/domain/money
|
|
6
|
+
*/
|
|
7
|
+
/** 1 个货币单位对应的最小单位数(8 位小数)。 */
|
|
8
|
+
export declare const MONEY_SCALE = 100000000n;
|
|
9
|
+
/** 契约规定的小数位数。 */
|
|
10
|
+
export declare const MONEY_DECIMALS = 8;
|
|
11
|
+
/** 金额的最小单位表示。 */
|
|
12
|
+
export type Units = bigint;
|
|
13
|
+
/**
|
|
14
|
+
* 把契约里的金额字符串解析成最小单位。
|
|
15
|
+
*
|
|
16
|
+
* 超过 {@link MONEY_DECIMALS} 位的小数**截断,不四舍五入**;解析失败抛
|
|
17
|
+
* {@link ParseError},绝不静默归 0。
|
|
18
|
+
* @param input - 金额字符串或数字(数字会被先转成字符串)。
|
|
19
|
+
* @returns 最小单位金额。
|
|
20
|
+
* @throws {ParseError} 当文本不是十进制定点。
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseMoney(input: string | number): Units;
|
|
23
|
+
/**
|
|
24
|
+
* 把最小单位格式化成定点字符串。
|
|
25
|
+
* @param units - 最小单位金额。
|
|
26
|
+
* @param decimals - 保留几位小数,0..{@link MONEY_DECIMALS};默认 8(给 API),UI 用 2。
|
|
27
|
+
* @returns 定点字符串,超出的位数直接截断。
|
|
28
|
+
* @throws {RangeError} 当 `decimals` 越界。
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatMoney(units: Units, decimals?: number): string;
|
|
31
|
+
/** 加法。 */
|
|
32
|
+
export declare function addMoney(left: Units, right: Units): Units;
|
|
33
|
+
/** 减法。 */
|
|
34
|
+
export declare function subMoney(left: Units, right: Units): Units;
|
|
35
|
+
/** 三路比较:-1 / 0 / 1。 */
|
|
36
|
+
export declare function cmpMoney(left: Units, right: Units): -1 | 0 | 1;
|
|
37
|
+
/** 是否为零。 */
|
|
38
|
+
export declare function isZeroMoney(value: Units): boolean;
|
|
39
|
+
//# sourceMappingURL=money.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定点金额。
|
|
3
|
+
*
|
|
4
|
+
* 全程 `bigint`,绝不经过浮点 —— 合约要求金额是字符串、比较与累加只在后端做。
|
|
5
|
+
* @module dsh-ds-balance/domain/money
|
|
6
|
+
*/
|
|
7
|
+
import { ParseError } from './errors.js';
|
|
8
|
+
/** 1 个货币单位对应的最小单位数(8 位小数)。 */
|
|
9
|
+
export const MONEY_SCALE = 100000000n;
|
|
10
|
+
/** 契约规定的小数位数。 */
|
|
11
|
+
export const MONEY_DECIMALS = 8;
|
|
12
|
+
/** 只接受十进制定点:可选负号、整数部分、可选小数部分。不接受科学计数法。 */
|
|
13
|
+
const MONEY_PATTERN = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
14
|
+
/**
|
|
15
|
+
* 把契约里的金额字符串解析成最小单位。
|
|
16
|
+
*
|
|
17
|
+
* 超过 {@link MONEY_DECIMALS} 位的小数**截断,不四舍五入**;解析失败抛
|
|
18
|
+
* {@link ParseError},绝不静默归 0。
|
|
19
|
+
* @param input - 金额字符串或数字(数字会被先转成字符串)。
|
|
20
|
+
* @returns 最小单位金额。
|
|
21
|
+
* @throws {ParseError} 当文本不是十进制定点。
|
|
22
|
+
*/
|
|
23
|
+
export function parseMoney(input) {
|
|
24
|
+
const text = String(input).trim();
|
|
25
|
+
const match = MONEY_PATTERN.exec(text);
|
|
26
|
+
if (match === null) {
|
|
27
|
+
throw new ParseError(`not a decimal amount: ${JSON.stringify(text.slice(0, 40))}`);
|
|
28
|
+
}
|
|
29
|
+
const sign = match[1] ?? '';
|
|
30
|
+
const whole = match[2] ?? '0';
|
|
31
|
+
const fraction = match[3] ?? '';
|
|
32
|
+
const scaled = (fraction + '00000000').slice(0, MONEY_DECIMALS);
|
|
33
|
+
const magnitude = BigInt(whole) * MONEY_SCALE + BigInt(scaled);
|
|
34
|
+
return sign === '-' ? -magnitude : magnitude;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 把最小单位格式化成定点字符串。
|
|
38
|
+
* @param units - 最小单位金额。
|
|
39
|
+
* @param decimals - 保留几位小数,0..{@link MONEY_DECIMALS};默认 8(给 API),UI 用 2。
|
|
40
|
+
* @returns 定点字符串,超出的位数直接截断。
|
|
41
|
+
* @throws {RangeError} 当 `decimals` 越界。
|
|
42
|
+
*/
|
|
43
|
+
export function formatMoney(units, decimals = MONEY_DECIMALS) {
|
|
44
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > MONEY_DECIMALS) {
|
|
45
|
+
throw new RangeError(`decimals must be an integer in 0..${MONEY_DECIMALS}`);
|
|
46
|
+
}
|
|
47
|
+
const negative = units < 0n;
|
|
48
|
+
const magnitude = negative ? -units : units;
|
|
49
|
+
const whole = (magnitude / MONEY_SCALE).toString();
|
|
50
|
+
const fraction = (magnitude % MONEY_SCALE).toString().padStart(MONEY_DECIMALS, '0');
|
|
51
|
+
const body = decimals === 0 ? whole : `${whole}.${fraction.slice(0, decimals)}`;
|
|
52
|
+
return negative ? `-${body}` : body;
|
|
53
|
+
}
|
|
54
|
+
/** 加法。 */
|
|
55
|
+
export function addMoney(left, right) {
|
|
56
|
+
return left + right;
|
|
57
|
+
}
|
|
58
|
+
/** 减法。 */
|
|
59
|
+
export function subMoney(left, right) {
|
|
60
|
+
return left - right;
|
|
61
|
+
}
|
|
62
|
+
/** 三路比较:-1 / 0 / 1。 */
|
|
63
|
+
export function cmpMoney(left, right) {
|
|
64
|
+
if (left < right)
|
|
65
|
+
return -1;
|
|
66
|
+
if (left > right)
|
|
67
|
+
return 1;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
/** 是否为零。 */
|
|
71
|
+
export function isZeroMoney(value) {
|
|
72
|
+
return value === 0n;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=money.js.map
|