creditdaddy 1.4.1 → 1.5.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,397 @@
1
+ /**
2
+ * 10Router 通用额度推送接线(issue #24)——把 CD 解析好的额度数字 POST 到
3
+ * `POST /api/external-quota/<source>`,10Router 配额页直接出卡。
4
+ *
5
+ * 信任模型与 10Router 侧一致:**载荷只带额度标量,永远不带凭据**。
6
+ * cookie/token 的解析与保管都留在 CD 本机(10Router 的 externalQuota 白名单
7
+ * 也会整体拒绝嵌套结构),所以这里每个 builder 出口前都过一遍 assertScalarOnly。
8
+ *
9
+ * 首批 source(issue #24「建议的首批接线」):
10
+ * - `tokenharbor` :TH 免费额度网页读数(CD 已捕获的网页会话 → dashboard 解析);
11
+ * - `minimax-code` :MiniMax Code 免费线算力币/签到额度——10Router 的 minimax-free
12
+ * 路由卡走 CD 本地网关,配额只有 CD 查得到,推数字正好补它的盲区。
13
+ *
14
+ * 降级语义(issue #24):会话失效 / 全部解析失败时改推 `message` 文字状态卡,
15
+ * 卡片不消失但显示「需要重新登录」,比静默掉卡更可运维。
16
+ */
17
+
18
+ import { logger } from './logger.js';
19
+ import { t } from './i18n.js';
20
+
21
+ const PUSH_TIMEOUT_MS = 15_000;
22
+ const MAX_ROWS = 20; // 与 10Router EXTERNAL_QUOTA_MAX_ENTRIES 对齐
23
+
24
+ /** 载荷出口前的最后一道闸:行字段值只允许标量(10Router 对嵌套结构整体 400,这里提前失败并留日志) */
25
+ function assertScalarOnly(payload, source) {
26
+ const isScalar = (v) => v === undefined || v === null || (typeof v !== 'object' && typeof v !== 'function');
27
+ const bad = (pathStr) => { throw new Error(`external-quota ${source}: 非标量字段 ${pathStr}(10Router 会整体拒绝,凭据材料禁止入载荷)`); };
28
+ for (const f of ['provider', 'label', 'plan', 'message', 'refreshUrl']) if (!isScalar(payload[f])) bad(f);
29
+ for (const [k, row] of Object.entries(payload.quotas || {})) {
30
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) bad(`quotas.${k}`);
31
+ for (const [f, fv] of Object.entries(row)) if (!isScalar(fv)) bad(`quotas.${k}.${f}`);
32
+ }
33
+ }
34
+
35
+ /** 行 key 只允许 ASCII(10Router QUOTA_KEY_RE),账号名可能是中文——key 用序号,显示名走 name 字段 */
36
+ const rowKey = (i) => `acc${i + 1}`;
37
+
38
+ /**
39
+ * 快照陈旧度标注(自愈闭环③):builder 从快照回退(非 live)且快照距今 > 24h 时,
40
+ * 副标题/行名带上数据时刻——避免把三个月前的陈粮当好粮挂卡面(auth 失效会拦死会话,
41
+ * 但「一直没探测」的行不拦,标注是最后的可见性兜底)。
42
+ * @returns {string} '' 或 '(数据 M-DD HH:mm)'
43
+ */
44
+ export function snapshotAgeTag(snap) {
45
+ const at = Date.parse(snap?.at || snap?.capturedAt || '');
46
+ if (!Number.isFinite(at)) return '';
47
+ const age = Date.now() - at;
48
+ if (age < 24 * 3600_000) return '';
49
+ const d = new Date(at);
50
+ const p2 = (n) => String(n).padStart(2, '0');
51
+ return t('(数据 {m}-{d} {hh}:{mm})', { m: d.getMonth() + 1, d: d.getDate(), hh: p2(d.getHours()), mm: p2(d.getMinutes()) });
52
+ }
53
+
54
+ /**
55
+ * 组装 tokenharbor 卡片载荷。
56
+ * 数据源 = 捕获时浏览器探针 HTML 解析出的 `meta.quotaSnapshot`(issue #23 的隐藏窗探针
57
+ * 是浏览器视角,能过风控;daemon 侧 Node fetch 拿空壳页——所以数字在捕获那一刻就解析入库,
58
+ * 推送不再打网络)。快照过旧(超过 7 天,或 resetAt 已过期——TH 刷新后数字作废)按
59
+ * 「读取失败」计数,全行皆旧时降级为文字状态卡提示重新捕获。
60
+ * @param accounts CD 账号库里已捕获的 tokenharbor 网页会话账号
61
+ */
62
+ export function buildTokenHarborPayload(accounts, { now = Date.now() } = {}) {
63
+ const rows = {};
64
+ let failed = 0;
65
+ for (const a of (accounts || []).slice(0, MAX_ROWS)) {
66
+ const snap = a.meta?.quotaSnapshot;
67
+ const snapAt = Date.parse(snap?.at || a.meta?.cookieSiteSession?.capturedAt || '');
68
+ const stale = !snap || !Number.isFinite(Number(snap.used))
69
+ || (Number.isFinite(snapAt) && now - snapAt > 7 * 86_400_000)
70
+ || (snap.resetAt && Date.parse(snap.resetAt) <= now); // 重置点已过,读数作废
71
+ if (!stale) {
72
+ const i = Object.keys(rows).length;
73
+ const remaining = Math.max(0, 100 - Number(snap.used));
74
+ rows[rowKey(i)] = {
75
+ name: (a.name || 'Token Harbor').slice(0, 80),
76
+ used: Number(snap.used),
77
+ total: 100,
78
+ remainingPercentage: remaining,
79
+ percentScale: 100,
80
+ resetAt: typeof snap.resetAt === 'string' ? snap.resetAt.slice(0, 64) : undefined,
81
+ recurring: true,
82
+ displayRemaining: true,
83
+ };
84
+ } else failed++;
85
+ }
86
+ const payload = { provider: 'tokenharbor', label: 'Token Harbor Free' };
87
+ if (Object.keys(rows).length) {
88
+ payload.quotas = rows;
89
+ payload.plan = 'Free Tier';
90
+ if (failed) payload.label = t('Token Harbor Free({n} 个会话读取失败)', { n: failed }).slice(0, 100);
91
+ } else {
92
+ payload.message = t('Token Harbor 网页会话失效或未捕获,请在 CreditDaddy「网页会话」页重新登录捕获');
93
+ }
94
+ assertScalarOnly(payload, 'tokenharbor');
95
+ return payload;
96
+ }
97
+
98
+ /** 下一个「业务日零点」(MiniMax 按 UTC+8 刷新),ISO 字符串 */
99
+ export function nextUtc8Midnight(now = new Date()) {
100
+ const ms = now.getTime() + 8 * 3600_000;
101
+ const dayMs = 86_400_000;
102
+ return new Date((Math.floor(ms / dayMs) + 1) * dayMs - 8 * 3600_000).toISOString();
103
+ }
104
+
105
+ /**
106
+ * 组装 minimax-code 卡片载荷(issue #24 行字段建议):
107
+ * 每账号一行「存量」——`used=0 / total=剩余算力币 / displayRemaining=true`,
108
+ * `resetAt` 给次日零点(免费线每日刷新口径)。全部查询失败时降级为文字状态卡。
109
+ * @param fetchQuota 注入的「account → 归一化额度」查询器(生产 = fetchMiniMaxQuota)
110
+ */
111
+ export async function buildMiniMaxPayload(accounts, fetchQuota, { resetAt } = {}) {
112
+ const rows = {};
113
+ let failed = 0;
114
+ for (const a of accounts.slice(0, MAX_ROWS)) {
115
+ let q = null;
116
+ try { q = await fetchQuota(a); } catch { failed++; continue; }
117
+ const remaining = Number(q?.remaining);
118
+ if (!Number.isFinite(remaining) || q?.error) { failed++; continue; }
119
+ const i = Object.keys(rows).length;
120
+ rows[rowKey(i)] = {
121
+ name: (a.name || a.uid || 'MiniMax').slice(0, 80),
122
+ used: 0,
123
+ total: Math.max(remaining, 0) || 0.000001, // total 必须 >0(10Router positiveNumber);0 余额给最小正数让卡片显示「耗尽」
124
+ displayRemaining: true,
125
+ recurring: true,
126
+ resetAt: resetAt || nextUtc8Midnight(),
127
+ };
128
+ }
129
+ const payload = { provider: 'minimax-code', label: t('MiniMax Code 免费额度') };
130
+ if (Object.keys(rows).length) {
131
+ payload.quotas = rows;
132
+ payload.plan = 'Free';
133
+ if (failed) payload.label = t('MiniMax Code 免费额度({n} 个账号查询失败)', { n: failed }).slice(0, 100);
134
+ } else {
135
+ payload.message = t('MiniMax 账号额度暂不可查(凭据失效需在 CreditDaddy 重新登录,或官方接口异常稍后自动重试)');
136
+ }
137
+ assertScalarOnly(payload, 'minimax-code');
138
+ return payload;
139
+ }
140
+
141
+ /**
142
+ * 推一张卡:POST /api/external-quota/<source>(Bearer 虚拟 key)。
143
+ * 与 tenrouter.call 同样的重定向纪律——虽然载荷不含凭据,key 也不能被 307 重放到别处。
144
+ * refreshUrl 随载荷下发(外配额卡「刷新配额」按钮的回调地址;HMAC 签名无裸令牌)。
145
+ */
146
+ async function pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl }) {
147
+ const doFetch = fetchImpl || fetch;
148
+ const body = refreshUrl ? { ...payload, refreshUrl } : payload;
149
+ assertScalarOnly(body, source);
150
+ const res = await doFetch(`${endpoint.replace(/\/+$/, '').replace(/\/v1$/, '')}/api/external-quota/${source}`, {
151
+ method: 'POST',
152
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
153
+ body: JSON.stringify(body),
154
+ signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
155
+ redirect: 'error',
156
+ });
157
+ const data = await res.json().catch(() => null);
158
+ if (!res.ok) throw new Error(`HTTP ${res.status}${data?.error ? ':' + data.error : ''}`);
159
+ return data || { ok: true };
160
+ }
161
+
162
+ /**
163
+ * 删除一张外部配额卡:DELETE /api/external-quota/<source>
164
+ */
165
+ export async function deleteExternalQuota({ endpoint, key, source, fetchImpl }) {
166
+ const doFetch = fetchImpl || fetch;
167
+ try {
168
+ const res = await doFetch(`${endpoint.replace(/\/+$/, '').replace(/\/v1$/, '')}/api/external-quota/${source}`, {
169
+ method: 'DELETE',
170
+ headers: { Authorization: `Bearer ${key}` },
171
+ signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
172
+ redirect: 'error',
173
+ });
174
+ return res.ok;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 一轮额度推送。整体 fail-soft:
182
+ * 任何一路失败只记日志,不影响主流程。
183
+ * @param {object} opts
184
+ * @param {string|string[]} [opts.onlySource] 只推这些 source(单卡回源传 string,重探传涉及的数组);缺省推全部
185
+ * @param {boolean} [opts.noRefreshUrl] 不附 refreshUrl(回调自调时防自环签发)
186
+ * @param {boolean} [opts.noProbeMark] 跳过探活痕迹回写(回调路径防自环;缺省后台推送都写)
187
+ * @returns {{ results: Array<{source: string, status: string, error?: string}> }}
188
+ */
189
+ export async function runQuotaPush({ endpoint, key, accounts, fetchImpl, onlySource, noRefreshUrl, noProbeMark }) {
190
+ const results = [];
191
+ const only = Array.isArray(onlySource) ? onlySource : (onlySource ? [onlySource] : null);
192
+ const { quotaRefreshUrl } = await import('./quotaRefresh.js');
193
+ const rurl = async (source) => {
194
+ if (noRefreshUrl) return undefined;
195
+ try { return await quotaRefreshUrl(source); } catch { return undefined; }
196
+ };
197
+ // live 拉取失败的两类处置:
198
+ // - auth 失效(401/403,三家 fetch 都标 authExpired)→ liveData 带 authInvalid 交给
199
+ // builder 降级 message 状态卡(10R 卡面立刻可见「会话失效」),**不再回退旧快照**——
200
+ // 死会话挂旧数字连 7 天 TTL 都不会过期(pushedAt 每轮刷新),10R 侧对失效是瞎的;
201
+ // - 网络抖动 / 5xx / 结构变化 → 保留旧快照数字(瞬时故障不该清空卡面)。
202
+ const liveError = (b) => (b?.authExpired ? { error: b.error, authInvalid: true } : { error: b?.error });
203
+ // 探活痕迹回写(自愈闭环①):后台推送每轮 live 拉取都等于一次探测——结果顺手落
204
+ // meta.lastProbe,面板呼吸灯就有真实心跳(不用等用户手点重探才更新)。fire-and-forget
205
+ // 串行队列写入,绝不阻塞推送;回调路径(quota-refresh → onlySource)noProbeMark 跳过。
206
+ // dead 账号低频重试(自愈闭环②):lastProbe.status==='dead' 且距上次探测不足 6 小时
207
+ // → 本轮跳过 live 拉取(少打上游),用快照/状态卡照常推卡;过期则再试一次——
208
+ // 网站恢复/误判自愈时自动复活,用户无感。
209
+ const DEAD_RETRY_MS = 6 * 60 * 60 * 1000;
210
+ const probeMark = (a, ok, error) => {
211
+ if (noProbeMark || !a?.id) return;
212
+ const status = ok ? 'ok' : (/会话失效|401|403|会话不可用|重新登录/i.test(String(error || '')) ? 'dead' : 'warn');
213
+ import('./store.js').then(({ withAccounts }) => withAccounts((list) => {
214
+ const hit = list.find((x) => x.id === a.id);
215
+ if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: Boolean(ok), status, error: ok ? null : String(error || '').slice(0, 200), at: new Date().toISOString() } };
216
+ })).catch(() => {});
217
+ };
218
+ const deadSkip = (a) => {
219
+ const lp = a.meta?.lastProbe;
220
+ if (!lp || lp.ok || lp.status !== 'dead') return false;
221
+ const at = Date.parse(lp.at || '');
222
+ return Number.isFinite(at) && (Date.now() - at) < DEAD_RETRY_MS;
223
+ };
224
+
225
+ // 清理历史遗留的 minimax-code 卡片(MiniMax 账号会话在 CD 自持,避免生成 0 行报错卡)
226
+ if (!only) deleteExternalQuota({ endpoint, key, source: 'minimax-code', fetchImpl }).catch(() => {});
227
+
228
+ // 1. tokenharbor(issue #24:TH 免费额度网页读数)
229
+ const thAccounts = (accounts || []).filter((a) => a.provider === 'tokenharbor' && a.meta?.cookieSiteId);
230
+ if (thAccounts.length && (!only || only.includes('tokenharbor'))) {
231
+ try {
232
+ const payload = buildTokenHarborPayload(thAccounts);
233
+ const r = await pushOne({ endpoint, key, source: 'tokenharbor', payload, fetchImpl, refreshUrl: await rurl('tokenharbor') });
234
+ results.push({ source: 'tokenharbor', status: 'ok', entries: r.entries });
235
+ logger.info('10R', '额度卡已推送:tokenharbor({n} 行)', { n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
236
+ } catch (e) {
237
+ results.push({ source: 'tokenharbor', status: 'failed', error: e.message });
238
+ logger.warn('10R', '额度卡推送失败 tokenharbor:{err}', { err: e.message });
239
+ }
240
+ }
241
+
242
+ // 2. xiaomi-mimo(issue #26:小米 MiMo 控制台充值余额推送)
243
+ const mimoAccounts = (accounts || []).filter((a) => (a.provider === 'xiaomi-mimo' || a.meta?.cookieSiteId === 'xiaomi-mimo') && a.token);
244
+ if (mimoAccounts.length) {
245
+ const { fetchXiaomiMimoBalance, buildXiaomiMimoQuotaPayload } = await import('./xiaomiMimoClient.js');
246
+ for (const a of mimoAccounts) {
247
+ // onlySource 先于 live 拉取判掉:单卡回调不该为别路 source 打上游
248
+ // (mimo source 由账号元数据定,与 liveData 无关——纯函数取一次即可)
249
+ const { source } = buildXiaomiMimoQuotaPayload(a);
250
+ if (only && !only.includes(source)) continue;
251
+ const skipDead = !only && deadSkip(a); // 单卡回调(only)不跳过——用户显式刷新必须真试
252
+ let liveData = null;
253
+ if (skipDead) {
254
+ liveData = { error: '会话已失效(后台低频重试等待中,跳过本轮 live 拉取)', authInvalid: true };
255
+ } else {
256
+ const uid = a.meta?.userId || a.uid || '';
257
+ try {
258
+ const b = await fetchXiaomiMimoBalance(a.token, uid, { fetchImpl });
259
+ if (b.ok) liveData = { balance: b.balance, cash: b.cash, voucher: b.voucher };
260
+ else liveData = liveError(b);
261
+ probeMark(a, b.ok, b.error);
262
+ } catch (err) {
263
+ liveData = { error: err.message };
264
+ probeMark(a, false, err.message);
265
+ }
266
+ }
267
+ const { payload } = buildXiaomiMimoQuotaPayload(a, liveData);
268
+ try {
269
+ assertScalarOnly(payload, source);
270
+ const r = await pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl: await rurl(source) });
271
+ results.push({ source, status: 'ok', entries: r.entries });
272
+ logger.info('10R', '额度卡已推送:{source}({n} 行)', { source, n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
273
+ } catch (e) {
274
+ results.push({ source, status: 'failed', error: e.message });
275
+ logger.warn('10R', '额度卡推送失败 {source}:{err}', { source, err: e.message });
276
+ }
277
+ }
278
+ }
279
+
280
+ // 3. river(River AI 免费额度余额卡)——WorkOS 会话线,live 拉 /api/billing/account;
281
+ // 过期自动刷新(单次轮换由 riverClient 内置落盘兜底),会话失效降级 message 状态卡。
282
+ const riverAccounts = (accounts || []).filter((a) => (a.provider === 'river' || a.meta?.cookieSiteId === 'river') && (a.meta?.workosSession || a.token));
283
+ if (riverAccounts.length) {
284
+ const { fetchRiverBalance, buildRiverQuotaPayload } = await import('./riverClient.js');
285
+ for (const a of riverAccounts) {
286
+ // onlySource 先判:river 的 live 拉取可能触发 WorkOS 单次轮换,别路 source 不该付这个代价
287
+ const { source } = buildRiverQuotaPayload(a);
288
+ if (only && !only.includes(source)) continue;
289
+ const skipDead = !only && deadSkip(a); // 单卡回调不跳过——用户显式刷新必须真试
290
+ let liveData = null;
291
+ if (skipDead) {
292
+ liveData = { error: '会话已失效(后台低频重试等待中,跳过本轮 live 拉取)', authInvalid: true };
293
+ } else {
294
+ try {
295
+ const b = await fetchRiverBalance(a, { fetchImpl });
296
+ if (b.ok) liveData = { balance: b.balance };
297
+ else liveData = liveError(b);
298
+ probeMark(a, b.ok, b.error);
299
+ } catch (err) {
300
+ liveData = { error: err.message };
301
+ probeMark(a, false, err.message);
302
+ }
303
+ }
304
+ const { payload } = buildRiverQuotaPayload(a, liveData);
305
+ try {
306
+ assertScalarOnly(payload, source);
307
+ const r = await pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl: await rurl(source) });
308
+ results.push({ source, status: 'ok', entries: r.entries });
309
+ logger.info('10R', '额度卡已推送:{source}({n} 行)', { source, n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
310
+ } catch (e) {
311
+ results.push({ source, status: 'failed', error: e.message });
312
+ logger.warn('10R', '额度卡推送失败 {source}:{err}', { source, err: e.message });
313
+ }
314
+ }
315
+ }
316
+
317
+ // 4. modelsscope(CD#25:魔方余额卡)——havana 会话 cookie 读 /openapi/v1/magicubes/balance;
318
+ // 每日额度自动发放(无签到动作端点),会话失效降级 message 状态卡。
319
+ const msAccounts = (accounts || []).filter((a) => (a.provider === 'modelscope' || a.meta?.cookieSiteId === 'modelscope') && a.token);
320
+ if (msAccounts.length) {
321
+ const { fetchMagicubeBalance, fetchMagicubeLedger, buildModelScopeQuotaPayload } = await import('./modelscopeClient.js');
322
+ for (const a of msAccounts) {
323
+ // onlySource 先判:别路 source 不打魔搭上游
324
+ const { source } = buildModelScopeQuotaPayload(a);
325
+ if (only && !only.includes(source)) continue;
326
+ const skipDead = !only && deadSkip(a); // 单卡回调不跳过——用户显式刷新必须真试
327
+ let liveData = null;
328
+ if (skipDead) {
329
+ liveData = { error: '会话已失效(后台低频重试等待中,跳过本轮 live 拉取)', authInvalid: true };
330
+ } else {
331
+ try {
332
+ const b = await fetchMagicubeBalance(a.token, { fetchImpl });
333
+ if (b.ok) {
334
+ const ledger = await fetchMagicubeLedger(a.token, b.balance, { fetchImpl }).catch(() => null);
335
+ liveData = { balance: b.balance, ledger };
336
+ probeMark(a, true);
337
+ } else {
338
+ liveData = liveError(b);
339
+ probeMark(a, false, b.error);
340
+ }
341
+ } catch (err) {
342
+ liveData = { error: err.message };
343
+ probeMark(a, false, err.message);
344
+ }
345
+ }
346
+ const { payload } = buildModelScopeQuotaPayload(a, liveData);
347
+ try {
348
+ assertScalarOnly(payload, source);
349
+ const r = await pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl: await rurl(source) });
350
+ results.push({ source, status: 'ok', entries: r.entries });
351
+ logger.info('10R', '额度卡已推送:{source}({n} 行)', { source, n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
352
+ } catch (e) {
353
+ results.push({ source, status: 'failed', error: e.message });
354
+ logger.warn('10R', '额度卡推送失败 {source}:{err}', { source, err: e.message });
355
+ }
356
+ }
357
+ }
358
+
359
+ // 5. LongCat(issue #29:Token 资源包卡)——美团 SSO cookie 读平台 summary;
360
+ // 储值卡 + 逐包明细(消耗顺序官方规则:购买包未动时先烧赠送包),会话失效降级 message 状态卡。
361
+ const lcAccounts = (accounts || []).filter((a) => (a.provider === 'longcat' || a.meta?.cookieSiteId === 'longcat') && a.token);
362
+ if (lcAccounts.length) {
363
+ const { fetchLongcatPacks, buildLongcatQuotaPayload } = await import('./longcatClient.js');
364
+ for (const a of lcAccounts) {
365
+ // onlySource 先判:别路 source 不打 LongCat 上游
366
+ const { source } = buildLongcatQuotaPayload(a);
367
+ if (only && !only.includes(source)) continue;
368
+ const skipDead = !only && deadSkip(a); // 单卡回调不跳过——用户显式刷新必须真试
369
+ let liveData = null;
370
+ if (skipDead) {
371
+ liveData = { error: '会话已失效(后台低频重试等待中,跳过本轮 live 拉取)', authInvalid: true };
372
+ } else {
373
+ try {
374
+ const b = await fetchLongcatPacks(a.token, { fetchImpl });
375
+ if (b.ok) liveData = { packs: b.packs };
376
+ else liveData = liveError(b);
377
+ probeMark(a, b.ok, b.error);
378
+ } catch (err) {
379
+ liveData = { error: err.message };
380
+ probeMark(a, false, err.message);
381
+ }
382
+ }
383
+ const { payload } = buildLongcatQuotaPayload(a, liveData);
384
+ try {
385
+ assertScalarOnly(payload, source);
386
+ const r = await pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl: await rurl(source) });
387
+ results.push({ source, status: 'ok', entries: r.entries });
388
+ logger.info('10R', '额度卡已推送:{source}({n} 行)', { source, n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
389
+ } catch (e) {
390
+ results.push({ source, status: 'failed', error: e.message });
391
+ logger.warn('10R', '额度卡推送失败 {source}:{err}', { source, err: e.message });
392
+ }
393
+ }
394
+ }
395
+
396
+ return { results };
397
+ }