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.
package/src/daemon.js CHANGED
@@ -48,13 +48,14 @@ import path from 'node:path';
48
48
  import { fileURLToPath } from 'node:url';
49
49
  import { logger, getLogs, readArchiveJsonl } from './logger.js';
50
50
  import {
51
- loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings,
51
+ loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings, normalizeAccountInput,
52
52
  } from './store.js';
53
53
  import { addAccount, importAccounts, refreshContext } from './accounts.js';
54
54
  import { productImpl } from './providers.js';
55
55
  import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
56
56
  import { refreshWorkbuddyToken } from './workbuddyClient.js';
57
57
  import { loadBuildInfo } from './buildInfo.js';
58
+ import { COOKIE_SITES, getSite, verifySiteCookie, verifyProbeHtml, extractIdentity, extractQuotaSnapshot, identityHashOf, isAutoGeneratedName } from './cookieSites.js';
58
59
  import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
59
60
  import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
60
61
  import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
@@ -165,6 +166,169 @@ function json(res, code, obj) {
165
166
  res.end(body);
166
167
  }
167
168
 
169
+ /**
170
+ * 自动行名守卫:只有「用户没手改过(无 meta.renamed)且现名仍在自动名家族」才允许覆盖。
171
+ * 两道闸缺一不可——renamed 标记护新改名,家族判定护历史改名(旧版改名没有标记)。
172
+ */
173
+ function canAutoRename(site, row) {
174
+ return !row.meta?.renamed && isAutoGeneratedName(site, row.name);
175
+ }
176
+
177
+ /**
178
+ * 单账号就地重探:用已存会话 cookie live 拉「身份+余额」→ 回填 meta → 探活痕迹(lastProbe)
179
+ * 一并落盘。重探路由(POST /api/cookie-sites/:id/reprobe)与 /api/accounts/:id/quota 的
180
+ * 网页会话分流共用——后者原直闯 PRODUCTS 表拿 undefined 崩 502,lastProbe 不更新,
181
+ * 呼吸灯「只有点重探才会变」的根因。
182
+ * @returns {Promise<{id, name, ok, identity?, balance?, today?, long?, error?, quotaSnapshot?}>}
183
+ */
184
+ async function reprobeCookieAccount(site, a) {
185
+ const now = new Date().toISOString();
186
+ const cookie = String(a.meta?.cookieSiteSession?.cookie || a.token || '').trim();
187
+ const finish = async (row) => {
188
+ // 每行都落探活痕迹(GET /api/cookie-sites 回显 lastProbe → 面板呼吸灯三态);
189
+ // 401 类失效红 / 其他失败黄 / 成功绿。await 串行队列排空,防调用方直改文件竞态。
190
+ const status = row.ok ? 'ok' : (/会话失效|401|403|会话不可用|重新登录/i.test(String(row.error || '')) ? 'dead' : 'warn');
191
+ await withAccounts((list) => {
192
+ const hit = list.find((x) => x.id === a.id);
193
+ if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: Boolean(row.ok), status, error: row.ok ? null : String(row.error || '').slice(0, 200), at: now } };
194
+ }).catch(() => {});
195
+ if (!row.ok) {
196
+ // 失败落运行日志(只 toast 一闪的话日志时间线无迹可查)
197
+ logger.warn('COOKIE-SITE', '{name} 就地重探失败({account}):{err}', { name: site.name, account: row.name || a.id, err: row.error || '未知原因' });
198
+ }
199
+ return row;
200
+ };
201
+ if (!cookie) return finish({ id: a.id, name: a.name, ok: false, error: '未存会话 cookie,请重新登录捕获' });
202
+ try {
203
+ if (site.id === 'modelscope') {
204
+ const { probeMagicubeSession } = await import('./modelscopeClient.js');
205
+ const probe = await probeMagicubeSession(cookie);
206
+ if (!probe.ok) return finish({ id: a.id, name: a.name, ok: false, error: probe.error || '探测失败,请稍后再试或重新登录捕获' });
207
+ const ih = identityHashOf(probe.msSession?.id || probe.identity);
208
+ await withAccounts((list) => {
209
+ const hit = list.find((x) => x.id === a.id);
210
+ if (!hit) return;
211
+ hit.meta = {
212
+ ...(hit.meta || {}),
213
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
214
+ ...(ih ? { identityHash: ih } : {}),
215
+ msSession: probe.msSession,
216
+ msBalance: probe.msBalance,
217
+ quotaSnapshot: { used: 0, total: probe.balance, isCreditBalance: true, at: now },
218
+ };
219
+ if (probe.msSession?.id) hit.uid = String(probe.msSession.id);
220
+ if (canAutoRename(site, hit) && probe.identity) hit.name = `ModelScope (${probe.identity})`;
221
+ hit.updatedAt = now;
222
+ });
223
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} 个,身份 {identity})', { name: site.name, balance: probe.balance, identity: probe.identity || '—' });
224
+ return finish({ id: a.id, name: a.name, ok: true, identity: probe.identity, balance: probe.balance, today: probe.msBalance.today, long: probe.msBalance.long, quotaSnapshot: { used: 0, total: probe.balance, isCreditBalance: true, at: now } });
225
+ }
226
+ if (site.id === 'longcat') {
227
+ // LongCat(issue #29):资源包 summary API 即探测(cookie 直连),重探刷新全部 ACTIVE 包快照
228
+ const { fetchLongcatPacks, longcatSnapshot, fetchLongcatIdentity } = await import('./longcatClient.js');
229
+ const packs = await fetchLongcatPacks(cookie);
230
+ if (!packs.ok) {
231
+ return finish({ id: a.id, name: a.name, ok: false, error: packs.authExpired ? (packs.error || '会话失效') : (packs.error || '资源包探测失败') });
232
+ }
233
+ const lcTotal = packs.packs.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
234
+ const ident = await fetchLongcatIdentity(cookie).catch(() => null);
235
+ const lcSession = ident?.ok ? ident.session : null;
236
+ await withAccounts((list) => {
237
+ const hit = list.find((x) => x.id === a.id);
238
+ if (!hit) return;
239
+ hit.meta = {
240
+ ...(hit.meta || {}),
241
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
242
+ longcatPacks: longcatSnapshot(packs.packs),
243
+ ...(lcSession ? { longcatSession: lcSession } : {}),
244
+ quotaSnapshot: { used: 0, total: lcTotal, isCreditBalance: true, at: now },
245
+ };
246
+ if (lcSession?.userId) hit.uid = String(lcSession.userId);
247
+ const nameStr = lcSession?.email || lcSession?.name || lcSession?.userId || null;
248
+ if (canAutoRename(site, hit) && nameStr) hit.name = `LongCat (${nameStr})`;
249
+ hit.updatedAt = now;
250
+ });
251
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: packs.packs.length, identity: lcSession?.email || lcSession?.userId || '—' });
252
+ return finish({ id: a.id, name: a.name, ok: true, identity: lcSession?.email || lcSession?.userId || null, balance: lcTotal, quotaSnapshot: { used: 0, total: lcTotal, isCreditBalance: true, at: now } });
253
+ }
254
+ if (site.id === 'xiaomi-mimo') {
255
+ const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
256
+ const prof = await fetchXiaomiMimoProfile(cookie);
257
+ if (!prof.ok || !prof.userId) return finish({ id: a.id, name: a.name, ok: false, error: prof.error || '资料探测失败' });
258
+ const bal = await fetchXiaomiMimoBalance(cookie, prof.userId);
259
+ const mimoBalance = bal.ok ? { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now } : (a.meta?.mimoBalance || null);
260
+ const accName = `Xiaomi MiMo (UID: ${prof.userId})`;
261
+ await withAccounts((list) => {
262
+ const hit = list.find((x) => x.id === a.id);
263
+ if (!hit) return;
264
+ hit.uid = prof.userId;
265
+ if (canAutoRename(site, hit)) hit.name = accName;
266
+ hit.meta = {
267
+ ...(hit.meta || {}),
268
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
269
+ userId: prof.userId,
270
+ ...(mimoBalance ? { mimoBalance } : {}),
271
+ ...(bal.ok ? { quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now } } : {}),
272
+ };
273
+ hit.updatedAt = now;
274
+ });
275
+ logger.info('COOKIE-SITE', '{name} 就地重探通过(UID {uid},余额 {balance})', { name: site.name, uid: prof.userId, balance: bal.ok ? bal.balance : '—' });
276
+ return finish({ id: a.id, name: a.name, ok: true, identity: prof.userId, balance: bal.ok ? bal.balance : null, quotaSnapshot: bal.ok ? { used: 0, total: bal.balance, isCreditBalance: true, at: now } : null });
277
+ }
278
+ if (site.id === 'river') {
279
+ const { fetchRiverBalance } = await import('./riverClient.js');
280
+ const bal = await fetchRiverBalance(a, {
281
+ log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
282
+ });
283
+ if (!bal.ok) return finish({ id: a.id, name: a.name, ok: false, error: bal.error || '余额探测失败' });
284
+ await withAccounts((list) => {
285
+ const hit = list.find((x) => x.id === a.id);
286
+ if (!hit) return;
287
+ const sess = hit.meta?.workosSession || {};
288
+ if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落盘(单次轮换)
289
+ hit.meta = {
290
+ ...(hit.meta || {}),
291
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
292
+ ...(sess.accessToken || sess.cookie ? { workosSession: sess } : {}),
293
+ riverBalance: { balanceUsd: bal.balance, at: now },
294
+ quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now },
295
+ };
296
+ const ident = sess.user?.email || sess.user?.id || null;
297
+ if (sess.user?.id) hit.uid = String(sess.user.id);
298
+ if (canAutoRename(site, hit) && ident) hit.name = `River AI (${ident})`;
299
+ hit.updatedAt = now;
300
+ });
301
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} USD)', { name: site.name, balance: bal.balance });
302
+ return finish({ id: a.id, name: a.name, ok: true, identity: null, balance: bal.balance, quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now } });
303
+ }
304
+ if (site.verify === 'api' || site.probe) {
305
+ // tokenharbor 等 HTML 探针站点:探测 + 身份 + 读数一并刷新
306
+ const v = await verifySiteCookie(site, cookie);
307
+ if (!v.ok) return finish({ id: a.id, name: a.name, ok: false, error: v.reason === 'invalid' ? '会话已失效,请重新登录捕获' : '探测失败,请稍后再试或重新登录捕获' });
308
+ const identity = extractIdentity(site, v.html) || null;
309
+ const quota = extractQuotaSnapshot(site, v.html) || null;
310
+ const ih = identityHashOf(identity);
311
+ await withAccounts((list) => {
312
+ const hit = list.find((x) => x.id === a.id);
313
+ if (!hit) return;
314
+ hit.meta = {
315
+ ...(hit.meta || {}),
316
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
317
+ ...(ih ? { identityHash: ih } : {}),
318
+ ...(quota ? { quotaSnapshot: { ...quota, at: now } } : {}),
319
+ };
320
+ if (canAutoRename(site, hit)) hit.name = site.accountName + (identity ? `(${identity})` : '');
321
+ hit.updatedAt = now;
322
+ });
323
+ logger.info('COOKIE-SITE', '{name} 就地重探通过(身份 {identity})', { name: site.name, identity: identity || '—' });
324
+ return finish({ id: a.id, name: a.name, ok: true, identity, quotaSnapshot: quota || null });
325
+ }
326
+ return finish({ id: a.id, name: a.name, ok: false, error: '该站点不支持重探,请重新登录捕获' });
327
+ } catch (e) {
328
+ return finish({ id: a.id, name: a.name, ok: false, error: e.message });
329
+ }
330
+ }
331
+
168
332
  async function readBody(req) {
169
333
  const chunks = [];
170
334
  let size = 0;
@@ -224,8 +388,10 @@ function localHosts() {
224
388
  * 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
225
389
  * - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
226
390
  * 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
391
+ * 例外:`/api/quota-refresh` 是 HMAC 自鉴权回调(10R 配额卡按钮浏览器直连,Origin 必然
392
+ * 是 10R 站点)——该路径豁免跨站检查,防伪交给签名 + 限流;Host 闸照常(防 DNS 重绑定)。
227
393
  */
228
- export function rejectForeignRequest(headers, bindHost) {
394
+ export function rejectForeignRequest(headers, bindHost, pathname) {
229
395
  const host = headers.host || '';
230
396
  const hostName = hostnameOf(host).toLowerCase();
231
397
  const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
@@ -236,7 +402,7 @@ export function rejectForeignRequest(headers, bindHost) {
236
402
  return 'Host 不被允许';
237
403
  }
238
404
  const origin = headers.origin;
239
- if (origin) {
405
+ if (origin && pathname !== '/api/quota-refresh') {
240
406
  let originHost = '';
241
407
  try { originHost = new URL(origin).host; } catch {}
242
408
  if (originHost !== host) return '跨站请求被拒绝';
@@ -355,7 +521,8 @@ async function handleApi(req, res, url) {
355
521
  const name = String(body?.name || '').trim().slice(0, 64) || null;
356
522
  const found = await withAccounts((accounts) => {
357
523
  const a = accounts.find((x) => x.id === idMatch[1]);
358
- if (a) a.name = name;
524
+ // meta.renamed = 用户手改的备注名(10R 同名认领键)——捕获/重探不得再自动覆盖
525
+ if (a) { a.name = name; a.meta = { ...(a.meta || {}), renamed: Boolean(name) }; }
359
526
  return a || null;
360
527
  });
361
528
  if (!found) return json(res, 404, { error: '账号不存在' });
@@ -405,6 +572,34 @@ async function handleApi(req, res, url) {
405
572
  const accounts = await loadAccounts();
406
573
  const account = accounts.find((a) => a.id === quotaMatch[1]);
407
574
  if (!account) return json(res, 404, { error: '账号不存在' });
575
+ // 网页会话账号(cookie-site 五家)不在 PRODUCTS 表里——productImpl 会 undefined 崩
576
+ // (502 且 lastProbe 不更新,呼吸灯纹丝不动、「重探后才行」的由来)。改走重探同款
577
+ // 语义:live 拉余额 → 回填快照 → 写探活痕迹 → 推对应卡,响应归一成统一 quota 结构。
578
+ if (account.meta?.cookieSiteId) {
579
+ const site = getSite(account.meta.cookieSiteId);
580
+ if (!site) return json(res, 404, { error: `站点 ${account.meta.cookieSiteId} 未配置` });
581
+ const rep = await reprobeCookieAccount(site, account).catch((e) => ({ ok: false, error: e.message }));
582
+ if (!rep.ok) return json(res, 502, { error: rep.error || '探测失败' });
583
+ const snap = rep.quotaSnapshot || {};
584
+ // 快照形状两样:API 站 total=余额;HTML 探针站(tokenharbor)total 缺失、
585
+ // used=已用百分比——统一结构按各自语义归一(HTML 站 remaining=100-used)。
586
+ const total = Number(snap.total);
587
+ const used = Number(snap.used) || 0;
588
+ const hasTotal = Number.isFinite(total) && total > 0;
589
+ // 单位标注:各家余额单位互异(Token / USD / CNY / 魔粒 / 积分)且都不是「积分」
590
+ // 口径——带 unit 返回,面板 creditSum 按 unit 有无决定是否计入「剩余积分」合计
591
+ //(有单位 = 不同量纲,不相加)。HTML 探针站(tokenharbor)是百分比,同样排除。
592
+ const UNIT_BY_SITE = { 'xiaomi-mimo': 'CNY', 'river': 'USD', 'longcat': 'Token', 'modelscope': '魔粒' };
593
+ const unit = UNIT_BY_SITE[site.id] || (hasTotal ? '' : '%');
594
+ return json(res, 200, { quota: {
595
+ total: hasTotal ? total : 100,
596
+ used,
597
+ remaining: hasTotal ? total : Math.max(0, 100 - used),
598
+ ...(unit ? { unit } : {}),
599
+ exceeded: false,
600
+ parts: [],
601
+ } });
602
+ }
408
603
  try {
409
604
  const log = (m) => logger.info('QUOTA', '{name}:{msg}', { name: account.name || account.id, msg: m });
410
605
  const ctx = { log, ...refreshContext(account, log) };
@@ -459,7 +654,8 @@ async function handleApi(req, res, url) {
459
654
  return json(res, 502, { error: e.message });
460
655
  }
461
656
  }
462
- // ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600)
657
+ // 网络出口偏好(全局——Qoder/ZCode/cookie 探测等全部出口都走它):默认「直连优先、代理兜底」,
658
+ // 可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600;键名 zcode 前缀是历史遗留)
463
659
  // 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
464
660
  if (p === '/api/zcode-gateway' && method === 'GET') {
465
661
  return json(res, 200, await zcodeGateway.gatewayStatus());
@@ -559,7 +755,7 @@ async function handleApi(req, res, url) {
559
755
  }
560
756
  } catch (e) { return json(res, 400, { error: e.message }); }
561
757
  const ac = autoClaimEnabled();
562
- logger.info('DAEMON', 'ZCode 出口:{route},自动领取:{claim},代理 {proxy}', { route: proxyFirst() ? '代理优先' : '直连优先', claim: ac ? '开(每 {min} 分钟,{window})' : '关', min: claimIntervalMin(), window: claimWindowMin() > 0 ? '运行 {windowMin} 分钟' : '一直运行', windowMin: claimWindowMin(), proxy: proxyUrl() || '(环境变量/未设置)' });
758
+ logger.info('DAEMON', '网络出口(全局):{route},ZCode 自动领取:{claim},代理 {proxy}', { route: proxyFirst() ? '代理优先' : '直连优先', claim: ac ? '开(每 {min} 分钟,{window})' : '关', min: claimIntervalMin(), window: claimWindowMin() > 0 ? '运行 {windowMin} 分钟' : '一直运行', windowMin: claimWindowMin(), proxy: proxyUrl() || '(环境变量/未设置)' });
563
759
  return json(res, 200, {
564
760
  proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
565
761
  claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
@@ -750,6 +946,7 @@ async function handleApi(req, res, url) {
750
946
  const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
751
947
  for (const a of hits) {
752
948
  a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
949
+ delete a.meta.qoderWebDead; // 新 cookie 落地即清失效标记(保活监视下一轮重新评估)
753
950
  a.updatedAt = now;
754
951
  }
755
952
  return hits.length;
@@ -769,6 +966,350 @@ async function handleApi(req, res, url) {
769
966
  return json(res, 200, { ok: true, bound });
770
967
  }
771
968
 
969
+ // ── 通用 cookie 站点捕获(issue #23):登录窗口收割 → 探测 → 入账号库(provider=site.provider)
970
+ if (p === '/api/cookie-sites' && method === 'GET') {
971
+ const accounts = await loadAccounts();
972
+ return json(res, 200, {
973
+ sites: COOKIE_SITES.map((site) => {
974
+ const rows = accounts
975
+ .filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id)
976
+ .map((a) => ({
977
+ id: a.id,
978
+ name: a.name || site.accountName,
979
+ capturedAt: a.meta?.cookieSiteSession?.capturedAt || null,
980
+ quota: a.meta?.quotaSnapshot || null,
981
+ mimoBalance: a.meta?.mimoBalance || null,
982
+ riverBalance: a.meta?.riverBalance || null,
983
+ msBalance: a.meta?.msBalance || null,
984
+ longcatPacks: a.meta?.longcatPacks || null,
985
+ lastProbe: a.meta?.lastProbe || null, // 呼吸灯数据源:ok / warn / dead + 失败原因
986
+ }));
987
+ const latest = rows.reduce((t, r) => (r.capturedAt && (!t || r.capturedAt > t) ? r.capturedAt : t), null);
988
+ return {
989
+ id: site.id, name: site.name, label: site.label, loginUrl: site.loginUrl, hint: site.hint,
990
+ accountId: rows[0]?.id || null, capturedAt: latest, accounts: rows,
991
+ };
992
+ }),
993
+ });
994
+ }
995
+
996
+ if (p.startsWith('/api/cookie-sites/') && p.endsWith('/capture') && method === 'POST') {
997
+ const siteId = p.slice('/api/cookie-sites/'.length, -'/capture'.length);
998
+ const site = getSite(siteId);
999
+ if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1000
+ const body = await readBody(req).catch(() => ({}));
1001
+ const cookie = String(body?.cookie || '').trim();
1002
+ // localStorage 凭据线(river = WorkOS SPA 会话):cookie 无关,探测走站点自有 API
1003
+ const storage = (body?.storage && typeof body.storage === 'object' && !Array.isArray(body.storage)) ? body.storage : {};
1004
+ const storageSite = Array.isArray(site.storageKeys) && site.storageKeys.length > 0 ? site : null;
1005
+ const count = body?.diag?.cookieCount;
1006
+ logger.info('COOKIE-SITE', '{name} cookie 收割上报({count} 条)', { name: site.name, count: typeof count === 'number' ? count : '-' });
1007
+ if (storageSite) {
1008
+ if (cookie.length > 32768) return json(res, 400, { error: 'cookie 体积异常,请重新登录后再捕获' });
1009
+ } else if (!cookie || cookie.length > 32768) {
1010
+ return json(res, 400, { error: 'cookie 不能为空,请重新登录后再捕获' });
1011
+ }
1012
+
1013
+ // 探测策略:桌面壳隐藏 Chromium 窗口探针产的 probeHtml 优先(真浏览器视角,过风控);
1014
+ // 无 probeHtml(CLI / 非桌面)回退 Node fetch 探测(fetchJsonRace,跟全局代理)。
1015
+ // storage 站点(river)与 verify:'api' 站点(modelscope)跳过 HTML 探针——余额 API 调用本身就是探测。
1016
+ const skipGenericProbe = Boolean(storageSite) || site.verify === 'api';
1017
+ const probeHtml = skipGenericProbe ? null : (typeof body?.probeHtml === 'string' && body.probeHtml.length > 0 && body.probeHtml.length < 4 * 1024 * 1024 ? body.probeHtml : null);
1018
+ const probe = skipGenericProbe ? { ok: true } : (probeHtml ? verifyProbeHtml(site, probeHtml) : await verifySiteCookie(site, cookie));
1019
+ if (probeHtml && !probe.ok) {
1020
+ // 临时调试:浏览器探针页首 120 字进日志,便于区分「风控挑战页 / 登录页壳 / 结构变化」三元
1021
+ const dbg = JSON.stringify(String(body.probeHtml).replace(/\s+/g, ' ').slice(0, 120));
1022
+ logger.warn('COOKIE-SITE', '{name} 浏览器探针未中(reason={reason}) head={head}', { name: site.name, reason: probe.reason, head: dbg });
1023
+ }
1024
+ if (!probe.ok) {
1025
+ const reason = probe.reason === 'invalid' ? '探测 401/403(会话无效)'
1026
+ : probe.reason === 'no-match' ? '探测页面缺少配额标记(登录未完成或已登出)'
1027
+ : `探测失败(${probe.reason}${probe.status ? ' HTTP ' + probe.status : ''})`;
1028
+ logger.warn('COOKIE-SITE', '{name} cookie 捕获被拒:{reason}', { name: site.name, reason });
1029
+ return json(res, probe.reason === 'invalid' ? 401 : probe.reason === 'no-match' ? 422 : 502, { error: probe.reason === 'invalid' ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)' : probe.reason === 'no-match' ? 'dashboard 页未找到配额标记——页面结构可能有更新,请稍后再试或反馈给我们' : '探测站点失败:' + (probe.error || reason) });
1030
+ }
1031
+
1032
+ const now = new Date().toISOString();
1033
+ // 身份探测(免费账号要多账号轮换):从浏览器探针 HTML 里抽一次身份;
1034
+ // 抽不到 → 无身份单例(与初版一致),不静默覆盖有身份的行。
1035
+ let identity = probeHtml ? extractIdentity(site, probeHtml) : null;
1036
+ // issue #24:浏览器探针 HTML 顺手解析额度读数(推送时不再打网络;解析不出保持旧快照)
1037
+ let quotaSnapshot = probeHtml ? extractQuotaSnapshot(site, probeHtml) : null;
1038
+ let mimoUserId = null;
1039
+ let mimoBalance = null;
1040
+
1041
+ // issue #26:小米 MiMo 控制台资料与余额抓取
1042
+ if (site.id === 'xiaomi-mimo') {
1043
+ try {
1044
+ const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
1045
+ const prof = await fetchXiaomiMimoProfile(cookie);
1046
+ if (prof.ok && prof.userId) {
1047
+ mimoUserId = prof.userId;
1048
+ identity = prof.userName ? `${prof.userName} (${prof.userId})` : prof.userId;
1049
+ const bal = await fetchXiaomiMimoBalance(cookie, mimoUserId);
1050
+ if (bal.ok) {
1051
+ mimoBalance = { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now };
1052
+ quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true, at: now };
1053
+ }
1054
+ }
1055
+ } catch (mimoErr) {
1056
+ logger.warn('COOKIE-SITE', '小米 MiMo 控制台资料/余额抓取异常:{err}', { err: mimoErr.message });
1057
+ }
1058
+ }
1059
+
1060
+ // river(WorkOS 会话线):cookie 直连 → Bearer → 刷新兑换,探测/身份/额度一步完成。
1061
+ // 实测(2026-10-14):SPA token 只在内存,localStorage 通常空——cookie 是主通道。
1062
+ let riverSession = null;
1063
+ let riverBalance = null;
1064
+ if (site.id === 'river') {
1065
+ try {
1066
+ const { parseRiverSession, fetchRiverBalance } = await import('./riverClient.js');
1067
+ const sess = parseRiverSession(storage);
1068
+ sess.cookie = cookie; // 多源合并 cookie 串(auth.console.river.ai 的会话凭据在内)
1069
+ const storageCount = Object.values(storage || {}).filter((v) => typeof v === 'string' && v.trim()).length;
1070
+ const cookieNames = Array.isArray(body?.diag?.cookieNames) ? body.diag.cookieNames.slice(0, 40).join(',') : '';
1071
+ logger.info('COOKIE-SITE', '{name} 凭据盘点:storage {sc} 项 / cookie {cc} 条({names})', { name: site.name, sc: storageCount, cc: String(cookie || '').split(';').filter((x) => x.trim()).length, names: cookieNames || '—' });
1072
+ if (!sess.accessToken && !cookie) {
1073
+ logger.warn('COOKIE-SITE', '{name} 未读到任何会话凭据(storage token 与 cookie 均空)', { name: site.name });
1074
+ return json(res, 400, { error: '未读到 River 登录会话(WorkOS token / 会话 cookie 均无),请在登录窗完成登录后再关闭' });
1075
+ }
1076
+ const bal = await fetchRiverBalance({ meta: { workosSession: sess } }, {
1077
+ log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
1078
+ });
1079
+ if (!bal.ok) {
1080
+ logger.warn('COOKIE-SITE', '{name} 余额探测被拒:{reason}', { name: site.name, reason: bal.error });
1081
+ return json(res, (bal.status === 401 || bal.status === 403) ? 401 : 502, {
1082
+ error: (bal.status === 401 || bal.status === 403)
1083
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1084
+ : '探测站点失败:' + (bal.error || ''),
1085
+ });
1086
+ }
1087
+ if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落库(单次轮换)
1088
+ riverSession = sess;
1089
+ riverBalance = { balanceUsd: bal.balance, at: now };
1090
+ identity = sess.user?.email || sess.user?.id || null;
1091
+ quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true };
1092
+ logger.info('COOKIE-SITE', '{name} 余额探测通过(通道 {channel},{balance} USD)', { name: site.name, channel: bal.channel, balance: bal.balance });
1093
+ } catch (riverErr) {
1094
+ logger.warn('COOKIE-SITE', '{name} 余额抓取异常:{err}', { name: site.name, err: riverErr.message });
1095
+ return json(res, 502, { error: '探测站点失败:' + (riverErr.message || '') });
1096
+ }
1097
+ }
1098
+
1099
+ // modelsscope(CD#25):魔粒余额 API 即探测;每日额度自动发放(无签到动作端点),只读余额
1100
+ let msSession = null;
1101
+ let msBalance = null;
1102
+ if (site.id === 'modelscope') {
1103
+ try {
1104
+ const { probeMagicubeSession } = await import('./modelscopeClient.js');
1105
+ if (!cookie) {
1106
+ logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1107
+ return json(res, 400, { error: '未读到 ModelScope 登录会话,请在登录窗完成登录后再关闭' });
1108
+ }
1109
+ const probe = await probeMagicubeSession(cookie);
1110
+ if (!probe.ok) {
1111
+ logger.warn('COOKIE-SITE', '{name} 魔粒余额探测被拒:{reason}', { name: site.name, reason: probe.error });
1112
+ return json(res, (probe.status === 401 || probe.status === 403) ? 401 : 502, {
1113
+ error: (probe.status === 401 || probe.status === 403)
1114
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1115
+ : '探测站点失败:' + (probe.error || ''),
1116
+ });
1117
+ }
1118
+ msSession = probe.msSession;
1119
+ msBalance = probe.msBalance;
1120
+ identity = probe.identity;
1121
+ quotaSnapshot = { used: 0, total: probe.balance, isCreditBalance: true };
1122
+ logger.info('COOKIE-SITE', '{name} 魔粒余额探测通过({balance} 个,今日 {today} / 长期 {long})', { name: site.name, balance: probe.balance, today: probe.msBalance.today ?? '—', long: probe.msBalance.long ?? '—' });
1123
+ if (!identity) logger.warn('COOKIE-SITE', '{name} 身份未解析(login/info 响应字段:{keys})', { name: site.name, keys: probe.keys || '—' });
1124
+ } catch (msErr) {
1125
+ logger.warn('COOKIE-SITE', '{name} 魔粒余额抓取异常:{err}', { name: site.name, err: msErr.message });
1126
+ return json(res, 502, { error: '探测站点失败:' + (msErr.message || '') });
1127
+ }
1128
+ }
1129
+
1130
+ // LongCat(issue #29):Token 资源包 summary API 即探测(美团 SSO cookie 直连,
1131
+ // 未登录 401「用户未登录」)。纯只读——包为购买/赠送发放,无签到动作端点。
1132
+ let lcPacks = null;
1133
+ let lcSession = null;
1134
+ let longcatSnapshot = null;
1135
+ if (site.id === 'longcat') {
1136
+ try {
1137
+ const lcMod = await import('./longcatClient.js');
1138
+ const { fetchLongcatPacks, fetchLongcatIdentity } = lcMod;
1139
+ longcatSnapshot = lcMod.longcatSnapshot;
1140
+ if (!cookie) {
1141
+ logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1142
+ return json(res, 400, { error: '未读到 LongCat 登录会话,请在登录窗完成登录后再关闭' });
1143
+ }
1144
+ const packs = await fetchLongcatPacks(cookie);
1145
+ if (!packs.ok) {
1146
+ logger.warn('COOKIE-SITE', '{name} 资源包探测被拒:{reason}', { name: site.name, reason: packs.error });
1147
+ return json(res, packs.authExpired ? 401 : 502, {
1148
+ error: packs.authExpired
1149
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1150
+ : '探测站点失败:' + (packs.error || ''),
1151
+ });
1152
+ }
1153
+ lcPacks = packs.packs;
1154
+ const lcTotal = lcPacks.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
1155
+ quotaSnapshot = { used: 0, total: lcTotal, isCreditBalance: true };
1156
+ // 身份 fail-soft:GET /api/v1/user-current 解析不出走无身份单例,不阻断捕获
1157
+ const ident = await fetchLongcatIdentity(cookie).catch(() => null);
1158
+ if (ident?.ok) {
1159
+ lcSession = ident.session;
1160
+ identity = ident.session?.email || ident.session?.name || ident.session?.userId || null;
1161
+ }
1162
+ logger.info('COOKIE-SITE', '{name} 资源包探测通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: lcPacks.length, identity: identity || '—' });
1163
+ } catch (lcErr) {
1164
+ logger.warn('COOKIE-SITE', '{name} 资源包抓取异常:{err}', { name: site.name, err: lcErr.message });
1165
+ return json(res, 502, { error: '探测站点失败:' + (lcErr.message || '') });
1166
+ }
1167
+ }
1168
+
1169
+ const ih = identityHashOf(mimoUserId || riverSession?.user?.id || msSession?.id || lcSession?.userId || identity);
1170
+ const quotaMeta = quotaSnapshot ? { quotaSnapshot: { ...quotaSnapshot, at: now } } : {};
1171
+ const cred = riverSession ? riverSession.accessToken : cookie; // 落盘的凭据主体
1172
+ const riverUid = riverSession?.user?.id || null;
1173
+ let accountId = null;
1174
+ await withAccounts((list) => {
1175
+ const hit = list.find((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1176
+ && (ih ? a.meta?.identityHash === ih : !a.meta?.identityHash));
1177
+ const accName = mimoUserId
1178
+ ? `Xiaomi MiMo (UID: ${mimoUserId})`
1179
+ : (site.id === 'river'
1180
+ ? `River AI (${identity || '会话'})`
1181
+ : (site.id === 'modelscope'
1182
+ ? `ModelScope (${identity || '会话'})`
1183
+ : (site.id === 'longcat'
1184
+ ? `LongCat (${identity || '会话'})`
1185
+ : (site.accountName + (identity ? `(${identity})` : '')))));
1186
+ // 自动行名只在「没手改过且现名属自动名家族」时写入(meta.renamed + isAutoGeneratedName 双闸)
1187
+ const autoName = (row) => { if (canAutoRename(site, row)) row.name = accName; };
1188
+ if (hit) {
1189
+ hit.token = cred;
1190
+ if (mimoUserId) { hit.uid = mimoUserId; autoName(hit); }
1191
+ if (riverUid) { hit.uid = riverUid; autoName(hit); }
1192
+ if (msSession && (msSession.id || msSession.email || msSession.name)) {
1193
+ if (msSession.id) hit.uid = String(msSession.id);
1194
+ autoName(hit);
1195
+ }
1196
+ hit.meta = {
1197
+ ...(hit.meta || {}),
1198
+ cookieSiteSession: { cookie, capturedAt: now },
1199
+ ...(ih ? { identityHash: ih } : {}),
1200
+ ...quotaMeta,
1201
+ ...(mimoUserId ? { userId: mimoUserId } : {}),
1202
+ ...(mimoBalance ? { mimoBalance } : {}),
1203
+ ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1204
+ ...(riverBalance ? { riverBalance } : {}),
1205
+ ...(msSession ? { msSession, msBalance } : {}),
1206
+ ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1207
+ ...(lcSession ? { longcatSession: lcSession } : {}),
1208
+ };
1209
+ if (lcSession?.userId) { hit.uid = String(lcSession.userId); autoName(hit); }
1210
+ hit.updatedAt = now;
1211
+ accountId = hit.id;
1212
+ } else {
1213
+ const acc = normalizeAccountInput({
1214
+ name: accName,
1215
+ provider: site.provider,
1216
+ token: cred,
1217
+ uid: mimoUserId || riverUid || (msSession?.id ? String(msSession.id) : undefined) || (lcSession?.userId ? String(lcSession.userId) : undefined) || undefined,
1218
+ source: 'web-session',
1219
+ meta: {
1220
+ cookieSiteId: site.id,
1221
+ cookieSiteSession: { cookie, capturedAt: now },
1222
+ ...(ih ? { identityHash: ih } : {}),
1223
+ ...quotaMeta,
1224
+ ...(mimoUserId ? { userId: mimoUserId } : {}),
1225
+ ...(mimoBalance ? { mimoBalance } : {}),
1226
+ ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1227
+ ...(riverBalance ? { riverBalance } : {}),
1228
+ ...(msSession ? { msSession, msBalance } : {}),
1229
+ ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1230
+ ...(lcSession ? { longcatSession: lcSession } : {}),
1231
+ },
1232
+ });
1233
+ acc.verified = true;
1234
+ list.push(acc);
1235
+ accountId = acc.id;
1236
+ }
1237
+ });
1238
+ if (ih) logger.info('COOKIE-SITE', '{name} 身份归户({identity},账号轮换键生效)', { name: site.name, identity });
1239
+ logger.info('COOKIE-SITE', '{name} cookie 捕获成功(探测通过,同步到 10Router 后卡片点亮)', { name: site.name });
1240
+ // 捕获成功后只推当前站点的额度卡(单号精准推送,绝不全量刷屏)
1241
+ (async () => {
1242
+ try {
1243
+ if (site.id === 'tokenharbor') {
1244
+ await tenrouter.pushQuotaCards({ onlySources: ['tokenharbor'] });
1245
+ } else if (accountId) {
1246
+ const accs = await loadAccounts();
1247
+ const target = accs.find((x) => x.id === accountId);
1248
+ if (target) {
1249
+ let src = null;
1250
+ if (site.id === 'xiaomi-mimo') {
1251
+ const { buildXiaomiMimoQuotaPayload } = await import('./xiaomiMimoClient.js');
1252
+ src = buildXiaomiMimoQuotaPayload(target).source;
1253
+ } else if (site.id === 'river') {
1254
+ const { buildRiverQuotaPayload } = await import('./riverClient.js');
1255
+ src = buildRiverQuotaPayload(target).source;
1256
+ } else if (site.id === 'modelscope') {
1257
+ const { buildModelScopeQuotaPayload } = await import('./modelscopeClient.js');
1258
+ src = buildModelScopeQuotaPayload(target).source;
1259
+ } else if (site.id === 'longcat') {
1260
+ const { buildLongcatQuotaPayload } = await import('./longcatClient.js');
1261
+ src = buildLongcatQuotaPayload(target).source;
1262
+ }
1263
+ if (src) await tenrouter.pushQuotaCards({ onlySources: [src] });
1264
+ }
1265
+ }
1266
+ } catch (pushErr) {
1267
+ logger.warn('COOKIE-SITE', '{name} 捕获后单路推卡失败:{err}', { name: site.name, err: pushErr.message });
1268
+ }
1269
+ })().catch(() => {});
1270
+ return json(res, 200, { ok: true, accountId });
1271
+ }
1272
+
1273
+ // ── 就地重探:用已存会话 cookie 重跑「身份+余额+流水」并回填行(免登录窗重捕)──
1274
+ if (p.startsWith('/api/cookie-sites/') && p.endsWith('/reprobe') && method === 'POST') {
1275
+ const siteId = p.slice('/api/cookie-sites/'.length, -'/reprobe'.length);
1276
+ const site = getSite(siteId);
1277
+ if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1278
+ const body = await readBody(req).catch(() => ({}));
1279
+ const onlyId = body?.accountId ? String(body.accountId) : null;
1280
+ const accounts = await loadAccounts();
1281
+ const targets = accounts.filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1282
+ && (!onlyId || a.id === onlyId));
1283
+ if (!targets.length) return json(res, 404, { error: '没有可重探的会话(先登录捕获一次)' });
1284
+ // 重探只推涉及的那几路 source(其余卡不打上游、不重推):
1285
+ // tokenharbor 是单例卡(source 固定);mimo/river/ms 按账号算 source;失败行也进名单
1286
+ // ——失败说明会话可能已死,恰好要把 message 状态卡推上去让 10R 卡面可见。
1287
+ const touchedSources = new Set();
1288
+ const sourceOf = async (a) => {
1289
+ if (site.id === 'tokenharbor') return 'tokenharbor';
1290
+ try {
1291
+ const m = await import(
1292
+ site.id === 'xiaomi-mimo' ? './xiaomiMimoClient.js'
1293
+ : site.id === 'river' ? './riverClient.js'
1294
+ : site.id === 'longcat' ? './longcatClient.js' : './modelscopeClient.js');
1295
+ const build = site.id === 'xiaomi-mimo' ? m.buildXiaomiMimoQuotaPayload
1296
+ : site.id === 'river' ? m.buildRiverQuotaPayload
1297
+ : site.id === 'longcat' ? m.buildLongcatQuotaPayload : m.buildModelScopeQuotaPayload;
1298
+ return build(a).source;
1299
+ } catch { return null; }
1300
+ };
1301
+ const results = [];
1302
+ for (const a of targets) {
1303
+ const touched = await sourceOf(a);
1304
+ if (touched) touchedSources.add(touched);
1305
+ const row = await reprobeCookieAccount(site, a);
1306
+ results.push(row);
1307
+ }
1308
+ // 重探后只同步涉及的额度卡(label/数字都可能变;失败行也已被纳入 touchedSources)
1309
+ if (touchedSources.size) tenrouter.pushQuotaCards({ onlySources: [...touchedSources] }).catch(() => {});
1310
+ return json(res, 200, { results });
1311
+ }
1312
+
772
1313
  // ── 本机检测 / 凭据扫描 ──
773
1314
  if (p === '/api/local/detect' && method === 'GET') {
774
1315
  return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
@@ -881,7 +1422,15 @@ async function handleApi(req, res, url) {
881
1422
  if (p === '/api/tenrouter' && method === 'PUT') {
882
1423
  const body = await readBody(req).catch(() => ({}));
883
1424
  try {
884
- await tenrouter.updateConfig({ endpoint: body?.endpoint, key: body?.key, adminPassword: body?.adminPassword, syncEnabled: body?.syncEnabled, sources: body?.sources });
1425
+ await tenrouter.updateConfig({
1426
+ endpoint: body?.endpoint,
1427
+ key: body?.key,
1428
+ adminPassword: body?.adminPassword,
1429
+ syncEnabled: body?.syncEnabled,
1430
+ sources: body?.sources,
1431
+ quotaPushEnabled: body?.quotaPushEnabled,
1432
+ quotaPushIntervalMin: body?.quotaPushIntervalMin,
1433
+ });
885
1434
  return json(res, 200, await tenrouter.publicConfig());
886
1435
  } catch (e) { return json(res, 400, { error: e.message }); }
887
1436
  }
@@ -901,7 +1450,12 @@ async function handleApi(req, res, url) {
901
1450
  }
902
1451
  if (p === '/api/tenrouter/quotas' && method === 'GET') {
903
1452
  try {
904
- return json(res, 200, await tenrouter.fetchQuotas({ force: url.searchParams.get('force') === '1' }));
1453
+ const force = url.searchParams.get('force') === '1';
1454
+ // 用户主动刷新额度时(force=1):先主动向 10Router 推一轮最新免费额度卡,再拉取全量额度
1455
+ if (force) {
1456
+ await tenrouter.pushQuotaCards().catch(() => {});
1457
+ }
1458
+ return json(res, 200, await tenrouter.fetchQuotas({ force }));
905
1459
  } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
906
1460
  }
907
1461
  if (p === '/api/tenrouter/health' && method === 'GET') {
@@ -924,6 +1478,12 @@ async function handleApi(req, res, url) {
924
1478
  return json(res, 200, await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: await loadAccounts() }));
925
1479
  } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
926
1480
  }
1481
+ // 额度卡推送(issue #24):手动触发一轮 external-quota 推送(面板「免费额度」页同步按钮附带)
1482
+ if (p === '/api/tenrouter/push-quota' && method === 'POST') {
1483
+ try {
1484
+ return json(res, 200, await tenrouter.pushQuotaCards());
1485
+ } catch (e) { return json(res, 500, { error: e.message, code: e.code }); }
1486
+ }
927
1487
 
928
1488
  // Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
929
1489
  if (p === '/api/qoder/umid' && method === 'GET') {
@@ -1148,7 +1708,7 @@ export function verifyPanelPassword(input) {
1148
1708
  return false;
1149
1709
  }
1150
1710
 
1151
- /** 出口代理地址(面板「ZCode 网络出口」里存的,环境变量不算——登录窗显式设置用) */
1711
+ /** 出口代理地址(面板「网络出口(全局)」里存的,环境变量不算——登录窗显式设置用) */
1152
1712
  export function getLoginProxyUrl() { return proxyUrl() || null; }
1153
1713
 
1154
1714
  export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
@@ -1183,7 +1743,7 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
1183
1743
  const server = http.createServer(async (req, res) => {
1184
1744
  const url = new URL(req.url, 'http://localhost');
1185
1745
  try {
1186
- const denied = rejectForeignRequest(req.headers, host);
1746
+ const denied = rejectForeignRequest(req.headers, host, url.pathname);
1187
1747
  if (denied) {
1188
1748
  logger.warn('DAEMON', '拒绝请求 {method} {path}:{denied}(Host={host} Origin={origin})', { method: req.method, path: url.pathname, denied, host: req.headers.host || '', origin: req.headers.origin || '' });
1189
1749
  return json(res, 403, { error: denied });
@@ -1208,6 +1768,38 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
1208
1768
  if (url.pathname === '/gateway/trae/v1/messages') {
1209
1769
  return await traeGateway.handleGateway(req, res);
1210
1770
  }
1771
+ // 10Router 外配额卡「刷新配额」回调(自鉴权:HMAC 签名,不走面板密码门;浏览器直连需 CORS)
1772
+ if (url.pathname === '/api/quota-refresh') {
1773
+ res.setHeader('Access-Control-Allow-Origin', '*');
1774
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
1775
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
1776
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
1777
+ if (req.method !== 'POST') return json(res, 405, { error: '仅支持 POST' });
1778
+ const remote = remoteKeyAddr(req);
1779
+ const { verifyQuotaRefresh, logRefresh, refreshRateLimited } = await import('./quotaRefresh.js');
1780
+ if (refreshRateLimited(remote)) {
1781
+ logger.warn('10R', '外配额刷新回调限流 {remote}', { remote });
1782
+ return json(res, 429, { error: '刷新过于频繁,请稍后再试' });
1783
+ }
1784
+ const v = await verifyQuotaRefresh({
1785
+ source: url.searchParams.get('source'),
1786
+ exp: url.searchParams.get('exp'),
1787
+ sig: url.searchParams.get('sig'),
1788
+ });
1789
+ if (!v.ok) {
1790
+ logger.warn('10R', '外配额刷新回调签名无效({reason},source={source})', { reason: v.reason || '?', source: url.searchParams.get('source') || '—' });
1791
+ return json(res, 401, { error: '签名校验失败' });
1792
+ }
1793
+ const source = String(url.searchParams.get('source'));
1794
+ logRefresh(source);
1795
+ // 只推这一路 source:现拉余额 → 推卡(载荷重新签发新 refreshUrl,签名滚动)→ 返回结果
1796
+ const { runQuotaPush } = await import('./externalQuota.js');
1797
+ const cfg = tenrouter.loadConfig();
1798
+ if (!cfg.endpoint || !cfg.key) return json(res, 503, { error: '未配置 10Router 地址与虚拟 key' });
1799
+ const accounts = await loadAccounts();
1800
+ const r = await runQuotaPush({ endpoint: cfg.endpoint, key: cfg.key, accounts, fetchImpl: undefined, onlySource: source, noProbeMark: true });
1801
+ return json(res, 200, { ok: true, results: r.results });
1802
+ }
1211
1803
  if (url.pathname.startsWith('/api/')) {
1212
1804
  return await handleApi(req, res, url);
1213
1805
  }