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
|
@@ -1,44 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 余额服务门面:**决定这一轮走哪条取数路**,然后把活交给那条路的账本。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 门面自己不持状态、不抓数据 —— 两份状态在 {@link SourceLedger} 里,一个来源一份。
|
|
5
|
+
* 选源规则是四层回落(会话路由 → 全局默认路由 → Key 优先 → 都没有),判据是纯函数
|
|
6
|
+
* [source-selector.ts](source-selector.ts);两条路的读取策略在
|
|
7
|
+
* [source-readers.ts](source-readers.ts)。
|
|
6
8
|
* @module dsh-ds-balance/services/balance-service
|
|
7
9
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import { pickBalance } from '../domain/select.js';
|
|
11
|
-
import { severityOf, thresholdsFor } from '../domain/severity.js';
|
|
12
|
-
import { noopMetrics } from '../ports/metrics.js';
|
|
13
|
-
import { endpointOf } from '../config.js';
|
|
14
|
-
import { accountTag8, computeAccountTag } from './account-tag.js';
|
|
15
|
-
/** 余额缓存与状态机。 */
|
|
10
|
+
import { FALLBACK_ORDER, pickSource, routeOf } from './source-selector.js';
|
|
11
|
+
/** 余额服务门面。 */
|
|
16
12
|
export class BalanceService {
|
|
17
13
|
options;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
/** 当前活跃来源;由最近一次解析决定,调度与健康检查读它。 */
|
|
15
|
+
active = 'deepseek-http';
|
|
16
|
+
/**
|
|
17
|
+
* 来源判据是否已经定下来过。
|
|
18
|
+
*
|
|
19
|
+
* 宿主刚起来时两个可选服务(账号、默认模型)可能还没到,那一次解析只能按兜底走;
|
|
20
|
+
* 服务到齐后由装配处调 {@link invalidateSource} + `scheduler.reset()` 重新定一次。
|
|
21
|
+
* 一旦有请求解析过就以请求为准 —— 调度不拿全局默认去顶会话级的来源。
|
|
22
|
+
*/
|
|
23
|
+
resolved = false;
|
|
24
|
+
/** 每条路只恢复一次(存储扫描不便宜,来回切来源不该重复扫)。 */
|
|
25
|
+
restores = new Map();
|
|
27
26
|
constructor(options) {
|
|
28
27
|
this.options = options;
|
|
29
|
-
this.metrics = options.metrics ?? noopMetrics;
|
|
30
28
|
}
|
|
31
|
-
/**
|
|
29
|
+
/** 当前活跃来源。诊断与测试用。 */
|
|
30
|
+
activeSource() {
|
|
31
|
+
return this.active;
|
|
32
|
+
}
|
|
33
|
+
/** 当前活跃账本的状态切片,供调度与健康检查使用。 */
|
|
32
34
|
status() {
|
|
33
|
-
return
|
|
34
|
-
state: this.state,
|
|
35
|
-
errorCode: this.error?.code ?? null,
|
|
36
|
-
consecutiveFailures: this.failures,
|
|
37
|
-
hasSnapshot: this.snapshot !== null,
|
|
38
|
-
serverRefreshSeconds: this.options.config.current().serverRefreshSeconds,
|
|
39
|
-
retryAfterMs: this.retryAfterMs,
|
|
40
|
-
lastSuccessAt: this.snapshot?.fetchedAt ?? null,
|
|
41
|
-
};
|
|
35
|
+
return this.ledger(this.active).status();
|
|
42
36
|
}
|
|
43
37
|
/**
|
|
44
38
|
* 当前账本标识的前 8 位;还没有快照时是 `null`。
|
|
@@ -46,182 +40,139 @@ export class BalanceService {
|
|
|
46
40
|
* **只回前 8 位**:完整 tag 是账本作用域标识,没有对外的理由。
|
|
47
41
|
*/
|
|
48
42
|
accountTag8() {
|
|
49
|
-
|
|
50
|
-
return tag === undefined ? null : accountTag8(tag);
|
|
43
|
+
return this.ledger(this.active).accountTag8();
|
|
51
44
|
}
|
|
52
|
-
/**
|
|
53
|
-
* 从存储恢复最近快照(挂载时调一次)。
|
|
54
|
-
*
|
|
55
|
-
* **按 `accountTag` 过滤**:凭据轮换后 tag 变了,旧快照视为不存在,不混用。
|
|
56
|
-
* 没有密钥可算 tag 时静默跳过。
|
|
57
|
-
*/
|
|
45
|
+
/** 从存储恢复最近快照(挂载时调一次,恢复的是**当前活跃来源**那条账本)。 */
|
|
58
46
|
async restore() {
|
|
59
|
-
|
|
60
|
-
const apiKey = await this.options.keys.resolve();
|
|
61
|
-
const accountTag = computeAccountTag(this.options.salt, apiKey);
|
|
62
|
-
const stored = await this.options.store.loadLatestSnapshot(accountTag);
|
|
63
|
-
if (stored === null)
|
|
64
|
-
return;
|
|
65
|
-
this.snapshot = stored;
|
|
66
|
-
this.state = this.withinWindow() ? 'ok' : 'stale';
|
|
67
|
-
this.publishGauge();
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
this.options.logger?.debug('ds-balance: no snapshot restored', {
|
|
71
|
-
error: describeError(error),
|
|
72
|
-
});
|
|
73
|
-
}
|
|
47
|
+
await this.restoreLedger(await this.resolve(undefined));
|
|
74
48
|
}
|
|
75
49
|
/**
|
|
76
50
|
* 取当前视图。
|
|
77
51
|
*
|
|
78
|
-
*
|
|
79
|
-
*/
|
|
80
|
-
async getView(options = {}) {
|
|
81
|
-
if (this.inflight !== null)
|
|
82
|
-
return this.inflight;
|
|
83
|
-
if (options.force !== true && this.canServeCache())
|
|
84
|
-
return this.toView(options.currency);
|
|
85
|
-
const run = this.fetchOnce(options.currency).finally(() => {
|
|
86
|
-
this.inflight = null;
|
|
87
|
-
});
|
|
88
|
-
this.inflight = run;
|
|
89
|
-
return run;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* 手动刷新。
|
|
52
|
+
* `providerHint` 一变(用户切了会话、切了模型)就重新解析来源 —— 这是「跟着会话走」的入口。
|
|
93
53
|
*
|
|
94
|
-
*
|
|
54
|
+
* **本插件只记官方的那一个数字**,所以首选那条路拿不出数字时**退到另一条官方路**:
|
|
55
|
+
* 叉(空态 / 失败态)只在「官方两条路都拿不到数据」时才出现 —— 那才是「没接入官方」。
|
|
95
56
|
*/
|
|
96
|
-
async
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.
|
|
102
|
-
return
|
|
57
|
+
async getView(options = {}) {
|
|
58
|
+
const preferred = await this.resolve(options.providerHint);
|
|
59
|
+
const view = await this.viewOf(preferred, options);
|
|
60
|
+
// 展示出去的那条 = 刷新与标签认的那条(否则「数字是账号的、刷新去刷 Key」)。
|
|
61
|
+
if (view.state === 'ok') {
|
|
62
|
+
this.mark(preferred);
|
|
63
|
+
return view;
|
|
103
64
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
65
|
+
// 兜底顺序:固定顺序里排在前面的先试,跳过刚试过的那条。
|
|
66
|
+
for (const id of FALLBACK_ORDER) {
|
|
67
|
+
if (id === preferred)
|
|
68
|
+
continue;
|
|
69
|
+
const fallback = await this.viewOf(id, options);
|
|
70
|
+
// 有快照就赢过没快照(陈的官方数字也比一个叉有信息);两边都没快照就继续找。
|
|
71
|
+
if (fallback.state === 'ok' || fallback.fetchedAt !== null) {
|
|
72
|
+
this.mark(id);
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
107
75
|
}
|
|
108
|
-
|
|
109
|
-
return
|
|
76
|
+
this.mark(preferred);
|
|
77
|
+
return view;
|
|
110
78
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const window = this.options.config.current().serverRefreshSeconds * 1000;
|
|
116
|
-
return this.options.clock.now() - this.snapshot.fetchedAt < window;
|
|
79
|
+
/** 取一条路的视图(先确保它恢复过)。 */
|
|
80
|
+
async viewOf(id, options) {
|
|
81
|
+
await this.restoreLedger(id);
|
|
82
|
+
return this.ledger(id).getView({ force: options.force, currency: options.currency });
|
|
117
83
|
}
|
|
118
84
|
/**
|
|
119
|
-
*
|
|
85
|
+
* 定时刷新:刷**用户实际看到的那条**。
|
|
120
86
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
87
|
+
* 来源还没定过(宿主刚起来、可选服务未到齐)时先解析一次;定过之后就认 `active` ——
|
|
88
|
+
* 而 `active` 是**上一次真正把数字交出去的那条**(`getView` 兜底出去的也算),
|
|
89
|
+
* 所以兜底展示的账号数字才会被继续刷新,不会永远停在一张旧快照上。
|
|
124
90
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
91
|
+
async refreshActive() {
|
|
92
|
+
let id = this.resolved ? this.active : await this.resolve(undefined);
|
|
93
|
+
// **服务晚到的自愈点**:宿主刚起来时判据可能还没齐(账号服务已注册、但它自己的凭据
|
|
94
|
+
// 还没读出来),那一轮会落在一条不可用的路上。下一轮发现仍旧不可用就再解析一次 ——
|
|
95
|
+
// 不必等界面来请求,也不必靠外部作废。
|
|
96
|
+
if (!(await this.ledger(id).available()))
|
|
97
|
+
id = await this.resolve(undefined);
|
|
98
|
+
this.mark(id);
|
|
99
|
+
await this.restoreLedger(id);
|
|
100
|
+
return this.ledger(id).getView({ force: true });
|
|
127
101
|
}
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const apiKey = await this.options.keys.resolve();
|
|
133
|
-
const config = this.options.config.current();
|
|
134
|
-
const raw = await this.options.client.fetchBalance({
|
|
135
|
-
baseUrl: endpointOf(config),
|
|
136
|
-
apiKey,
|
|
137
|
-
timeoutMs: this.options.config.timeoutMs(),
|
|
138
|
-
});
|
|
139
|
-
const snapshot = normalize(raw, computeAccountTag(this.options.salt, apiKey), this.options.clock.now());
|
|
140
|
-
// 落盘失败不算这次抓取失败:快照留在内存里,界面照常显示,
|
|
141
|
-
// 代价只是重启后不恢复。存储是可降级的一层。
|
|
142
|
-
await this.persist(snapshot);
|
|
143
|
-
this.snapshot = snapshot;
|
|
144
|
-
this.state = 'ok';
|
|
145
|
-
this.error = null;
|
|
146
|
-
this.failures = 0;
|
|
147
|
-
this.retryAfterMs = null;
|
|
148
|
-
this.metrics.counter('balance_fetch_total', { result: 'ok' });
|
|
149
|
-
this.metrics.histogram('balance_fetch_duration_ms', {}, this.options.clock.now() - startedAt);
|
|
150
|
-
this.publishGauge();
|
|
151
|
-
return this.toView(currency);
|
|
152
|
-
}
|
|
153
|
-
catch (error) {
|
|
154
|
-
this.applyFailure(error, startedAt);
|
|
155
|
-
return this.toView(currency);
|
|
156
|
-
}
|
|
102
|
+
/** 作废「来源已定」这件事,让下一次调度重新解析。 */
|
|
103
|
+
invalidateSource() {
|
|
104
|
+
this.resolved = false;
|
|
157
105
|
}
|
|
158
106
|
/**
|
|
159
|
-
*
|
|
107
|
+
* 手动刷新:**用户看哪条就刷哪条**(与 {@link getView} 同一条判定)。
|
|
160
108
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
109
|
+
* 首选那条一条数字都没有时,界面上实际是兜底那条在撑着 —— 那就刷兜底那条,
|
|
110
|
+
* 否则用户点了刷新、数字却永远不动。冷却仍由那条自己的账本管。
|
|
163
111
|
*/
|
|
164
|
-
async
|
|
165
|
-
|
|
166
|
-
|
|
112
|
+
async forceRefresh(reason, providerHint) {
|
|
113
|
+
const preferred = await this.resolve(providerHint);
|
|
114
|
+
await this.restoreLedger(preferred);
|
|
115
|
+
const shown = this.ledger(preferred).status().hasSnapshot
|
|
116
|
+
? preferred
|
|
117
|
+
: await this.firstWithSnapshot(preferred);
|
|
118
|
+
this.mark(shown);
|
|
119
|
+
await this.restoreLedger(shown);
|
|
120
|
+
return this.ledger(shown).forceRefresh(reason);
|
|
121
|
+
}
|
|
122
|
+
/** 找一条**有快照**的官方路;都没有就退回默认那条(那时才画叉)。 */
|
|
123
|
+
async firstWithSnapshot(preferred) {
|
|
124
|
+
for (const id of FALLBACK_ORDER) {
|
|
125
|
+
if (id === preferred)
|
|
126
|
+
continue;
|
|
127
|
+
await this.restoreLedger(id);
|
|
128
|
+
if (this.ledger(id).status().hasSnapshot)
|
|
129
|
+
return id;
|
|
167
130
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
131
|
+
return preferred;
|
|
132
|
+
}
|
|
133
|
+
/** 记下「这条刚刚把数字交出去了」:展示、刷新、标签从此都认它。 */
|
|
134
|
+
mark(id) {
|
|
135
|
+
if (id !== this.active) {
|
|
136
|
+
this.options.logger?.debug('ds-balance: balance source switched', {
|
|
137
|
+
from: this.active,
|
|
138
|
+
to: id,
|
|
139
|
+
});
|
|
140
|
+
// 来源摆动(首选那条时好时坏)在界面上**刻意不给提示**(见决策记录
|
|
141
|
+
// `.agents/notes/2026-10-01-boundaries-left-as-is.md`),所以这里是唯一的观测出口:
|
|
142
|
+
// 没有这个计数器,「来源在摆」只能靠翻日志发现。
|
|
143
|
+
this.options.metrics?.counter('balance_source_switch_total', {
|
|
144
|
+
from: this.active,
|
|
145
|
+
to: id,
|
|
174
146
|
});
|
|
175
147
|
}
|
|
148
|
+
this.active = id;
|
|
149
|
+
this.resolved = true;
|
|
176
150
|
}
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.options.logger?.warn('ds-balance: balance fetch failed', { code: info.code });
|
|
187
|
-
}
|
|
188
|
-
this.metrics.counter('balance_fetch_total', { result: 'error' });
|
|
189
|
-
this.metrics.histogram('balance_fetch_duration_ms', {}, this.options.clock.now() - startedAt);
|
|
190
|
-
this.publishGauge();
|
|
151
|
+
/** 解析这一轮**首选**哪条路。 */
|
|
152
|
+
async resolve(hint) {
|
|
153
|
+
const routed = routeOf(hint ?? this.options.routeProvider());
|
|
154
|
+
// 问**每一条**账本自己「此刻可用吗」:新增来源时这里一行都不用改。
|
|
155
|
+
const availability = new Map();
|
|
156
|
+
await Promise.all([...this.options.ledgers].map(async ([id, ledger]) => {
|
|
157
|
+
availability.set(id, await ledger.available());
|
|
158
|
+
}));
|
|
159
|
+
return pickSource({ routed, available: (id) => availability.get(id) === true });
|
|
191
160
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
161
|
+
/** 取账本;缺一条属于装配错误,直接抛(静默降级会让界面永远停在空态)。 */
|
|
162
|
+
ledger(id) {
|
|
163
|
+
const ledger = this.options.ledgers.get(id);
|
|
164
|
+
if (ledger === undefined)
|
|
165
|
+
throw new Error(`ds-balance: no ledger for source ${id}`);
|
|
166
|
+
return ledger;
|
|
195
167
|
}
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
stale: this.state === 'stale',
|
|
205
|
-
fetchedAt: this.snapshot?.fetchedAt ?? null,
|
|
206
|
-
ageMs: this.snapshot === null
|
|
207
|
-
? null
|
|
208
|
-
: Math.max(0, this.options.clock.now() - this.snapshot.fetchedAt),
|
|
209
|
-
isAvailable: this.snapshot?.isAvailable ?? null,
|
|
210
|
-
balances: this.snapshot?.balances ?? [],
|
|
211
|
-
selected: selected === null ? null : { currency: selected.currency, total: selected.total },
|
|
212
|
-
severity: severityOf(selected, this.snapshot?.isAvailable ?? false, thresholdsFor(selected?.currency ?? '', thresholds)),
|
|
213
|
-
thresholds,
|
|
214
|
-
error: this.state === 'ok' ? null : this.error,
|
|
215
|
-
};
|
|
168
|
+
/** 首次用到某条路时才恢复它的快照;并发调用合并成一次。 */
|
|
169
|
+
restoreLedger(id) {
|
|
170
|
+
let pending = this.restores.get(id);
|
|
171
|
+
if (pending === undefined) {
|
|
172
|
+
pending = this.ledger(id).restore();
|
|
173
|
+
this.restores.set(id, pending);
|
|
174
|
+
}
|
|
175
|
+
return pending;
|
|
216
176
|
}
|
|
217
177
|
}
|
|
218
|
-
/** 从上游错误里抽 `Retry-After`;抽不到返回 `null`。 */
|
|
219
|
-
function retryAfterOf(error, now) {
|
|
220
|
-
if (typeof error !== 'object' || error === null || !('headers' in error))
|
|
221
|
-
return null;
|
|
222
|
-
const headers = error.headers;
|
|
223
|
-
if (!(headers instanceof Headers))
|
|
224
|
-
return null;
|
|
225
|
-
return parseRetryAfter(headers, now) ?? null;
|
|
226
|
-
}
|
|
227
178
|
//# sourceMappingURL=balance-service.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一个取数来源的账本:快照、状态机、抓取与落盘。
|
|
3
|
+
*
|
|
4
|
+
* 职责:把「读一次 → 归一化 → 落快照」包成一次可合并的请求,并维护这一条路自己的缓存状态。
|
|
5
|
+
* **对外永不抛错**:一切失败都变成 `view.state` 与 `view.error`。
|
|
6
|
+
*
|
|
7
|
+
* 两条路(API Key / 账号登录)各持一份账本,互不影响 —— 谁的失败都不该拖累另一条。
|
|
8
|
+
* @module dsh-ds-balance/services/balance-source
|
|
9
|
+
*/
|
|
10
|
+
import type { BalanceSource, BalanceView, CacheState, Severity } from '../domain/balance.js';
|
|
11
|
+
import { type ErrorCode } from '../domain/errors.js';
|
|
12
|
+
import type { Clock } from '../ports/clock.js';
|
|
13
|
+
import type { CoreStore } from '../ports/core-store.js';
|
|
14
|
+
import type { Logger } from '../ports/logger.js';
|
|
15
|
+
import { type Metrics } from '../ports/metrics.js';
|
|
16
|
+
import type { ConfigService } from './config-service.js';
|
|
17
|
+
/**
|
|
18
|
+
* 一条路的读取策略。
|
|
19
|
+
*
|
|
20
|
+
* 账本不认识凭据、不认识 HTTP —— 换来源就是换一个 reader。
|
|
21
|
+
* `available()` 与 `tag()` 都是**本地读**(凭据/身份来自内存),只有 `read()` 打上游。
|
|
22
|
+
*/
|
|
23
|
+
export interface SourceReader {
|
|
24
|
+
/** 这条路的凭据此刻可用吗。 */
|
|
25
|
+
available(): Promise<boolean>;
|
|
26
|
+
/** 这次快照的账本标识;凭据拿不到时抛错(由调用方吸收)。 */
|
|
27
|
+
tag(): Promise<string>;
|
|
28
|
+
/** 抓一次余额,连账本标识一起给。 */
|
|
29
|
+
read(): Promise<{
|
|
30
|
+
raw: unknown;
|
|
31
|
+
accountTag: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
/** `forceRefresh` 的返回值,形状对齐契约 §8.4。 */
|
|
35
|
+
export interface RefreshResult {
|
|
36
|
+
triggered: boolean;
|
|
37
|
+
joined: boolean;
|
|
38
|
+
cooldownMs: number;
|
|
39
|
+
state: CacheState;
|
|
40
|
+
}
|
|
41
|
+
/** 调度器需要的状态切片。 */
|
|
42
|
+
export interface BalanceStatus {
|
|
43
|
+
state: CacheState;
|
|
44
|
+
errorCode: ErrorCode | null;
|
|
45
|
+
consecutiveFailures: number;
|
|
46
|
+
hasSnapshot: boolean;
|
|
47
|
+
serverRefreshSeconds: number;
|
|
48
|
+
/** 上游给的 `Retry-After`,优先于自算退避。 */
|
|
49
|
+
retryAfterMs: number | null;
|
|
50
|
+
/** 最近一次成功抓取的时刻;从未成功过是 `null`。健康检查要用。 */
|
|
51
|
+
lastSuccessAt: number | null;
|
|
52
|
+
/** 这一份状态属于哪条路。 */
|
|
53
|
+
source: BalanceSource;
|
|
54
|
+
/**
|
|
55
|
+
* 下面四项**只服务健康检查里的处境**(`GET /api/v1/healthz` 的 `situation`):
|
|
56
|
+
* 不开浏览器也要能知道活跃账本此刻会画成什么。判定口径与 `toView` 完全同源。
|
|
57
|
+
*/
|
|
58
|
+
/** 快照里的上游 `is_available`;没有快照时 `false`。 */
|
|
59
|
+
isAvailable: boolean;
|
|
60
|
+
/** 按当前显示币种偏好挑得出可展示的币种吗。 */
|
|
61
|
+
hasSelected: boolean;
|
|
62
|
+
/** 后端算好的严重度。 */
|
|
63
|
+
severity: Severity;
|
|
64
|
+
}
|
|
65
|
+
/** `getView` 的可选参数(账本层:来源已经定好了)。 */
|
|
66
|
+
export interface LedgerViewOptions {
|
|
67
|
+
/** 跳过新鲜度判据强制拉一次。 */
|
|
68
|
+
force?: boolean;
|
|
69
|
+
/** 覆盖展示币种偏好;缺省读配置。 */
|
|
70
|
+
currency?: string;
|
|
71
|
+
}
|
|
72
|
+
/** 构造参数。 */
|
|
73
|
+
export interface SourceLedgerOptions {
|
|
74
|
+
source: BalanceSource;
|
|
75
|
+
reader: SourceReader;
|
|
76
|
+
store: CoreStore;
|
|
77
|
+
config: ConfigService;
|
|
78
|
+
clock: Clock;
|
|
79
|
+
logger?: Logger | undefined;
|
|
80
|
+
metrics?: Metrics | undefined;
|
|
81
|
+
}
|
|
82
|
+
/** 一条路的账本。 */
|
|
83
|
+
export declare class SourceLedger {
|
|
84
|
+
private readonly options;
|
|
85
|
+
private readonly metrics;
|
|
86
|
+
private snapshot;
|
|
87
|
+
private state;
|
|
88
|
+
private error;
|
|
89
|
+
private failures;
|
|
90
|
+
private retryAfterMs;
|
|
91
|
+
/** 上一次真的触发过手动刷新的时刻;`null` 表示本账本还没有过。 */
|
|
92
|
+
private lastManualRefreshAt;
|
|
93
|
+
private inflight;
|
|
94
|
+
/** 落盘失败只报一次,避免每轮刷新都刷屏。 */
|
|
95
|
+
private persistWarned;
|
|
96
|
+
constructor(options: SourceLedgerOptions);
|
|
97
|
+
/** 这条路的 id。 */
|
|
98
|
+
source(): BalanceSource;
|
|
99
|
+
/** 这条路的凭据此刻可用吗。 */
|
|
100
|
+
available(): Promise<boolean>;
|
|
101
|
+
/** 当前状态切片,供调度与健康检查使用。 */
|
|
102
|
+
status(): BalanceStatus;
|
|
103
|
+
/**
|
|
104
|
+
* 当前账本标识的前 8 位;还没有快照时是 `null`。
|
|
105
|
+
*
|
|
106
|
+
* **只回前 8 位**:完整 tag 是账本作用域标识,没有对外的理由。
|
|
107
|
+
*/
|
|
108
|
+
accountTag8(): string | null;
|
|
109
|
+
/**
|
|
110
|
+
* 从存储恢复最近快照(这条账本第一次被用到时调一次)。
|
|
111
|
+
*
|
|
112
|
+
* **按 `accountTag` 过滤**:凭据轮换后 tag 变了,旧快照视为不存在,不混用。
|
|
113
|
+
* 没有凭据可算 tag 时静默跳过。
|
|
114
|
+
*/
|
|
115
|
+
restore(): Promise<void>;
|
|
116
|
+
/**
|
|
117
|
+
* 取当前视图。
|
|
118
|
+
*
|
|
119
|
+
* 有在飞的请求就合并;不 force 且未过期就返回缓存;否则拉一次。
|
|
120
|
+
*/
|
|
121
|
+
getView(options?: LedgerViewOptions): Promise<BalanceView>;
|
|
122
|
+
/**
|
|
123
|
+
* 手动刷新。
|
|
124
|
+
*
|
|
125
|
+
* 冷却中不触发;已有请求在飞时合并并回报 `joined`。
|
|
126
|
+
*
|
|
127
|
+
* **冷却的锚点是「上一次手动刷新」,不是「上一次抓取」**:调度与轮询也在抓,
|
|
128
|
+
* 拿 `snapshot.fetchedAt` 当锚点的话,一次自动刷新就会把用户刚按下的一下吞掉 ——
|
|
129
|
+
* 界面转了圈、倒计时也走了,上游却一次没打。
|
|
130
|
+
*
|
|
131
|
+
* 时刻取**决定触发那一刻**(不是抓完那一刻):客户端从「拿到结果那一刻」起算,
|
|
132
|
+
* 两者相差一个往返 ⇒ 客户端更保守,按钮亮起时按下去一定真的会抓。
|
|
133
|
+
*/
|
|
134
|
+
forceRefresh(reason: string): Promise<RefreshResult>;
|
|
135
|
+
/** 快照是否还在 `serverRefreshSeconds` 窗口内。**只看时间,不看状态。** */
|
|
136
|
+
private withinWindow;
|
|
137
|
+
/**
|
|
138
|
+
* 能否直接拿缓存顶上。
|
|
139
|
+
*
|
|
140
|
+
* 与 {@link withinWindow} 分开:`restore` 要在状态还是 `empty` 时用纯时间判据,
|
|
141
|
+
* 而这里必须要求 `ok` —— 否则一次失败之后,窗口内的旧快照会让 `getView`
|
|
142
|
+
* 永远不再重试。
|
|
143
|
+
*/
|
|
144
|
+
private canServeCache;
|
|
145
|
+
/** 跑一次真实抓取。**所有异常都在这里被吸收。** */
|
|
146
|
+
private fetchOnce;
|
|
147
|
+
/**
|
|
148
|
+
* 把快照落盘。
|
|
149
|
+
*
|
|
150
|
+
* **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
|
|
151
|
+
* @param snapshot - 刚归一化出来的快照。
|
|
152
|
+
*/
|
|
153
|
+
private persist;
|
|
154
|
+
/** 记录一次失败,并把状态推到 `stale` 或 `error`。 */
|
|
155
|
+
private applyFailure;
|
|
156
|
+
/** 记一次抓取耗时(带上来源标签,两条路各自可看)。 */
|
|
157
|
+
private observe;
|
|
158
|
+
/** 记一次抓取结果。 */
|
|
159
|
+
private count;
|
|
160
|
+
/** 把缓存状态推成指标。 */
|
|
161
|
+
private publishGauge;
|
|
162
|
+
/** 把缓存折成对外视图。 */
|
|
163
|
+
private toView;
|
|
164
|
+
/**
|
|
165
|
+
* 这一份视图的严重度。
|
|
166
|
+
*
|
|
167
|
+
* **只服务环的颜色与弧长**;「处境」是另一个维度,由 {@link situationOf} 判,
|
|
168
|
+
* 两者不再各自决定界面形态(历史上它们是两条互相打架的链)。
|
|
169
|
+
* @param selected - 选中币种的那条余额;没快照时是 `null`。
|
|
170
|
+
* @returns 严重度。
|
|
171
|
+
*/
|
|
172
|
+
private severityOf;
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=balance-source.d.ts.map
|