dsh-plugin-billing 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * 余额与金额插件 —— Host 侧。
3
+ *
4
+ * 只做客户端做不到的事:拿提供方凭证去问账户余额,并把「当前路由的模型」一并告诉
5
+ * 浏览器。金额计算留在浏览器侧(那边已经有 token 账目),因此这里不碰 token 统计。
6
+ *
7
+ * ## 为什么走 HTTP 路由而不是 Remote
8
+ *
9
+ * 本部署的 Host↔Client 类型化 Remote(Typert)需要随包发布的生成产物
10
+ * (`typert.host.js` / `typert.remote-client.js`),不适合一个本地小插件。
11
+ * 而 `ctx.webServer` 本来就允许插件注册具名路由(`dsh-client-modules` 也是这么
12
+ * 提供 `/plugins` 的),浏览器同源 `fetch` 即可,无需新增构建链。
13
+ *
14
+ * 路由是**精确匹配**:`webServer.match()` 先查 exact 表再查最长前缀,因此
15
+ * `/plugins/billing/summary` 不会被 `/plugins` 前缀路由抢走。
16
+ *
17
+ * ## 配置
18
+ *
19
+ * 全部走环境变量(插件不声明 `Config` schema,避免与组合器校验耦合):
20
+ *
21
+ * | 环境变量 | 默认值 | 作用 |
22
+ * |---|---|---|
23
+ * | `DSH_BILLING_API_KEY_ENV` | `DEEPSEEK_API_KEY` | 用哪个凭据引用去解析密钥 |
24
+ * | `DSH_BILLING_BASE_URL` / `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | 提供方端点 |
25
+ * | `DSH_BILLING_BALANCE_TTL_MS` | `30000` | 余额缓存时长 |
26
+ *
27
+ * 单价表在 `src/pricing-body.js`,改完执行 `node scripts/build-client.mjs`。
28
+ */
29
+
30
+ /** 余额摘要路由(客户端 fetch 的同源地址)。 */
31
+ import { createPricingReader } from './pricing.js';
32
+ import { overridePath, readOverride, refreshPrices } from './prices.js';
33
+
34
+ const ROUTE_PATH = '/plugins/billing/summary';
35
+
36
+ /** 价格表接口:`GET` 读当前表,`POST .../refresh` 从官方页更新(写本机覆盖文件)。 */
37
+ const PRICES_PREFIX = '/plugins/billing/prices';
38
+ const PRICES_PATH = PRICES_PREFIX;
39
+ const REFRESH_PATH = `${PRICES_PREFIX}/refresh`;
40
+
41
+ /** 提供方默认端点与余额路径。 */
42
+ const DEFAULT_BASE_URL = 'https://api.deepseek.com';
43
+ const BALANCE_PATH = '/user/balance';
44
+
45
+ /** 默认凭据引用名:与 `@deepseek-ai/dsh-llm-deepseek` 的 `apiKeyEnv` 默认值一致。 */
46
+ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
47
+
48
+ /** 余额缓存默认时长:余额变化慢,没必要每次渲染都打接口。 */
49
+ const DEFAULT_TTL_MS = 30_000;
50
+
51
+ /** 单次上游请求超时。 */
52
+ const REQUEST_TIMEOUT_MS = 10_000;
53
+
54
+ /** 会话模型解析结果的缓存时长:命中长、未命中短(会话首轮尚无 `request/context`)。 */
55
+ const MODEL_TTL_HIT_MS = 60_000;
56
+ const MODEL_TTL_MISS_MS = 5_000;
57
+
58
+ /** 计费币种:优先取接口返回的那个,没有时用这个兜底。 */
59
+ const FALLBACK_CURRENCY = 'CNY';
60
+
61
+ /**
62
+ * 读环境变量,空串与纯空白视为未设置。
63
+ * @param name - 变量名。
64
+ * @returns 去除首尾空白后的值,或 `undefined`。
65
+ */
66
+ function envValue(name) {
67
+ const value = process.env[name];
68
+ if (value === undefined) return undefined;
69
+ const trimmed = value.trim();
70
+ return trimmed === '' ? undefined : trimmed;
71
+ }
72
+
73
+ /**
74
+ * 组装运行期选项。每次调用都重读环境变量,便于不重启就调整缓存时长。
75
+ * @returns `{ apiKeyEnv, baseURL, ttlMs }`。
76
+ */
77
+ function options() {
78
+ const ttl = Number(envValue('DSH_BILLING_BALANCE_TTL_MS') ?? DEFAULT_TTL_MS);
79
+ return {
80
+ apiKeyEnv: envValue('DSH_BILLING_API_KEY_ENV') ?? DEFAULT_API_KEY_ENV,
81
+ baseURL: (envValue('DSH_BILLING_BASE_URL') ?? envValue('DEEPSEEK_BASE_URL') ?? DEFAULT_BASE_URL).replace(
82
+ /\/+$/,
83
+ '',
84
+ ),
85
+ ttlMs: Number.isFinite(ttl) && ttl >= 0 ? ttl : DEFAULT_TTL_MS,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * 按凭据 seam 解析 API 密钥:先问凭据服务(它已叠加进程环境、托管存储与 `.env`),
91
+ * 再退回直接读进程环境。
92
+ * @param ctx - Host cordis 上下文。
93
+ * @param envName - 凭据引用名。
94
+ * @returns `{ value, source }`,未配置时为 `undefined`。
95
+ */
96
+ async function resolveApiKey(ctx, envName) {
97
+ const credentials = ctx.get('credentials');
98
+ if (credentials !== undefined && typeof credentials.resolve === 'function') {
99
+ const hit = await credentials.resolve(envName);
100
+ if (hit !== undefined && typeof hit.value === 'string' && hit.value.trim() !== '') {
101
+ return { value: hit.value, source: hit.source ?? 'credentials' };
102
+ }
103
+ }
104
+ const ambient = envValue(envName);
105
+ return ambient === undefined ? undefined : { value: ambient, source: 'env' };
106
+ }
107
+
108
+ /**
109
+ * 读部署的**默认路由**模型(`agentDefaultModel.currentSelection()`)。
110
+ * 这只是兜底:真正在服务某个会话的模型可能与之不同,优先用 {@link modelForSession}。
111
+ * @param ctx - Host cordis 上下文。
112
+ * @returns `{ provider, model }` 或 `null`。
113
+ */
114
+ function defaultModel(ctx) {
115
+ const service = ctx.get('agentDefaultModel');
116
+ if (service === undefined || typeof service.currentSelection !== 'function') return null;
117
+ const selection = service.currentSelection();
118
+ if (selection === null || typeof selection !== 'object' || typeof selection.model !== 'string') return null;
119
+ return {
120
+ provider: typeof selection.provider === 'string' ? selection.provider : null,
121
+ model: selection.model,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * 读一个会话的事件列表,跨 dsh 版本:
127
+ * - **0.1.5+**:`sessionPersistence` 改成句柄式 API,`inspect(id)` 已被移除;
128
+ * 用 `open(id, 'read')` 拿只读句柄(不取得写所有权),`read()` 取事件,随后 `close()`。
129
+ * - **0.1.1**:仍有按 id 寻址的 `inspect(id)`,返回已验证的逻辑视图。
130
+ * @param persistence - `ctx.sessionPersistence` 服务。
131
+ * @param sessionId - 目标会话 id。
132
+ * @returns 事件数组,或 `undefined`(读不到)。
133
+ */
134
+ async function readSessionEvents(persistence, sessionId) {
135
+ if (typeof persistence.open === 'function') {
136
+ const reader = await persistence.open(sessionId, 'read');
137
+ try {
138
+ const slice = await reader.read();
139
+ return Array.isArray(slice?.events) ? slice.events : undefined;
140
+ } finally {
141
+ // 句柄是本地资源,必须释放;close 幂等。
142
+ await reader.close?.();
143
+ }
144
+ }
145
+ if (typeof persistence.inspect === 'function') {
146
+ const view = await persistence.inspect(sessionId);
147
+ return Array.isArray(view?.events) ? view.events : undefined;
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /**
153
+ * 读**某个会话实际使用**的 provider/model:从该会话自己的日志里取最后一条
154
+ * `request/context`。日志是事件溯源的唯一真源,所以这是精确值,而不是部署默认值——
155
+ * 两者可以不同(本机上默认路由是 v4-pro,而这个会话跑的是 v4-flash,单价差 3 倍)。
156
+ *
157
+ * 用 `sessionPersistence.inspect()`:它返回已验证的逻辑视图且**不提交恢复、不发布
158
+ * Session**,对正在运行的会话也是安全的只读操作(`load()` 会在轮次开放时拒绝,且带副作用)。
159
+ * 命中缓存 60 秒,未命中只缓存 5 秒——会话刚起第一轮时还没有 `request/context`,
160
+ * 短缓存能让它尽快转为精确值,同时避免每次轮询都重扫长日志。
161
+ * @param ctx - Host cordis 上下文。
162
+ * @param sessionId - 目标会话 id;缺省时返回 `undefined`。
163
+ * @param cache - 缓存槽(会在此挂载 `models` Map)。
164
+ * @returns `{ provider, model }`,无法确定时为 `undefined`。
165
+ */
166
+ async function modelForSession(ctx, sessionId, cache) {
167
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
168
+ const persistence = ctx.get('sessionPersistence');
169
+ const canRead =
170
+ persistence !== undefined &&
171
+ (typeof persistence.open === 'function' || typeof persistence.inspect === 'function');
172
+ if (!canRead) return undefined;
173
+
174
+ const now = Date.now();
175
+ const cached = cache.models?.get(sessionId);
176
+ if (cached !== undefined && now - cached.at < (cached.value === undefined ? MODEL_TTL_MISS_MS : MODEL_TTL_HIT_MS)) {
177
+ return cached.value;
178
+ }
179
+
180
+ let value;
181
+ try {
182
+ const events = (await readSessionEvents(persistence, sessionId)) ?? [];
183
+ for (let index = events.length - 1; index >= 0; index -= 1) {
184
+ const event = events[index];
185
+ if (event?.type !== 'request/context') continue;
186
+ const model = event.data?.model;
187
+ if (typeof model !== 'string' || model === '') continue;
188
+ value = {
189
+ provider: typeof event.data.provider === 'string' ? event.data.provider : null,
190
+ model,
191
+ };
192
+ break;
193
+ }
194
+ } catch {
195
+ // 会话不存在、日志损坏或后端不支持:一律退回默认路由,不让整条路由失败。
196
+ value = undefined;
197
+ }
198
+
199
+ if (cache.models === undefined) cache.models = new Map();
200
+ if (cache.models.size > 64) cache.models.clear();
201
+ cache.models.set(sessionId, { at: now, value });
202
+ return value;
203
+ }
204
+
205
+ /**
206
+ * 定出用于计价的模型:优先该会话的真实路由,退回部署默认路由。
207
+ * @param ctx - Host cordis 上下文。
208
+ * @param sessionId - 目标会话 id。
209
+ * @param cache - 缓存槽。
210
+ * @returns `{ provider, model, source }`;`source` 为 `session` / `default` / `unknown`。
211
+ */
212
+ async function resolveModel(ctx, sessionId, cache) {
213
+ const session = await modelForSession(ctx, sessionId, cache);
214
+ if (session !== undefined) return { ...session, source: 'session' };
215
+ const fallback = defaultModel(ctx);
216
+ if (fallback !== null) return { ...fallback, source: 'default' };
217
+ return { provider: null, model: null, source: 'unknown' };
218
+ }
219
+
220
+ /**
221
+ * 请求上游余额接口并归一化。
222
+ * @param baseURL - 提供方端点。
223
+ * @param apiKey - 已解析的密钥。
224
+ * @returns 归一化后的余额对象。
225
+ * @throws 带 `code` 的错误(`http-error` / `bad-response` / `network`)。
226
+ */
227
+ async function queryBalance(baseURL, apiKey) {
228
+ let response;
229
+ try {
230
+ response = await fetch(`${baseURL}${BALANCE_PATH}`, {
231
+ headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
232
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
233
+ });
234
+ } catch (error) {
235
+ const detail = error instanceof Error ? error.message : String(error);
236
+ throw Object.assign(new Error(`请求 ${baseURL}${BALANCE_PATH} 失败:${detail}`), { code: 'network' });
237
+ }
238
+
239
+ if (!response.ok) {
240
+ // 上游正文可能带诊断信息,但绝不回显密钥;截断后附上。
241
+ const text = await response.text().catch(() => '');
242
+ const suffix = text.trim() === '' ? '' : ` — ${text.trim().slice(0, 200)}`;
243
+ throw Object.assign(new Error(`余额接口返回 HTTP ${response.status}${suffix}`), { code: 'http-error' });
244
+ }
245
+
246
+ let body;
247
+ try {
248
+ body = await response.json();
249
+ } catch (error) {
250
+ throw Object.assign(new Error('余额接口返回的不是 JSON'), { code: 'bad-response' });
251
+ }
252
+
253
+ const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : [];
254
+ const info = infos.find((entry) => entry?.currency === FALLBACK_CURRENCY) ?? infos[0];
255
+ if (info === undefined || info === null || typeof info !== 'object') {
256
+ throw Object.assign(new Error('余额接口未返回 balance_infos'), { code: 'bad-response' });
257
+ }
258
+
259
+ return {
260
+ state: 'ok',
261
+ isAvailable: body?.is_available === true,
262
+ currency: typeof info.currency === 'string' ? info.currency : FALLBACK_CURRENCY,
263
+ total: String(info.total_balance ?? '0'),
264
+ granted: String(info.granted_balance ?? '0'),
265
+ toppedUp: String(info.topped_up_balance ?? '0'),
266
+ };
267
+ }
268
+
269
+ /**
270
+ * 产出一次余额摘要。成功结果按 TTL 缓存;失败不缓存,因此刚配好密钥的下一步就能生效。
271
+ * @param ctx - Host cordis 上下文。
272
+ * @param cache - 缓存槽(`{ at, balance, models }`)。
273
+ * @param sessionId - 请求方所在的会话 id,用于精确定位计价模型。
274
+ * @returns 响应体。
275
+ */
276
+ async function summary(ctx, cache, sessionId, pricing) {
277
+ const opts = options();
278
+ const now = Date.now();
279
+ const model = await resolveModel(ctx, sessionId, cache);
280
+ // 价格表随每次响应下发:客户端优先用它,因此改价不需要重建 bundle 或刷新页面。
281
+ const priceInfo = pricing?.read() ?? { prices: {}, source: undefined, currency: undefined };
282
+ const priceFields = { prices: priceInfo.prices, priceSource: priceInfo.source, currency: priceInfo.currency };
283
+
284
+ if (cache.at !== undefined && now - cache.at < opts.ttlMs) {
285
+ return { ok: true, fetchedAt: now, currency: cache.balance.currency, balance: cache.balance, model, ...priceFields };
286
+ }
287
+
288
+ let apiKey;
289
+ try {
290
+ apiKey = await resolveApiKey(ctx, opts.apiKeyEnv);
291
+ } catch (error) {
292
+ return {
293
+ ok: false,
294
+ fetchedAt: now,
295
+ currency: FALLBACK_CURRENCY,
296
+ model,
297
+ ...priceFields,
298
+ error: { code: 'credential-error', message: error instanceof Error ? error.message : String(error) },
299
+ };
300
+ }
301
+
302
+ if (apiKey === undefined) {
303
+ return {
304
+ ok: false,
305
+ fetchedAt: now,
306
+ currency: FALLBACK_CURRENCY,
307
+ model,
308
+ ...priceFields,
309
+ error: {
310
+ code: 'missing-credential',
311
+ message: `未配置凭据 ${opts.apiKeyEnv}(可设 DSH_BILLING_API_KEY_ENV 指向别的引用)`,
312
+ },
313
+ };
314
+ }
315
+
316
+ try {
317
+ const balance = await queryBalance(opts.baseURL, apiKey.value);
318
+ cache.at = now;
319
+ cache.balance = balance;
320
+ return { ok: true, fetchedAt: now, currency: balance.currency, balance, model, ...priceFields };
321
+ } catch (error) {
322
+ return {
323
+ ok: false,
324
+ fetchedAt: now,
325
+ currency: FALLBACK_CURRENCY,
326
+ model,
327
+ ...priceFields,
328
+ error: {
329
+ code: typeof error?.code === 'string' ? error.code : 'unexpected',
330
+ message: error instanceof Error ? error.message : String(error),
331
+ },
332
+ };
333
+ }
334
+ }
335
+
336
+ /**
337
+ * 与部署数据面(`/api`)对齐的请求准入判断。
338
+ *
339
+ * 0.1.5 起 dsh web 给数据请求加了两道闸,`connection` 服务把它们打包成
340
+ * `requestRejection(request)`:
341
+ * 1. **Host/Origin 信任围栏** —— 不属于受信 authority 的请求直接 403(防 DNS rebinding);
342
+ * 2. **浏览器会话认证** —— 带 `?token=` 访问一次首页后,服务器签发绑定 authority 的
343
+ * 签名 cookie(`dsh-auth-<authority-hash>`,Path=/、HttpOnly、SameSite=Strict),
344
+ * 之后校验它,失败给 401。
345
+ *
346
+ * 本插件的余额路由返回的是**数据**(账户余额),所以用同一入口做准入,语义与 `/api` 一致。
347
+ * `requestRejection` 返回 `undefined` 表示放行。
348
+ *
349
+ * 0.1.1 没有这个方法(它的围栏在别处),此时返回 `undefined` 跳过,保持与旧版部署一致。
350
+ * @param ctx - Host cordis 上下文。
351
+ * @param req - 传入的 HTTP 请求。
352
+ * @returns `undefined` 放行;`401` / `403` 为拒绝状态码。
353
+ */
354
+ function requestRejection(ctx, req) {
355
+ const connection = ctx.get('connection');
356
+ if (connection === undefined || typeof connection.requestRejection !== 'function') return undefined;
357
+ try {
358
+ const rejection = connection.requestRejection(req);
359
+ return rejection === 401 || rejection === 403 ? rejection : undefined;
360
+ } catch {
361
+ // 准入服务本身异常时按拒绝处理:这是数据面,宁可 401 也不放开。
362
+ return 401;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 路由处理器。返回 Promise,交给 `webServer` 的 `await route.handler(...)` 统一
368
+ * 收敛错误——同时让处理器在测试里可被 await。
369
+ * @param req - node:http 请求。
370
+ * @param res - node:http 响应。
371
+ * @param ctx - Host cordis 上下文。
372
+ * @param cache - 余额缓存槽。
373
+ * @param pricing - 价格读取器(随响应下发价格表)。
374
+ * @returns 响应写完后的 Promise。
375
+ */
376
+ async function handleSummary(req, res, ctx, cache, pricing) {
377
+ const rejection = requestRejection(ctx, req);
378
+ if (rejection !== undefined) {
379
+ res.writeHead(rejection, { 'cache-control': 'no-store', 'content-type': 'text/plain; charset=utf-8' });
380
+ res.end(
381
+ rejection === 401
382
+ ? 'dsh web authentication required; reopen the URL printed by dsh web.\n'
383
+ : 'forbidden: request authority is not trusted by this deployment.\n',
384
+ );
385
+ return;
386
+ }
387
+
388
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
389
+ res.writeHead(405, { allow: 'GET, HEAD' });
390
+ res.end();
391
+ return;
392
+ }
393
+
394
+ // 客户端带上 `?session=<id>` 时按该会话的真实路由计价,否则退回部署默认路由。
395
+ let sessionId;
396
+ try {
397
+ sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session') ?? undefined;
398
+ } catch {
399
+ sessionId = undefined;
400
+ }
401
+
402
+ let body;
403
+ try {
404
+ body = await summary(ctx, cache, sessionId, pricing);
405
+ } catch (error) {
406
+ // 兜底:任何未预料的异常也要给出可解析的响应,别让前端 fetch 挂住。
407
+ const fallback = defaultModel(ctx);
408
+ const priceInfo = pricing?.read() ?? { prices: {}, source: undefined };
409
+ body = {
410
+ ok: false,
411
+ fetchedAt: Date.now(),
412
+ currency: FALLBACK_CURRENCY,
413
+ model: { provider: fallback?.provider ?? null, model: fallback?.model ?? null, source: 'default' },
414
+ prices: priceInfo.prices,
415
+ priceSource: priceInfo.source,
416
+ error: { code: 'unexpected', message: error instanceof Error ? error.message : String(error) },
417
+ };
418
+ }
419
+
420
+ const text = JSON.stringify(body);
421
+ res.writeHead(200, {
422
+ 'content-type': 'application/json; charset=utf-8',
423
+ 'cache-control': 'no-store',
424
+ 'content-length': Buffer.byteLength(text),
425
+ });
426
+ res.end(req.method === 'HEAD' ? undefined : text);
427
+ }
428
+
429
+ /**
430
+ * 价格表接口:读当前表 / 从官方页更新(写入本机覆盖文件)。
431
+ *
432
+ * 「一点就更新」按钮打的就是这里。仍然走与数据面同一套准入(`requestRejection`),
433
+ * 因为这是一个**会写文件**的动作。
434
+ * @param ctx - Host cordis 上下文。
435
+ * @param req - 请求。
436
+ * @param res - 应答。
437
+ * @param pricing - 价格读取器(写成功后要让它丢缓存,界面无需等 TTL)。
438
+ */
439
+ async function handlePrices(ctx, req, res, pricing) {
440
+ const connection = ctx.get('connection');
441
+ const rejection =
442
+ typeof connection?.requestRejection === 'function' ? connection.requestRejection(req) : undefined;
443
+ const send = (status, value) => {
444
+ const text = `${JSON.stringify(value)}\n`;
445
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
446
+ res.end(text);
447
+ };
448
+ if (rejection !== undefined) {
449
+ send(rejection, { ok: false, error: rejection === 401 ? 'authentication-required' : 'forbidden' });
450
+ return;
451
+ }
452
+
453
+ const path = (req.url ?? '/').split('?')[0];
454
+ /**
455
+ * 描述当前价格表。
456
+ * 注意:**每次调用都重新读**——刷新成功后必须用刷新后的状态回答,
457
+ * 否则界面会显示"没变"(这个坑踩过一次)。
458
+ */
459
+ const describe = (state = pricing.read()) => {
460
+ const override = readOverride();
461
+ return {
462
+ ok: true,
463
+ source: state.source,
464
+ currency: state.currency,
465
+ modelCount: Object.keys(state.prices).length,
466
+ models: Object.keys(state.prices),
467
+ overrideUsed: state.overrideUsed,
468
+ overridePath: overridePath(),
469
+ // 覆盖文件里的抓取日期:界面用它显示"上次更新于"。
470
+ updatedAt: typeof override?.fetchedAt === 'string' ? override.fetchedAt : null,
471
+ };
472
+ };
473
+
474
+ if (path === PRICES_PATH && req.method === 'GET') {
475
+ send(200, describe());
476
+ return;
477
+ }
478
+ if (path === REFRESH_PATH) {
479
+ if (req.method !== 'POST') {
480
+ res.writeHead(405, { allow: 'POST', 'cache-control': 'no-store' });
481
+ res.end();
482
+ return;
483
+ }
484
+ const before = { ...pricing.read().prices };
485
+ try {
486
+ const result = await refreshPrices();
487
+ // 丢掉 TTL 缓存,让界面立刻拿到新价(否则最多要等 5 秒)。
488
+ pricing.invalidate();
489
+ const after = pricing.read();
490
+ const changed = Object.keys(after.prices).filter(
491
+ (model) => JSON.stringify(before[model]) !== JSON.stringify(after.prices[model]),
492
+ );
493
+ send(200, { ...describe(after), changed, models: result.models, path: result.path });
494
+ } catch (error) {
495
+ // 解析/网络失败:把原因原样给界面(自校验的报错信息本身就是给用户看的)。
496
+ send(502, {
497
+ ok: false,
498
+ error: 'refresh-failed',
499
+ message: error instanceof Error ? error.message : String(error),
500
+ ...describe(),
501
+ });
502
+ }
503
+ return;
504
+ }
505
+ send(404, { ok: false, error: 'not-found' });
506
+ }
507
+
508
+ /**
509
+ * 注册余额摘要路由。
510
+ * @param ctx - Host cordis 上下文。
511
+ */
512
+ export function apply(ctx) {
513
+ const opts = options();
514
+ /** 成功结果的缓存槽:TTL 内复用,避免前端每次渲染都打上游。 */
515
+ const cache = {};
516
+ /** 价格读取器:每次请求读 pricing.json(+ 本机覆盖),因此改价无需重启。 */
517
+ const pricing = createPricingReader();
518
+
519
+ ctx.inject(['webServer'], (hostCtx) => {
520
+ hostCtx.effect(
521
+ () =>
522
+ hostCtx.webServer.register({
523
+ kind: 'exact',
524
+ path: ROUTE_PATH,
525
+ handler: (req, res) => handleSummary(req, res, ctx, cache, pricing),
526
+ }),
527
+ 'billing: summary route',
528
+ );
529
+ // 价格表:读 + 「一点就更新」。用 prefix 一次挂住两个子路径。
530
+ hostCtx.effect(
531
+ () =>
532
+ hostCtx.webServer.register({
533
+ kind: 'prefix',
534
+ path: PRICES_PREFIX,
535
+ // 必须把 promise 返回出去:调用方(测试、上层)靠它等待异步完成。
536
+ handler: (req, res) =>
537
+ handlePrices(ctx, req, res, pricing).catch(() => {
538
+ if (!res.headersSent) {
539
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
540
+ res.end('{"ok":false,"error":"internal"}\n');
541
+ } else {
542
+ res.end();
543
+ }
544
+ }),
545
+ }),
546
+ 'billing: prices route',
547
+ );
548
+ });
549
+
550
+ // 启动时把鉴权可用性写进日志:拿不到 connection 服务时会退化成不校验,
551
+ // 这一点必须可见,而不是悄悄放开。
552
+ const connection = ctx.get('connection');
553
+ const authReady = connection !== undefined && typeof connection.requestRejection === 'function';
554
+ const priceFiles = pricing.files();
555
+ console.log(
556
+ `billing: 价格表 ${Object.keys(pricing.read().prices).length} 个模型(${pricing.read().source});` +
557
+ `内置 ${priceFiles.builtin},覆盖 ${priceFiles.override}`,
558
+ );
559
+ ctx.logger?.info?.(
560
+ `billing: 余额路由 ${ROUTE_PATH} 就绪(端点 ${opts.baseURL}${BALANCE_PATH},凭据 ${opts.apiKeyEnv});` +
561
+ `数据面鉴权 ${authReady ? '已启用(connection.requestRejection)' : '不可用 —— 按旧版跳过校验'}`,
562
+ );
563
+ }