dsh-account-pool 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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * WorkBuddy 上游客户端。
3
+ *
4
+ * 职责:
5
+ * - 把 DSH 发来的 OpenAI 请求改写成上游认的形态(强制流式、developer→system、tool_choice 压平)
6
+ * - 带上 CLI 形态请求头,转发到 copilot.tencent.com / www.workbuddy.ai
7
+ * - 拉模型目录,解析出上下文、输出上限、积分倍率、多模态、推理档位
8
+ * - 把上游失败分类,供选号器决定「换号重试」还是「直接报错」
9
+ *
10
+ * 上游全部怪癖都集中在这一层,其他模块不需要知道。
11
+ */
12
+
13
+ import { chatBaseOf, originOf } from './accounts.js'
14
+ import { CLIENT_UA, DESKTOP_UA, IDE_UA, commonHeaders as baseHeaders } from './headers.js'
15
+
16
+ /** 单次 JSON 请求超时。 */
17
+ const JSON_TIMEOUT_MS = 30_000
18
+
19
+ /** 错误响应体截断长度,避免把上游 HTML 整页塞进日志。 */
20
+ const ERROR_BODY_LIMIT = 4096
21
+
22
+ /** 国内版模型目录路径(国际版走别的路径,见下)。 */
23
+ const MODELS_PATH = '/v2/enterprises/personal/models'
24
+
25
+ /** 国际版模型目录路径:配置服务按客户端通道返回不同清单。 */
26
+ const GLOBAL_CONFIG_PATH = '/v3/config'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // 失败分类
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** 积分/额度不足的文案标记(中英双语,上游两种都可能出现)。 */
33
+ const HARD_CREDIT_MARKERS = [
34
+ 'insufficient credit', 'no credit', 'credit exhausted', 'out of credit',
35
+ 'quota exceeded', 'quota exhaust', 'payment required', 'credit not enough',
36
+ 'not enough credit',
37
+ '积分不足', '额度不足', '余额不足', '积分用完', '额度用尽', '没有积分',
38
+ ]
39
+
40
+ /** 会话失效标记:需要重新登录,换号也没用(但换号可以让别的账号顶上)。 */
41
+ const SESSION_DEAD_MARKERS = ['Offline user session not found', '12153']
42
+
43
+ /**
44
+ * 分类上游失败。顺序很重要:先看额度,再看会话,最后按状态码兜底。
45
+ * 分类结果直接决定选号器怎么处置这个账号。
46
+ *
47
+ * @returns {'hard_credit'|'soft_rate'|'session_dead'|'not_found'|'server'|'client'}
48
+ */
49
+ export function classifyUpstreamError(status, body) {
50
+ if (status === 402) return 'hard_credit'
51
+ const lower = String(body ?? '').toLowerCase()
52
+ for (const marker of HARD_CREDIT_MARKERS) {
53
+ if (lower.includes(marker.toLowerCase()) || String(body ?? '').includes(marker)) return 'hard_credit'
54
+ }
55
+ for (const marker of SESSION_DEAD_MARKERS) {
56
+ if (String(body ?? '').includes(marker)) return 'session_dead'
57
+ }
58
+ if (status === 429) return 'soft_rate'
59
+ if (status === 404) return 'not_found'
60
+ if (status >= 500) return 'server'
61
+ return 'client'
62
+ }
63
+
64
+ /**
65
+ * 哪些失败值得换个账号重试。
66
+ * - hard_credit 该号没钱了 → 换号
67
+ * - soft_rate 该号被限流 → 换号
68
+ * - session_dead 该号掉线 → 换号
69
+ * - server/not_found 上游抖动 → 换号(别的号可能正常)
70
+ * - client 请求本身有问题 → 换号也没用,不重试(否则白白又消耗一次)
71
+ */
72
+ export function isRetryableWithAnotherAccount(kind) {
73
+ return kind === 'hard_credit' || kind === 'soft_rate' || kind === 'session_dead'
74
+ || kind === 'server' || kind === 'not_found'
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // 请求体改写
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * tool_choice 归一化:上游该字段只认字符串,对象形式会 400。
83
+ * DSH 可能发 {"type":"auto"} 这种,需要压平。
84
+ */
85
+ function normalizeToolChoice(obj) {
86
+ const choice = obj.tool_choice
87
+ if (choice === undefined || choice === null) return
88
+ if (typeof choice === 'string') return
89
+ if (typeof choice === 'object') {
90
+ const type = choice.type
91
+ if (typeof type === 'string') {
92
+ obj.tool_choice = type
93
+ return
94
+ }
95
+ }
96
+ // 认不出来的形态直接删,总比让上游 400 好。
97
+ delete obj.tool_choice
98
+ }
99
+
100
+ /**
101
+ * 把 DSH 的请求体改写成上游能接受的形态:
102
+ * 1. 强制 stream:true——上游拒绝非流式
103
+ * 2. developer 角色改 system——上游不认 developer,会报「unapproved channel」
104
+ * 3. tool_choice 压平为字符串
105
+ *
106
+ * 解析失败时原样返回,让上游自己去报错,不在本地吞掉。
107
+ */
108
+ export function prepareChatBody(source) {
109
+ let body
110
+ try {
111
+ body = JSON.parse(source)
112
+ } catch {
113
+ return source
114
+ }
115
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return source
116
+
117
+ body.stream = true
118
+
119
+ if (Array.isArray(body.messages)) {
120
+ for (const message of body.messages) {
121
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) continue
122
+ if (message.role === 'developer') message.role = 'system'
123
+ }
124
+ }
125
+
126
+ normalizeToolChoice(body)
127
+ return JSON.stringify(body)
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // 请求头
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /** 所有上游请求共用的头。 */
135
+ function commonHeaders(credential, overrides = {}) {
136
+ return baseHeaders({
137
+ origin: originOf(credential.domain),
138
+ // 区域直接取凭据字段(导入/登录时已判定),比按域名后缀猜更准。
139
+ region: credential.region === 'global' ? 'global' : 'cn',
140
+ uid: credential.uid,
141
+ ...overrides,
142
+ })
143
+ }
144
+
145
+ /**
146
+ * chat 请求头。
147
+ * 安全红线:绝不放 refresh token——它只在刷新端点出现。
148
+ */
149
+ function chatHeaders(credential) {
150
+ const headers = {
151
+ ...commonHeaders(credential),
152
+ authorization: `Bearer ${credential.accessToken}`,
153
+ 'x-product': 'SaaS',
154
+ }
155
+ // 上游用 X-No-* 表示「本字段确实为空」,而不是省略字段。
156
+ if (credential.uid) headers['x-user-id'] = credential.uid
157
+ else headers['x-no-user-id'] = '1'
158
+ if (credential.enterpriseId) headers['x-enterprise-id'] = credential.enterpriseId
159
+ else headers['x-no-enterprise-id'] = '1'
160
+ if (credential.domain) headers['x-domain'] = credential.domain
161
+ else headers['x-no-department-info'] = '1'
162
+ return headers
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // 模型目录
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** 从上游 `credits` 字符串(形如 "x0.71")解析出数字倍率;解析不出返回 undefined。 */
170
+ export function parseCreditMultiplier(raw) {
171
+ if (typeof raw !== 'string') return undefined
172
+ const match = /([0-9]*\.?[0-9]+)/.exec(raw)
173
+ if (match === null) return undefined
174
+ const value = Number(match[1])
175
+ return Number.isFinite(value) ? value : undefined
176
+ }
177
+
178
+ /**
179
+ * 单数 effort 形态(只有 `effort`,没有 supportedEfforts/defaultEffort/
180
+ * canDisableThinking)在实测中表示「不发 reasoning_effort 就完全不思考」,
181
+ * 也就是说这些模型其实接受整条档位阶梯。所以折成完整阶梯,并把声明的值
182
+ * 当作默认档——这个结论来自参照项目在真机上的探测,不是推测。
183
+ */
184
+ const SINGULAR_EFFORT_LADDER = ['low', 'medium', 'high', 'xhigh', 'max']
185
+
186
+ /** 是否是单数 effort 形态:只声明 effort,其余字段一个都没有。 */
187
+ function isSingularEffortForm(raw) {
188
+ return typeof raw.effort === 'string'
189
+ && !Array.isArray(raw.supportedEfforts)
190
+ && typeof raw.defaultEffort !== 'string'
191
+ && typeof raw.canDisableThinking !== 'boolean'
192
+ }
193
+
194
+ /** 单数形态展开为阶梯;认不出的值就只留它自己。 */
195
+ function singularEffortLadder(raw) {
196
+ const effort = typeof raw.effort === 'string' ? raw.effort : undefined
197
+ if (effort === undefined) return undefined
198
+ return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort]
199
+ }
200
+
201
+ /**
202
+ * 解析推理档位声明。上游有两种拼写:
203
+ * - 复数形式 reasoning.supportedEfforts(完整清单)
204
+ * - 单数形式 reasoning.effort(只声明默认档,实为完整阶梯)
205
+ *
206
+ * canDisableThinking 只在字段显式为布尔时采纳;单数形态下推定为可关闭。
207
+ * 绝不自己编造「可以关思考」——那会让 DSH 展示一个上游不支持的档位。
208
+ */
209
+ function parseReasoning(raw) {
210
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
211
+ const effort = typeof raw.effort === 'string' ? raw.effort : undefined
212
+ const supported = Array.isArray(raw.supportedEfforts)
213
+ ? raw.supportedEfforts.filter(e => typeof e === 'string')
214
+ : singularEffortLadder(raw)
215
+ const defaultEffort = typeof raw.defaultEffort === 'string' ? raw.defaultEffort : effort
216
+ const canDisableThinking = typeof raw.canDisableThinking === 'boolean'
217
+ ? raw.canDisableThinking
218
+ : isSingularEffortForm(raw) ? true : undefined
219
+
220
+ if (supported === undefined && defaultEffort === undefined && canDisableThinking === undefined) {
221
+ return undefined
222
+ }
223
+ return {
224
+ supportedEfforts: supported === undefined || supported.length === 0 ? undefined : supported,
225
+ defaultEffort,
226
+ canDisableThinking,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 上游模型条目 → 内部模型描述。
232
+ *
233
+ * 字段缺失就留空,绝不编造——尤其:
234
+ * - 上游标了 disabled 的模型直接丢弃
235
+ * - 上下文/输出上限缺失导致无法定预算的模型也丢弃
236
+ * - supportsToolCall 只在显式为布尔时才采纳(undefined 表示上游没说,
237
+ * 此时交给 pi-ai 的默认行为,不要替它下结论)
238
+ */
239
+ export function parseUpstreamModel(raw) {
240
+ if (typeof raw !== 'object' || raw === null) return undefined
241
+ const id = String(raw.id ?? raw.model ?? '')
242
+ if (id === '' || raw.disabled === true) return undefined
243
+
244
+ const contextWindow = Number(raw.maxInputTokens ?? raw.contextLength ?? 0)
245
+ const maxTokens = Number(raw.maxOutputTokens ?? raw.max_output_tokens ?? 0)
246
+ // 两个上限都拿不到就没法定预算,这种条目宁可不要。
247
+ if (contextWindow <= 0 || maxTokens <= 0) return undefined
248
+
249
+ const name = typeof raw.name === 'string' && raw.name !== ''
250
+ ? raw.name
251
+ : id.replace(/^[a-z]+:/, '')
252
+
253
+ const supportsToolCall = typeof raw.supportsToolCall === 'boolean'
254
+ ? raw.supportsToolCall
255
+ : typeof raw.supports_tool_call === 'boolean' ? raw.supports_tool_call : undefined
256
+
257
+ return {
258
+ id,
259
+ name,
260
+ contextWindow,
261
+ maxTokens,
262
+ creditMultiplier: parseCreditMultiplier(raw.credits),
263
+ multimodal: raw.supportsImages === true || raw.supports_images === true,
264
+ reasoning: parseReasoning(raw.reasoning),
265
+ supportsToolCall,
266
+ descriptionZh: raw.descriptionZh ?? raw.description,
267
+ }
268
+ }
269
+
270
+ /** 在任意嵌套结构里找模型数组——两个区域的文档外层形状不同,内层条目一致。 */
271
+ function isNonChatModel(id, maxOutputTokens, tags) {
272
+ const lowered = String(id ?? '').trim().toLowerCase()
273
+ for (const prefix of ['nes-', 'completion-', 'codewise-']) {
274
+ if (lowered.startsWith(prefix)) return true
275
+ }
276
+ if (typeof maxOutputTokens === 'number' && maxOutputTokens > 0 && maxOutputTokens <= 256) {
277
+ return true
278
+ }
279
+ if (Array.isArray(tags) && tags.includes('text-to-image')) return true
280
+ return false
281
+ }
282
+
283
+ /**
284
+ * 从文档里取出「CLI 通道认可的模型 id 清单」。
285
+ *
286
+ * 为什么不直接用 data.models 全量:同一个端点返回 30 个模型,但
287
+ * agents[cli].models 只列了 16 个。CLI 通道(我们扮演的就是 CLI)只认这 16 个,
288
+ * 其余属于 IDE 或其它 agent。混进来会出现「选了就报错」的条目——
289
+ * 实测 glm-4.6 返回 HTTP 400 code=11102「model service info not found」。
290
+ *
291
+ * roster 缺失或为空时返回 undefined,调用方据此回落到全量列表:
292
+ * roster 是上游实现细节,把它当硬性前提会让插件在目录变化时直接失效。
293
+ */
294
+ function cliRosterOf(data) {
295
+ const agents = data?.agents
296
+ if (!Array.isArray(agents)) return undefined
297
+ for (const agent of agents) {
298
+ if (agent !== null && typeof agent === 'object' && agent.name === 'cli') {
299
+ const models = agent.models
300
+ if (Array.isArray(models) && models.length > 0) {
301
+ return models.filter(id => typeof id === 'string' && id !== '')
302
+ }
303
+ }
304
+ }
305
+ return undefined
306
+ }
307
+
308
+ /**
309
+ * 第二路探测:/v3/config(IDE 通道)。
310
+ *
311
+ * 为什么需要第二路:企业端点的 cli roster 只有 16 个模型,而 IDE 通道还
312
+ * 额外提供 deepseek-v4-flash、hy4-preview-f 等。网关正是把两路取并集
313
+ * (v3 优先,企业端点补缺),才得到完整的 18 个。
314
+ *
315
+ * 失败时返回空数组而不抛错——第二路是增强,不该拖垮主路。
316
+ */
317
+ async function fetchV3ConfigModels(credential) {
318
+ const headers = {
319
+ accept: 'application/json, text/plain, */*',
320
+ 'x-requested-with': 'XMLHttpRequest',
321
+ authorization: `Bearer ${credential.accessToken}`,
322
+ 'x-product': 'SaaS',
323
+ 'user-agent': IDE_UA,
324
+ }
325
+ if (credential.uid) headers['x-user-id'] = credential.uid
326
+ if (credential.domain) headers['x-domain'] = credential.domain
327
+
328
+ try {
329
+ const response = await fetch(`${chatBaseOf(credential.domain)}/v3/config`, {
330
+ headers,
331
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
332
+ })
333
+ if (!response.ok) return []
334
+ const envelope = await response.json()
335
+ if (typeof envelope.code === 'number' && envelope.code !== 0) return []
336
+ const models = envelope.data?.models
337
+ if (!Array.isArray(models)) return []
338
+ // v3 面取全量(不按 cli roster 过滤),但仍剔除非对话模型。
339
+ return models
340
+ .filter(m => m !== null && typeof m === 'object')
341
+ .filter(m => {
342
+ const id = String(m.id ?? '')
343
+ return id !== '' && !isNonChatModel(id, m.maxOutputTokens, m.tags) && m.disabled !== true
344
+ })
345
+ .map(parseUpstreamModel)
346
+ .filter(m => m !== undefined)
347
+ } catch {
348
+ return []
349
+ }
350
+ }
351
+
352
+ /**
353
+ * 拉取某账号可见的模型目录。
354
+ * 国内版走 /v2/enterprises/personal/models;
355
+ * 国际版走 /v3/config 并伪装成桌面端通道(CLI 通道会漏掉部分模型)。
356
+ */
357
+ export async function fetchModels(credential) {
358
+ const base = chatBaseOf(credential.domain)
359
+ const isGlobal = credential.region === 'global'
360
+ const path = isGlobal ? GLOBAL_CONFIG_PATH : MODELS_PATH
361
+
362
+ const headers = {
363
+ ...commonHeaders(credential, isGlobal ? { userAgent: DESKTOP_UA } : {}),
364
+ authorization: `Bearer ${credential.accessToken}`,
365
+ }
366
+ if (credential.uid) headers['x-user-id'] = credential.uid
367
+
368
+ const response = await fetch(`${base}${path}`, {
369
+ headers,
370
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
371
+ })
372
+ if (!response.ok) {
373
+ throw new Error(`拉取模型目录失败:HTTP ${response.status}`)
374
+ }
375
+ const envelope = await response.json()
376
+ if (typeof envelope.code === 'number' && envelope.code !== 0) {
377
+ throw new Error(`拉取模型目录失败:code=${envelope.code} ${envelope.msg ?? ''}`)
378
+ }
379
+
380
+ const data = envelope.data ?? envelope
381
+ const allModels = Array.isArray(data?.models) ? data.models : []
382
+ if (allModels.length === 0) throw new Error('模型目录里没有 models 数组')
383
+
384
+ // 先按 id 建表,便于按 roster 取回顺序与完整字段。
385
+ const byId = new Map()
386
+ for (const raw of allModels) {
387
+ if (raw === null || typeof raw !== 'object') continue
388
+ const id = String(raw.id ?? raw.model ?? '')
389
+ if (id === '') continue
390
+ // 非对话模型(嵌入/补全/文生图/超小输出)直接剔除,
391
+ // 它们不是给对话用的,选到只会报错。
392
+ if (isNonChatModel(id, raw.maxOutputTokens, raw.tags)) continue
393
+ // 明确标记 disabled 的也剔除。
394
+ if (raw.disabled === true) continue
395
+ byId.set(id, raw)
396
+ }
397
+
398
+ // 主路结果:有 cli roster 就按它筛(CLI 通道只认这些);
399
+ // 没有就回落到全量,避免上游改结构后插件直接失效。
400
+ const roster = cliRosterOf(data)
401
+ const primaryRaw = roster === undefined
402
+ ? [...byId.values()]
403
+ : roster.map(id => byId.get(id)).filter(raw => raw !== undefined)
404
+ if (primaryRaw.length === 0) throw new Error('cli roster 与模型表没有交集')
405
+
406
+ const primary = primaryRaw.map(parseUpstreamModel).filter(m => m !== undefined)
407
+
408
+ // 国际版没有第二路(它走 /v3/config 本身就是完整目录)。
409
+ if (isGlobal) return primary
410
+
411
+ // 第二路:/v3/config(IDE 通道)补缺。企业端点的 cli roster 只有 16 个,
412
+ // IDE 通道还额外提供 deepseek-v4-flash、hy4-preview-f 等——网关正是靠
413
+ // 两路取并集才凑齐 18 个。合并规则:主路优先,第二路只补主路没有的 id。
414
+ const secondary = await fetchV3ConfigModels(credential)
415
+ if (secondary.length === 0) return primary
416
+
417
+ const seen = new Set(primary.map(m => m.id))
418
+ const merged = [...primary]
419
+ for (const model of secondary) {
420
+ if (seen.has(model.id)) continue
421
+ seen.add(model.id)
422
+ merged.push(model)
423
+ }
424
+ return merged
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // 对话流式转发
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * 发一次 chat 请求,返回上游原始 Response(SSE 流)。
433
+ *
434
+ * 不做账号选择——由调用方(shim)决定用哪个凭证,这样选号与传输职责分离。
435
+ *
436
+ * @returns {Promise<{ok:true,response:Response}|{ok:false,status:number,kind:string,message:string}>}
437
+ */
438
+ export async function chatStream(credential, bodyJson, signal) {
439
+ let response
440
+ try {
441
+ response = await fetch(`${chatBaseOf(credential.domain)}/v2/chat/completions`, {
442
+ method: 'POST',
443
+ headers: chatHeaders(credential),
444
+ body: bodyJson,
445
+ signal,
446
+ })
447
+ } catch (error) {
448
+ // 网络层失败归为 server:值得换个账号再试。
449
+ return { ok: false, status: 0, kind: 'server', message: `网络错误:${error?.message ?? error}` }
450
+ }
451
+
452
+ if (response.ok) return { ok: true, response }
453
+
454
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT)
455
+ return {
456
+ ok: false,
457
+ status: response.status,
458
+ kind: classifyUpstreamError(response.status, text),
459
+ message: text,
460
+ }
461
+ }
462
+
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // 积分查询
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /** 国内版计费域。 */
469
+ const CN_BILLING_BASE = 'https://www.codebuddy.cn'
470
+
471
+ /** 计费接口的产品码(CodeBuddy)。 */
472
+ const BILLING_PRODUCT_CODE = 'p_tcaca'
473
+
474
+ /** 「即将到期」的判定窗口:3 天。 */
475
+ const EXPIRING_SOON_MS = 3 * 24 * 3600 * 1000
476
+
477
+ /** 计费域:国内版固定 codebuddy.cn;国际版跟随凭据自己的域。 */
478
+ function billingBaseOf(credential) {
479
+ return credential.region === 'global' ? chatBaseOf(credential.domain) : CN_BILLING_BASE
480
+ }
481
+
482
+ /** 计费接口要的时间格式:YYYY-MM-DD HH:mm:ss(本地时区,无时区后缀)。 */
483
+ function formatBillingTime(date) {
484
+ const pad = (n) => String(n).padStart(2, '0')
485
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
486
+ + ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
487
+ }
488
+
489
+ /** 日期字段可能是毫秒数或字符串,统一转成毫秒;认不出返回 undefined。 */
490
+ function parseDateMs(raw) {
491
+ if (typeof raw === 'number' && raw > 1e12) return raw
492
+ if (typeof raw === 'string' && raw !== '') {
493
+ const parsed = Date.parse(raw)
494
+ if (!Number.isNaN(parsed)) return parsed
495
+ }
496
+ return undefined
497
+ }
498
+
499
+ /**
500
+ * 解析计费响应的嵌套结构。
501
+ *
502
+ * 真实层级是 data.Response.Data.Accounts——不是平铺的,直接取
503
+ * data.Accounts 会拿到空数组。字段名也要注意:剩余量叫 CapacityRemain,
504
+ * 而月包(CapacityType 4)要用 CycleCapacityRemain。
505
+ */
506
+ function parseBillingAccounts(envelope) {
507
+ const wrapper = envelope?.data
508
+ if (wrapper === null || typeof wrapper !== 'object') return []
509
+ const response = wrapper.Response
510
+ if (response === null || typeof response !== 'object') return []
511
+ const inner = response.Data
512
+ if (inner === null || typeof inner !== 'object') return []
513
+ return Array.isArray(inner.Accounts) ? inner.Accounts : []
514
+ }
515
+
516
+ /**
517
+ * 查询一个账号的剩余积分,按套餐聚合。
518
+ *
519
+ * 为什么区分月包与一次性礼包:
520
+ * CapacityType 4 是月包(每周期刷新、不过期),剩余看 CycleCapacityRemain;
521
+ * 其余是抵扣式礼包,剩余看 CapacityRemain,并且耗尽/过期的要丢掉,
522
+ * 否则「最近到期」列表会被一堆没用的零余额条目淹没。
523
+ *
524
+ * 注意:该端点必须带 Origin/Referer/User-Agent,否则上游直接 403。
525
+ */
526
+ export async function fetchCredits(credential) {
527
+ const now = new Date()
528
+ const body = {
529
+ PageNumber: 1,
530
+ PageSize: 100,
531
+ ProductCode: BILLING_PRODUCT_CODE,
532
+ Status: [0, 3],
533
+ PackageEndTimeRangeBegin: formatBillingTime(now),
534
+ PackageEndTimeRangeEnd: formatBillingTime(new Date(now.getTime() + 365 * 101 * 24 * 3600 * 1000)),
535
+ }
536
+
537
+ const headers = {
538
+ ...commonHeaders(credential),
539
+ authorization: `Bearer ${credential.accessToken}`,
540
+ }
541
+ if (credential.uid) headers['x-user-id'] = credential.uid
542
+ if (credential.enterpriseId) {
543
+ headers['x-enterprise-id'] = credential.enterpriseId
544
+ headers['x-tenant-id'] = credential.enterpriseId
545
+ }
546
+ if (credential.domain) headers['x-domain'] = credential.domain
547
+
548
+ const response = await fetch(`${billingBaseOf(credential)}/v2/billing/meter/get-user-resource`, {
549
+ method: 'POST',
550
+ headers,
551
+ body: JSON.stringify(body),
552
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
553
+ })
554
+ if (!response.ok) throw new Error(`查询积分失败:HTTP ${response.status}`)
555
+
556
+ const envelope = await response.json()
557
+ if (typeof envelope.code === 'number' && envelope.code !== 0) {
558
+ throw new Error(`查询积分失败:code=${envelope.code} ${envelope.msg ?? ''}`)
559
+ }
560
+
561
+ let total = 0
562
+ let expiringSoon = 0
563
+ let nearestExpiryMs
564
+ const packages = []
565
+
566
+ for (const account of parseBillingAccounts(envelope)) {
567
+ if (account === null || typeof account !== 'object') continue
568
+ const numberOf = (key) => (typeof account[key] === 'number' ? account[key] : 0)
569
+
570
+ // 周期包/一次性包的判定用「数据驱动」的三档判据(对照网关实现的 switch),
571
+ // 不依赖 CapacityType 枚举值——上游对枚举的赋值不受我们控制。
572
+ // 第二档覆盖「周期刚好用完」的包:Size 归 0 但 Remain/Used 还有痕迹,
573
+ // 这时读 Capacity 字段会算出 0,漏算余额。
574
+ const cycleSize = numberOf('CycleCapacitySize')
575
+ const cycleRemain = numberOf('CycleCapacityRemain')
576
+ const cycleUsed = numberOf('CycleCapacityUsed')
577
+ const monthly = cycleSize > 0 || cycleRemain > 0 || cycleUsed > 0
578
+ const size = monthly ? cycleSize : numberOf('CapacitySize')
579
+ const rawRemain = monthly ? cycleRemain : numberOf('CapacityRemain')
580
+ const remain = rawRemain < 0 ? 0 : rawRemain
581
+
582
+ const cycleEndMs = parseDateMs(account.CycleEndTime)
583
+ const expiresAtMs = monthly ? undefined : (parseDateMs(account.ExpiredTime) ?? cycleEndMs)
584
+ const refreshAtMs = monthly && cycleEndMs !== undefined ? cycleEndMs + 1000 : undefined
585
+
586
+ // 一次性礼包:空的或已过期的丢掉,它们不贡献可用额度。
587
+ if (!monthly && (remain <= 0 || (expiresAtMs !== undefined && expiresAtMs <= Date.now()))) {
588
+ continue
589
+ }
590
+
591
+ total += remain
592
+ if (expiresAtMs !== undefined) {
593
+ if (nearestExpiryMs === undefined || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs
594
+ if (expiresAtMs - Date.now() <= EXPIRING_SOON_MS) expiringSoon += remain
595
+ }
596
+ packages.push({
597
+ packageName: typeof account.PackageName === 'string' ? account.PackageName : '(未命名)',
598
+ remain,
599
+ size,
600
+ monthly,
601
+ refreshAtMs,
602
+ expiresAtMs,
603
+ })
604
+ }
605
+
606
+ // 总量与已用一起给出:界面的悬停提示要展示「总量 / 已用 / 剩余」构成。
607
+ // size 求和与 remain 同源(同一批包),已用 = 总量 − 剩余。
608
+ const totalSize = packages.reduce((sum, pkg) => sum + (pkg.size || 0), 0)
609
+ return { total, size: totalSize, packages, expiringSoon, nearestExpiryMs }
610
+ }