dsh-ds-balance 2.1.2 → 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.
- package/README.md +78 -19
- package/README_en.md +81 -19
- package/lib/adapters/domain-core-store.d.ts +2 -2
- package/lib/adapters/domain-core-store.js +3 -1
- package/lib/adapters/host-account.d.ts +66 -0
- package/lib/adapters/host-account.js +68 -0
- package/lib/client.js +473 -114
- package/lib/domain/account.d.ts +40 -0
- package/lib/domain/account.js +74 -0
- package/lib/domain/balance.d.ts +20 -1
- package/lib/domain/normalize.d.ts +3 -2
- package/lib/domain/normalize.js +3 -2
- package/lib/domain/severity.d.ts +8 -2
- package/lib/domain/severity.js +11 -4
- package/lib/domain/situation.d.ts +104 -0
- package/lib/domain/situation.js +93 -0
- package/lib/http/handlers.d.ts +1 -1
- package/lib/http/handlers.js +29 -5
- package/lib/http/wire.d.ts +11 -1
- package/lib/http/wire.js +2 -0
- package/lib/index.js +122 -6
- package/lib/ports/account.d.ts +27 -0
- package/lib/ports/account.js +9 -0
- package/lib/services/balance-service.d.ts +66 -85
- package/lib/services/balance-service.js +132 -181
- package/lib/services/balance-source.d.ts +174 -0
- package/lib/services/balance-source.js +294 -0
- package/lib/services/scheduler.d.ts +31 -2
- package/lib/services/scheduler.js +29 -2
- package/lib/services/source-readers.d.ts +26 -0
- package/lib/services/source-readers.js +73 -0
- package/lib/services/source-selector.d.ts +50 -0
- package/lib/services/source-selector.js +64 -0
- package/lib/types/client/api-types.d.ts +16 -0
- package/lib/types/client/data.d.ts +12 -3
- package/lib/types/client/index.d.ts +6 -0
- package/lib/types/client/locales.d.ts +10 -18
- package/lib/types/client/mock/index.d.ts +12 -11
- package/lib/types/client/mock/scenarios.d.ts +13 -5
- package/lib/types/client/model.d.ts +49 -25
- package/lib/types/client/route-hint.d.ts +61 -0
- package/lib/types/client/sidebar/BalancePopover.d.ts +7 -0
- package/lib/types/client/sidebar/PercentRing.d.ts +13 -5
- package/lib/types/client/sidebar/SidebarBalance.d.ts +9 -1
- package/lib/types/client/situation.d.ts +164 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- 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
|
|
10
|
+
import type { BalanceStatus } from './balance-service.js';
|
|
11
11
|
/** 首拉延迟。 */
|
|
12
12
|
export declare const INITIAL_DELAY_MS = 1000;
|
|
13
13
|
/** 缺密钥时的快速重试间隔。 */
|
|
@@ -19,6 +19,13 @@ export declare const MAX_BACKOFF_MS = 300000;
|
|
|
19
19
|
export declare const JITTER_RATIO = 0.2;
|
|
20
20
|
/** 延迟下限,避免忙转。 */
|
|
21
21
|
export declare const MIN_DELAY_MS = 1000;
|
|
22
|
+
/**
|
|
23
|
+
* 健康态延迟给「这一轮抓取本身」留的余量。
|
|
24
|
+
*
|
|
25
|
+
* 延迟是从上一轮**抓完之后**起算的,而抓取要几百毫秒(本机实测到上游一个往返约 0.2 秒)。
|
|
26
|
+
* 延迟若正好等于缓存窗口,新快照就会晚于窗口过期落地,那个缝里又会出现一次轮询代打的抓取。
|
|
27
|
+
*/
|
|
28
|
+
export declare const SCHEDULE_GUARD_MS = 1000;
|
|
22
29
|
/** 定时器抽象,测试可注入。 */
|
|
23
30
|
export interface SchedulerTimers {
|
|
24
31
|
set(callback: () => void, ms: number): unknown;
|
|
@@ -26,7 +33,13 @@ export interface SchedulerTimers {
|
|
|
26
33
|
}
|
|
27
34
|
/** 调度器需要的最小服务面。 */
|
|
28
35
|
export interface SchedulerTarget {
|
|
29
|
-
|
|
36
|
+
/**
|
|
37
|
+
* 抓一轮**当前活跃来源**。
|
|
38
|
+
*
|
|
39
|
+
* 调度**不重新解析来源**:节拍属于「这份数据」,不属于某一次请求 —— 每轮都按全局默认重算的话,
|
|
40
|
+
* 会话级的来源会被无声地顶掉。
|
|
41
|
+
*/
|
|
42
|
+
refreshActive(): Promise<BalanceView>;
|
|
30
43
|
status(): BalanceStatus;
|
|
31
44
|
}
|
|
32
45
|
/** 构造参数。 */
|
|
@@ -49,6 +62,22 @@ export interface SchedulerOptions {
|
|
|
49
62
|
* @returns 取整后的毫秒数。
|
|
50
63
|
*/
|
|
51
64
|
export declare function jitter(base: number, ratio: number, random: () => number): number;
|
|
65
|
+
/**
|
|
66
|
+
* 健康态的单边抖动:落在 `[base * (1 - ratio), base - SCHEDULE_GUARD_MS]`。
|
|
67
|
+
*
|
|
68
|
+
* **为什么健康态不能用对称抖动**:缓存窗口就是 `serverRefreshSeconds`
|
|
69
|
+
* (见 `BalanceService.withinWindow`),对称抖动有一半轮次落在窗口**之后** ——
|
|
70
|
+
* 那段时间里界面一次普通轮询就会自己穿透上游抓一次,紧接着这一轮 tick 又抓一次。
|
|
71
|
+
* 本机 3608 个快照间隔里 158 对相隔不足 3 秒的连击,其中 117 对前面那格超过 60 秒(窗口已过),
|
|
72
|
+
* 就是这么来的。单边抖动把「tick 不越过窗口」变成结构保证。
|
|
73
|
+
*
|
|
74
|
+
* 退避与缺密钥重试仍走对称的 {@link jitter}:那些分支本来就在窗口外,没有这条不变量。
|
|
75
|
+
* @param base - 基准毫秒数。
|
|
76
|
+
* @param ratio - 抖动比例。
|
|
77
|
+
* @param random - 随机源,返回 `[0, 1)`。
|
|
78
|
+
* @returns 取整后的毫秒数。
|
|
79
|
+
*/
|
|
80
|
+
export declare function jitterWithin(base: number, ratio: number, random: () => number): number;
|
|
52
81
|
/**
|
|
53
82
|
* 算下一轮该等多久。
|
|
54
83
|
*
|
|
@@ -19,6 +19,13 @@ export const JITTER_RATIO = 0.2;
|
|
|
19
19
|
const MAX_BACKOFF_EXPONENT = 10;
|
|
20
20
|
/** 延迟下限,避免忙转。 */
|
|
21
21
|
export const MIN_DELAY_MS = 1000;
|
|
22
|
+
/**
|
|
23
|
+
* 健康态延迟给「这一轮抓取本身」留的余量。
|
|
24
|
+
*
|
|
25
|
+
* 延迟是从上一轮**抓完之后**起算的,而抓取要几百毫秒(本机实测到上游一个往返约 0.2 秒)。
|
|
26
|
+
* 延迟若正好等于缓存窗口,新快照就会晚于窗口过期落地,那个缝里又会出现一次轮询代打的抓取。
|
|
27
|
+
*/
|
|
28
|
+
export const SCHEDULE_GUARD_MS = 1000;
|
|
22
29
|
/** 把值夹到区间内。 */
|
|
23
30
|
function clamp(value, min, max) {
|
|
24
31
|
return Math.min(max, Math.max(min, value));
|
|
@@ -35,6 +42,26 @@ export function jitter(base, ratio, random) {
|
|
|
35
42
|
const span = base * ratio * 2;
|
|
36
43
|
return Math.floor(min + random() * span);
|
|
37
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 健康态的单边抖动:落在 `[base * (1 - ratio), base - SCHEDULE_GUARD_MS]`。
|
|
47
|
+
*
|
|
48
|
+
* **为什么健康态不能用对称抖动**:缓存窗口就是 `serverRefreshSeconds`
|
|
49
|
+
* (见 `BalanceService.withinWindow`),对称抖动有一半轮次落在窗口**之后** ——
|
|
50
|
+
* 那段时间里界面一次普通轮询就会自己穿透上游抓一次,紧接着这一轮 tick 又抓一次。
|
|
51
|
+
* 本机 3608 个快照间隔里 158 对相隔不足 3 秒的连击,其中 117 对前面那格超过 60 秒(窗口已过),
|
|
52
|
+
* 就是这么来的。单边抖动把「tick 不越过窗口」变成结构保证。
|
|
53
|
+
*
|
|
54
|
+
* 退避与缺密钥重试仍走对称的 {@link jitter}:那些分支本来就在窗口外,没有这条不变量。
|
|
55
|
+
* @param base - 基准毫秒数。
|
|
56
|
+
* @param ratio - 抖动比例。
|
|
57
|
+
* @param random - 随机源,返回 `[0, 1)`。
|
|
58
|
+
* @returns 取整后的毫秒数。
|
|
59
|
+
*/
|
|
60
|
+
export function jitterWithin(base, ratio, random) {
|
|
61
|
+
const upper = Math.max(MIN_DELAY_MS, base - SCHEDULE_GUARD_MS);
|
|
62
|
+
const lower = Math.min(upper, Math.max(MIN_DELAY_MS, base * (1 - ratio)));
|
|
63
|
+
return Math.floor(lower + (upper - lower) * random());
|
|
64
|
+
}
|
|
38
65
|
/**
|
|
39
66
|
* 算下一轮该等多久。
|
|
40
67
|
*
|
|
@@ -55,7 +82,7 @@ export function nextDelayMs(status, options = {}) {
|
|
|
55
82
|
const exponent = Math.min(Math.max(0, status.consecutiveFailures - 1), MAX_BACKOFF_EXPONENT);
|
|
56
83
|
return jitter(Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** exponent), JITTER_RATIO, random);
|
|
57
84
|
}
|
|
58
|
-
return
|
|
85
|
+
return jitterWithin(Math.max(MIN_DELAY_MS, status.serverRefreshSeconds * 1000), JITTER_RATIO, random);
|
|
59
86
|
}
|
|
60
87
|
/** 真正的默认定时器。 */
|
|
61
88
|
const defaultTimers = {
|
|
@@ -131,7 +158,7 @@ export class Scheduler {
|
|
|
131
158
|
if (!this.running)
|
|
132
159
|
return;
|
|
133
160
|
try {
|
|
134
|
-
await this.options.target.
|
|
161
|
+
await this.options.target.refreshActive();
|
|
135
162
|
}
|
|
136
163
|
catch (error) {
|
|
137
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;
|