creditdaddy 1.6.3 → 1.6.5

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
@@ -1,2062 +1,2251 @@
1
- /**
2
- * CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
3
- *
4
- * 架构参考 WorkDaddy:
5
- * - 默认只监听 127.0.0.1,数据不出本机
6
- * - 无依赖,Node 18+ 原生 http 模块
7
- * - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
8
- *
9
- * API:
10
- * GET /api/accounts 账号列表(脱敏)
11
- * POST /api/accounts 添加账号 {name, provider, token}
12
- * PATCH /api/accounts/:id 修改备注名 {name}
13
- * DELETE /api/accounts/:id 删除账号
14
- * POST /api/accounts/:id/checkin 手动为单个账号签到
15
- * GET /api/accounts/:id/quota 查询积分(统一结构)
16
- * POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
17
- * GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
18
- * POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
19
- * GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
20
- * POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
21
- * POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai / minimax}
22
- * POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
23
- * GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
24
- * POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
25
- * POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
26
- * GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
27
- * POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
28
- * GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
29
- * GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
30
- * POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
31
- * POST /api/tenrouter/sync-accounts 把本机账号推送到 10Router 连接(OAuth 通道需已配面板密码)
32
- * GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
33
- * POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
34
- * GET /api/logs 最近日志
35
- * GET /api/status 守护进程状态
36
- * GET /api/settings 面板设置(访问密码开关状态,不回显密码)
37
- * PUT /api/settings 设置/修改/关闭面板访问密码 {panelKey? | disable?}
38
- * POST /api/export 导出账号 {password, provider?}(口令必填,10router 兼容加密文件)
39
- * POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
40
- * GET / Web 面板
41
- */
42
-
43
- import http from 'node:http';
44
- import crypto from 'node:crypto';
45
- import os from 'node:os';
46
- import fs from 'node:fs/promises';
47
- import path from 'node:path';
48
- import { execFileSync } from 'node:child_process';
49
- import { fileURLToPath } from 'node:url';
50
- import { logger, getLogs, readArchiveJsonl } from './logger.js';
51
- import {
52
- loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings, normalizeAccountInput,
53
- } from './store.js';
54
- import { addAccount, importAccounts, refreshContext } from './accounts.js';
55
- import { productImpl } from './providers.js';
56
- import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid, detectWorkbuddy } from './workbuddyLocal.js';
57
- import { refreshWorkbuddyToken } from './workbuddyClient.js';
58
- import { loadBuildInfo } from './buildInfo.js';
59
- import { COOKIE_SITES, getSite, verifySiteCookie, verifyProbeHtml, extractIdentity, extractQuotaSnapshot, identityHashOf, isAutoGeneratedName } from './cookieSites.js';
60
- import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
61
- import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
62
- import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
63
- import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
64
- import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
65
- import * as minimaxGateway from './minimaxGateway.js';
66
- import * as traeGateway from './traeGateway.js';
67
- import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
68
- import { exportAccounts, buildExportPayload, parseImport, TransferError } from './transfer.js';
69
- import { syncAccountsTo10r } from './tenrouterAccounts.js';
70
- import { runCheckinTick, getSchedulerInfo, dayKey, enableZcodeAutoClaimWindow, refreshZcodeScheduler, pollZcodeNow, resyncAutoCheckinScheduler } from './checkin.js';
71
- import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource, switchTo as qoderSwitchTo, qoderRunning, terminateQoder } from './qoderApp.js';
72
- import { umidInfo, installUmid } from './qoderUmid.js';
73
- import * as tenrouter from './tenrouter.js';
74
- import * as zcodeGateway from './zcodeGateway.js';
75
- import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
76
- import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
77
- import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
78
- import { getLocale, setLocale, normalizeLocale, loadDict, t as tr } from './i18n.js';
79
-
80
- const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax'];
81
-
82
- /** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
83
- * 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
84
- * 保证安装向导密码在 NAS 部署里始终有效。设置后所有 /api/* 需要 x-qd-key 头;密码不回显。 */
85
- let PANEL_KEY = '';
86
- let PANEL_KEY_HASH = null; // 'scrypt:<salt_hex>:<hash_hex>' — 面板密码只存这个,不存明文
87
- let PANEL_SESSION = null; // 进程级会话钥(重启随机):桌面壳寄主的 loopback 凭证,外部没人知道
88
- let PANEL_KEY_MODE = 'none'; // 'plain' | 'hash' | 'none'
89
-
90
- function scryptPanelKey(peer) {
91
- const salt = crypto.randomBytes(8);
92
- const h = crypto.scryptSync(String(peer), salt, 32);
93
- return `scrypt:${salt.toString('hex')}:${h.toString('hex')}`;
94
- }
95
- function scryptMatches(peer, stored) {
96
- try {
97
- const [algo, saltHex, hashHex] = String(stored).split(':');
98
- if (algo !== 'scrypt' || !saltHex || !hashHex) return false;
99
- const h = crypto.scryptSync(String(peer), Buffer.from(saltHex, 'hex'), 32);
100
- return crypto.timingSafeEqual(h, Buffer.from(hashHex, 'hex'));
101
- } catch { return false; }
102
- }
103
-
104
- /** 面板密码是否生效(哈希 / 明文 / 环境变量任一路径都算)—— PANEL_KEY 字符串本身在哈希模式下为空。 */
105
- export function panelKeyRequired() { return PANEL_KEY_MODE !== 'none'; }
106
-
107
- async function initPanelKey() {
108
- let s = {};
109
- try { s = await loadSettings(); } catch {}
110
- const env = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
111
- // 存储面板密码只存 scrypt 哈希——此前的明文 panelKey 见了顺手升级(写为哈希并清除明文)。
112
- if (typeof s.panelKeyHash === 'string' && s.panelKeyHash.startsWith('scrypt:')) {
113
- PANEL_KEY_HASH = s.panelKeyHash;
114
- PANEL_KEY_MODE = 'hash';
115
- PANEL_KEY = '';
116
- if (s.panelKey) await saveSettings({ panelKey: '' });
117
- } else {
118
- const plain = typeof s.panelKey === 'string' && s.panelKey ? s.panelKey : '';
119
- if (plain) {
120
- PANEL_KEY_HASH = scryptPanelKey(plain);
121
- PANEL_KEY_MODE = 'hash';
122
- PANEL_KEY = '';
123
- await saveSettings({ panelKeyHash: PANEL_KEY_HASH, panelKey: '' });
124
- } else if (env) {
125
- PANEL_KEY = env;
126
- PANEL_KEY_HASH = null;
127
- PANEL_KEY_MODE = 'plain'; // 环境变量来源的密码按现状走(部署侧管理)
128
- } else {
129
- PANEL_KEY = '';
130
- PANEL_KEY_HASH = null;
131
- PANEL_KEY_MODE = 'none';
132
- }
133
- }
134
- PANEL_SESSION = crypto.randomBytes(16).toString('hex'); // 每次拉起重新随机(桌面上次用过期票不会误通行)
135
- setLocale(s.locale);
136
- }
137
-
138
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
139
- const PANEL_FILE = path.join(__dirname, 'panel.html');
140
- const DEFAULT_PORT = 47860;
141
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
142
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
143
-
144
- /**
145
- * Windows 系统保留端口段。WSL2 镜像网络模式会把一段端口圈进 Hyper-V 的
146
- * excludedportrange(实测 47860~47870 落进去),bind 该段直接 EACCES(或 EADDRINUSE),
147
- * 应用根本拿不到绑定权限。查询 netsh 拿到全量保留段供选端口避开。
148
- * 非 Windows / 查询失败返回 [](退化为 +1 重试逃逸,见 bind)。
149
- */
150
- function windowsExcludedTcpRanges() {
151
- if (process.platform !== 'win32') return [];
152
- try {
153
- const out = execFileSync('netsh', ['interface', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'],
154
- { encoding: 'utf8', windowsHide: true, timeout: 3000 });
155
- // 行形如「 47860 47870」;表头/分隔线无两列纯数字,自然跳过
156
- return [...String(out).matchAll(/^\s*(\d+)\s+(\d+)\s*$/gm)].map((m) => [Number(m[1]), Number(m[2])]);
157
- } catch {
158
- return [];
159
- }
160
- }
161
-
162
- /**
163
- * 从 start 起挑第一个不在系统保留段内的端口——确定性选择,避免每次启动漂到不同端口
164
- * (10Router 网关节点 / 书签硬编码了端口,漂了就断)。netsh 不可用时原样返回 start,
165
- * 交给 bind 的 +1 重试逃逸。ranges 可注入(测试用),默认查 netsh。
166
- */
167
- export function pickBindablePort(start, ranges = windowsExcludedTcpRanges()) {
168
- let p = Number(start) || DEFAULT_PORT;
169
- for (let i = 0; i < 20000 && p <= 65535; i++, p++) {
170
- if (!ranges.some(([a, b]) => p >= a && p <= b)) return p;
171
- }
172
- return Number(start) || DEFAULT_PORT;
173
- }
174
-
175
- // Qoder 当前登录缓存:面板 30s 一轮 status,DPAPI 解密结果没必要每轮重算
176
- let qoderCurCache = { at: 0, data: null };
177
- async function qoderCurrentInfo() {
178
- if (qoderCurCache.data && Date.now() - qoderCurCache.at < 60_000) return qoderCurCache.data;
179
- const data = { apps: detectQoderApps(), tokens: {}, uids: {}, backfill: [] };
180
- try {
181
- const r = await readQoderAppAccounts();
182
- for (const c of r.accounts) {
183
- // 当前登录以 token 为准:客户端静默刷新 / 重新登录后 token 会变而 uid 不变,
184
- // 只比 uid 时「当前账号」与切换键状态会错位(用户现场:切完切换键没了)
185
- data.tokens[c.provider] = c.token || null;
186
- data.uids[c.provider] = c.user.id || null;
187
- if (c.user.id) data.backfill.push({ provider: c.provider, uid: c.user.id, authJson: c.authJson, file: c.file });
188
- }
189
- } catch { /* 解密失败只留检测信息 */ }
190
- qoderCurCache = { at: Date.now(), data };
191
- return data;
192
- }
193
-
194
- /**
195
- * 账号库里哪个账号是本机 Qoder 当前登录:先按 token 精确对齐,token 没对上再退回 uid。
196
- * 面板的「客户端当前」角标 / 切换按钮显隐都以这里为准(status.qoderCurrent 的取值)。
197
- */
198
- function qoderCurrentIds(accounts, qoder) {
199
- const ids = {};
200
- for (const provider of ['qoder', 'qoder-cn']) {
201
- const token = qoder.tokens?.[provider];
202
- const uid = qoder.uids?.[provider];
203
- const sameProvider = accounts.filter((a) => a.provider === provider);
204
- const byToken = token ? sameProvider.find((a) => a.token === token) : null;
205
- const hit = byToken || (uid ? sameProvider.find((a) => a.uid && String(a.uid) === String(uid)) : null);
206
- ids[provider] = hit?.id || null;
207
- }
208
- return ids;
209
- }
210
-
211
- class HttpError extends Error {
212
- constructor(status, message) { super(message); this.status = status; }
213
- }
214
-
215
- function json(res, code, obj) {
216
- const body = JSON.stringify(obj);
217
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
218
- res.end(body);
219
- }
220
-
221
- /**
222
- * 自动行名守卫:只有「用户没手改过(无 meta.renamed)且现名仍在自动名家族」才允许覆盖。
223
- * 两道闸缺一不可——renamed 标记护新改名,家族判定护历史改名(旧版改名没有标记)。
224
- */
225
- function canAutoRename(site, row) {
226
- return !row.meta?.renamed && isAutoGeneratedName(site, row.name);
227
- }
228
-
229
- /**
230
- * 单账号就地重探:用已存会话 cookie live 拉「身份+余额」→ 回填 meta → 探活痕迹(lastProbe)
231
- * 一并落盘。重探路由(POST /api/cookie-sites/:id/reprobe)与 /api/accounts/:id/quota 的
232
- * 网页会话分流共用——后者原直闯 PRODUCTS 表拿 undefined 崩 502,lastProbe 不更新,
233
- * 呼吸灯「只有点重探才会变」的根因。
234
- * @returns {Promise<{id, name, ok, identity?, balance?, today?, long?, error?, quotaSnapshot?}>}
235
- */
236
- async function reprobeCookieAccount(site, a) {
237
- const now = new Date().toISOString();
238
- const cookie = String(a.meta?.cookieSiteSession?.cookie || a.token || '').trim();
239
- const finish = async (row) => {
240
- // 每行都落探活痕迹(GET /api/cookie-sites 回显 lastProbe → 面板呼吸灯三态);
241
- // 401 类失效红 / 其他失败黄 / 成功绿。await 串行队列排空,防调用方直改文件竞态。
242
- const status = row.ok ? 'ok' : (/会话失效|401|403|会话不可用|重新登录/i.test(String(row.error || '')) ? 'dead' : 'warn');
243
- await withAccounts((list) => {
244
- const hit = list.find((x) => x.id === a.id);
245
- if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: Boolean(row.ok), status, error: row.ok ? null : String(row.error || '').slice(0, 200), at: now } };
246
- }).catch(() => {});
247
- if (!row.ok) {
248
- // 失败落运行日志(只 toast 一闪的话日志时间线无迹可查)
249
- logger.warn('COOKIE-SITE', '{name} 就地重探失败({account}):{err}', { name: site.name, account: row.name || a.id, err: row.error || '未知原因' });
250
- }
251
- return row;
252
- };
253
- if (!cookie) return finish({ id: a.id, name: a.name, ok: false, error: '未存会话 cookie,请重新登录捕获' });
254
- try {
255
- if (site.id === 'modelscope') {
256
- const { probeMagicubeSession } = await import('./modelscopeClient.js');
257
- const probe = await probeMagicubeSession(cookie);
258
- if (!probe.ok) return finish({ id: a.id, name: a.name, ok: false, error: probe.error || '探测失败,请稍后再试或重新登录捕获' });
259
- const ih = identityHashOf(probe.msSession?.id || probe.identity);
260
- await withAccounts((list) => {
261
- const hit = list.find((x) => x.id === a.id);
262
- if (!hit) return;
263
- hit.meta = {
264
- ...(hit.meta || {}),
265
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
266
- ...(ih ? { identityHash: ih } : {}),
267
- msSession: probe.msSession,
268
- msBalance: probe.msBalance,
269
- quotaSnapshot: { used: 0, total: probe.balance, isCreditBalance: true, at: now },
270
- };
271
- if (probe.msSession?.id) hit.uid = String(probe.msSession.id);
272
- if (canAutoRename(site, hit) && probe.identity) hit.name = `ModelScope (${probe.identity})`;
273
- hit.updatedAt = now;
274
- });
275
- logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} 个,身份 {identity})', { name: site.name, balance: probe.balance, identity: probe.identity || '—' });
276
- 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 } });
277
- }
278
- if (site.id === 'longcat') {
279
- // LongCat(issue #29):资源包 summary API 即探测(cookie 直连),重探刷新全部 ACTIVE 包快照
280
- const { fetchLongcatPacks, longcatSnapshot, fetchLongcatIdentity } = await import('./longcatClient.js');
281
- const packs = await fetchLongcatPacks(cookie);
282
- if (!packs.ok) {
283
- return finish({ id: a.id, name: a.name, ok: false, error: packs.authExpired ? (packs.error || '会话失效') : (packs.error || '资源包探测失败') });
284
- }
285
- const lcTotal = packs.packs.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
286
- const ident = await fetchLongcatIdentity(cookie).catch(() => null);
287
- const lcSession = ident?.ok ? ident.session : null;
288
- await withAccounts((list) => {
289
- const hit = list.find((x) => x.id === a.id);
290
- if (!hit) return;
291
- hit.meta = {
292
- ...(hit.meta || {}),
293
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
294
- longcatPacks: longcatSnapshot(packs.packs),
295
- ...(lcSession ? { longcatSession: lcSession } : {}),
296
- quotaSnapshot: { used: 0, total: lcTotal, isCreditBalance: true, at: now },
297
- };
298
- if (lcSession?.userId) hit.uid = String(lcSession.userId);
299
- const nameStr = lcSession?.email || lcSession?.name || lcSession?.userId || null;
300
- if (canAutoRename(site, hit) && nameStr) hit.name = `LongCat (${nameStr})`;
301
- hit.updatedAt = now;
302
- });
303
- logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: packs.packs.length, identity: lcSession?.email || lcSession?.userId || '—' });
304
- 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 } });
305
- }
306
- if (site.id === 'xiaomi-mimo') {
307
- const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
308
- const prof = await fetchXiaomiMimoProfile(cookie);
309
- if (!prof.ok || !prof.userId) return finish({ id: a.id, name: a.name, ok: false, error: prof.error || '资料探测失败' });
310
- const bal = await fetchXiaomiMimoBalance(cookie, prof.userId);
311
- const mimoBalance = bal.ok ? { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now } : (a.meta?.mimoBalance || null);
312
- const accName = `Xiaomi MiMo (UID: ${prof.userId})`;
313
- await withAccounts((list) => {
314
- const hit = list.find((x) => x.id === a.id);
315
- if (!hit) return;
316
- hit.uid = prof.userId;
317
- if (canAutoRename(site, hit)) hit.name = accName;
318
- hit.meta = {
319
- ...(hit.meta || {}),
320
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
321
- userId: prof.userId,
322
- ...(mimoBalance ? { mimoBalance } : {}),
323
- ...(bal.ok ? { quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now } } : {}),
324
- };
325
- hit.updatedAt = now;
326
- });
327
- logger.info('COOKIE-SITE', '{name} 就地重探通过(UID {uid},余额 {balance})', { name: site.name, uid: prof.userId, balance: bal.ok ? bal.balance : '—' });
328
- 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 });
329
- }
330
- if (site.id === 'river') {
331
- const { fetchRiverBalance } = await import('./riverClient.js');
332
- const bal = await fetchRiverBalance(a, {
333
- log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
334
- });
335
- if (!bal.ok) return finish({ id: a.id, name: a.name, ok: false, error: bal.error || '余额探测失败' });
336
- await withAccounts((list) => {
337
- const hit = list.find((x) => x.id === a.id);
338
- if (!hit) return;
339
- const sess = hit.meta?.workosSession || {};
340
- if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落盘(单次轮换)
341
- hit.meta = {
342
- ...(hit.meta || {}),
343
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
344
- ...(sess.accessToken || sess.cookie ? { workosSession: sess } : {}),
345
- riverBalance: { balanceUsd: bal.balance, at: now },
346
- quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now },
347
- };
348
- const ident = sess.user?.email || sess.user?.id || null;
349
- if (sess.user?.id) hit.uid = String(sess.user.id);
350
- if (canAutoRename(site, hit) && ident) hit.name = `River AI (${ident})`;
351
- hit.updatedAt = now;
352
- });
353
- logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} USD)', { name: site.name, balance: bal.balance });
354
- 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 } });
355
- }
356
- if (site.id === 'doubao') {
357
- // 豆包(issue #32):detail 接口一次拿全(会话有效性 + 签到状态 + 累计点数),
358
- // 重探 = 刷新额度快照;无 10R 同步(豆包不推外部配额卡,错误文案不再误导)
359
- const { fetchDoubaoStatus } = await import('./doubaoClient.js');
360
- const st = await fetchDoubaoStatus(cookie, a.uid || '').catch((e) => ({ auth: false, code: e?.message || 'network' }));
361
- if (!st.auth) return finish({ id: a.id, name: a.name, ok: false, error: '豆包会话已失效,请重新登录捕获' });
362
- await withAccounts((list) => {
363
- const hit = list.find((x) => x.id === a.id);
364
- if (!hit) return;
365
- hit.meta = {
366
- ...(hit.meta || {}),
367
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
368
- quotaSnapshot: { used: 0, total: st.totalAmount, isCreditBalance: true, at: now },
369
- };
370
- hit.updatedAt = now;
371
- });
372
- logger.info('COOKIE-SITE', '{name} 就地重探通过(累计 {amount} 点,签到{signed})', { name: site.name, amount: st.totalAmount, signed: st.checkin?.checkedIn ? '已签' : '未签' });
373
- return finish({ id: a.id, name: a.name, ok: true, identity: null, balance: st.totalAmount, quotaSnapshot: { used: 0, total: st.totalAmount, isCreditBalance: true, at: now } });
374
- }
375
- if (site.verify === 'api' || site.probe) {
376
- // tokenharbor 等 HTML 探针站点:探测 + 身份 + 读数一并刷新
377
- const v = await verifySiteCookie(site, cookie);
378
- if (!v.ok) return finish({ id: a.id, name: a.name, ok: false, error: v.reason === 'invalid' ? '会话已失效,请重新登录捕获' : '探测失败,请稍后再试或重新登录捕获' });
379
- const identity = extractIdentity(site, v.html) || null;
380
- const quota = extractQuotaSnapshot(site, v.html) || null;
381
- const ih = identityHashOf(identity);
382
- await withAccounts((list) => {
383
- const hit = list.find((x) => x.id === a.id);
384
- if (!hit) return;
385
- hit.meta = {
386
- ...(hit.meta || {}),
387
- cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
388
- ...(ih ? { identityHash: ih } : {}),
389
- ...(quota ? { quotaSnapshot: { ...quota, at: now } } : {}),
390
- };
391
- if (canAutoRename(site, hit)) hit.name = site.accountName + (identity ? `(${identity})` : '');
392
- hit.updatedAt = now;
393
- });
394
- logger.info('COOKIE-SITE', '{name} 就地重探通过(身份 {identity})', { name: site.name, identity: identity || '—' });
395
- return finish({ id: a.id, name: a.name, ok: true, identity, quotaSnapshot: quota || null });
396
- }
397
- return finish({ id: a.id, name: a.name, ok: false, error: '该站点不支持重探,请重新登录捕获' });
398
- } catch (e) {
399
- return finish({ id: a.id, name: a.name, ok: false, error: e.message });
400
- }
401
- }
402
-
403
- async function readBody(req) {
404
- const chunks = [];
405
- let size = 0;
406
- for await (const c of req) {
407
- size += c.length;
408
- if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
409
- chunks.push(c);
410
- }
411
- const raw = Buffer.concat(chunks).toString('utf8');
412
- try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
413
- }
414
-
415
- /** 常量时间比较,避免通过响应时间逐字符猜出密钥;
416
- * mode 'hash' → scrypt 推导比;
417
- * mode 'plain' → 仅环境变量来源,按原常量时间比;
418
- * mode 'none' → 永远 false(本函数不该进来) */
419
- function keyMatches(given) {
420
- const head = String(given);
421
- if (PANEL_SESSION && head === PANEL_SESSION) return true; // 桌面寄主会话钥(重启随机,外部不可伪造)
422
- if (PANEL_KEY_MODE === 'hash') return scryptMatches(head, PANEL_KEY_HASH);
423
- const a = Buffer.from(head);
424
- const b = Buffer.from(PANEL_KEY);
425
- return a.length === b.length && crypto.timingSafeEqual(a, b);
426
- }
427
-
428
- function hostnameOf(hostHeader) {
429
- try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
430
- }
431
-
432
- /** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
433
- let localHostCache = { at: 0, set: null };
434
- function localHosts() {
435
- const now = Date.now();
436
- if (!localHostCache.set || now - localHostCache.at > 60_000) {
437
- const s = new Set(LOOPBACK_HOSTS);
438
- try { s.add(os.hostname().toLowerCase()); } catch {}
439
- try {
440
- for (const list of Object.values(os.networkInterfaces())) {
441
- for (const it of list || []) {
442
- if (!it?.address) continue;
443
- const a = it.address.toLowerCase();
444
- s.add(a);
445
- if (a.includes(':')) s.add(`[${a}]`);
446
- }
447
- }
448
- } catch {}
449
- localHostCache = { at: now, set: s };
450
- }
451
- return localHostCache.set;
452
- }
453
-
454
- /**
455
- * 浏览器侧防护,返回拒绝原因(null = 放行):
456
- * - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
457
- * · 回环监听:只允许 127.0.0.1 / localhost / ::1
458
- * · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
459
- * 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
460
- * - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
461
- * 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
462
- * 例外:`/api/quota-refresh` 是 HMAC 自鉴权回调(10R 配额卡按钮浏览器直连,Origin 必然
463
- * 是 10R 站点)——该路径豁免跨站检查,防伪交给签名 + 限流;Host 闸照常(防 DNS 重绑定)。
464
- */
465
- export function rejectForeignRequest(headers, bindHost, pathname) {
466
- const host = headers.host || '';
467
- const hostName = hostnameOf(host).toLowerCase();
468
- const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
469
- if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
470
- return 'Host 不被允许';
471
- }
472
- if (!loopbackBind && !panelKeyRequired() && !localHosts().has(hostName)) {
473
- return 'Host 不被允许';
474
- }
475
- const origin = headers.origin;
476
- if (origin && pathname !== '/api/quota-refresh') {
477
- let originHost = '';
478
- try { originHost = new URL(origin).host; } catch {}
479
- if (originHost !== host) return '跨站请求被拒绝';
480
- }
481
- return null;
482
- }
483
-
484
- /**
485
- * PUT /api/settings 的实际处理(鉴权已在入口完成):
486
- * { panelKey: '新密码' } 设置 / 修改面板访问密码(≥4 位、不含空白)
487
- * { disable: true } 关闭面板访问密码(导出账号的加密口令不受影响,始终必填)
488
- * 口令校验规则与导出加密一致(sealTransfer:至少 4 个字符)。
489
- */
490
- async function handleSettingsPut(res, body) {
491
- if (body?.disable === true) {
492
- // 明文与 scrypt 哈希一起清——漏清 panelKeyHash 的话 initPanelKey 重读又把模式拉回 hash,
493
- // 「关闭」永不生效,还会误报成「部署环境强制注入」。
494
- await saveSettings({ panelKey: '', panelKeyHash: '' });
495
- await initPanelKey(); // 环境变量部署(fnOS)下关闭后仍保留安装向导密码
496
- logger.warn('DAEMON', panelKeyRequired() ? '面板访问密码已在设置里关闭,但部署环境仍注入密码(保持开启)' : '面板访问密码已关闭(导出账号仍需加密口令)', {});
497
- return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
498
- }
499
- const next = typeof body?.panelKey === 'string' ? body.panelKey.trim() : '';
500
- if (!next) return json(res, 400, { error: '请提供新密码,或提交 disable 关闭' });
501
- if (next.length < 4) return json(res, 400, { error: '面板访问密码至少 4 位' });
502
- if (/\s/.test(next)) return json(res, 400, { error: '面板访问密码不能包含空格' });
503
- // 只存 scrypt 哈希,明文设置完立刻请出数据目录
504
- await saveSettings({ panelKeyHash: scryptPanelKey(next), panelKey: '' });
505
- await initPanelKey();
506
- logger.info('DAEMON', '面板访问密码已更新(scrypt 哈希落盘)', {});
507
- return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
508
- }
509
-
510
- // socket 地址级回环判定(不是 hostname):PANEL_KEY 未设时 fail-closed 的关键输入
511
- function isLoopbackSocketAddr(remote) {
512
- const r = String(remote || '');
513
- return !r || /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/.test(r);
514
- }
515
-
516
- // PANEL_KEY 按源 IP 失败计数限速:401 暴力猜密是免费的网络噪音――连错 10 次锁 5 分钟。
517
- // timingSafeEqual 防了侧信道,防不了离线字典速度撞库;失败计数只留进程内,重启归零。
518
- const keyFails = new Map(); // socketRemote → { n, lockedUntil }
519
- const KEY_FAIL_LIMIT = 10;
520
- const KEY_FAIL_LOCK_MS = 5 * 60_000;
521
-
522
- function remoteKeyAddr(req) { return String((req.socket && req.socket.remoteAddress) || ''); }
523
- function keyThrottled(remote) {
524
- const rec = keyFails.get(remote);
525
- if (!rec) return false;
526
- if (rec.lockedUntil && Date.now() < rec.lockedUntil) return true;
527
- if (rec.lockedUntil) keyFails.delete(remote);
528
- return false;
529
- }
530
- function noteKeyMiss(remote) {
531
- const rec = keyFails.get(remote) || { n: 0 };
532
- rec.n += 1;
533
- if (rec.n >= KEY_FAIL_LIMIT) { rec.lockedUntil = Date.now() + KEY_FAIL_LOCK_MS; rec.n = 0;
534
- logger.warn('DAEMON', '面板密码连续 {n} 次错误,来源 {remote} 已限流 5 分钟', { n: KEY_FAIL_LIMIT, remote }); }
535
- keyFails.set(remote, rec);
536
- }
537
-
538
- async function handleApi(req, res, url) {
539
- if (!panelKeyRequired()) {
540
- const remote = (req.socket && req.socket.remoteAddress) || '';
541
- if (!isLoopbackSocketAddr(remote)) {
542
- logger.warn('DAEMON', '拒绝非本机请求(未设面板访问密码):{method} {path} 来自 {remote}', { method: req.method, path: url.pathname, remote });
543
- return json(res, 403, { error: '未设置面板访问密码时,面板账号接口仅允许本机访问——请在「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问' });
544
- }
545
- }
546
- if (panelKeyRequired()) {
547
- const remote = remoteKeyAddr(req);
548
- if (keyThrottled(remote)) {
549
- return json(res, 429, { error: '面板密码连续错误过多,已限流 5 分钟' });
550
- }
551
- // 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
552
- const key = req.headers['x-qd-key'] || '';
553
- if (!keyMatches(key)) {
554
- noteKeyMiss(remote);
555
- return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
556
- }
557
- keyFails.delete(remote); // 猜对了立即清零
558
- }
559
- const p = url.pathname;
560
- const method = req.method;
561
-
562
- // 账号列表
563
- if (p === '/api/accounts' && method === 'GET') {
564
- const accounts = await loadAccounts();
565
- return json(res, 200, {
566
- accounts: accounts.map(publicAccount),
567
- providers: PROVIDER_LABEL,
568
- dataDir: dataDir(),
569
- });
570
- }
571
-
572
- // 添加账号(自动拉取 userinfo 补全名字)
573
- if (p === '/api/accounts' && method === 'POST') {
574
- const body = await readBody(req);
575
- let result;
576
- try {
577
- result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
578
- } catch (e) { return json(res, 400, { error: e.message }); }
579
- const { account, duplicate } = result;
580
- if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
581
- if (account.verified === false) {
582
- logger.warn('DAEMON', '账号 {name} 校验失败:{err}', { name: account.name || account.id, err: account.verifyError });
583
- }
584
- logger.info('DAEMON', '添加账号 {name}({provider})', { name: account.name || account.id, provider: PROVIDER_LABEL[account.provider] });
585
- return json(res, 201, { account: publicAccount(account) });
586
- }
587
-
588
- // 修改备注名
589
- const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
590
- if (idMatch && method === 'PATCH') {
591
- const body = await readBody(req);
592
- const name = String(body?.name || '').trim().slice(0, 64) || null;
593
- const found = await withAccounts((accounts) => {
594
- const a = accounts.find((x) => x.id === idMatch[1]);
595
- // meta.renamed = 用户手改的备注名(10R 同名认领键)——捕获/重探不得再自动覆盖
596
- if (a) { a.name = name; a.meta = { ...(a.meta || {}), renamed: Boolean(name) }; }
597
- return a || null;
598
- });
599
- if (!found) return json(res, 404, { error: '账号不存在' });
600
- return json(res, 200, { account: publicAccount(found) });
601
- }
602
-
603
- // 删除账号
604
- const delMatch = idMatch;
605
- if (delMatch && method === 'DELETE') {
606
- const removed = await withAccounts((accounts) => {
607
- const i = accounts.findIndex((a) => a.id === delMatch[1]);
608
- return i < 0 ? null : accounts.splice(i, 1)[0];
609
- });
610
- if (!removed) return json(res, 404, { error: '账号不存在' });
611
- logger.info('DAEMON', '删除账号 {name}', { name: removed.name || removed.id });
612
- return json(res, 200, { ok: true });
613
- }
614
-
615
- // 单账号签到
616
- const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
617
- if (checkinMatch && method === 'POST') {
618
- const accounts = await loadAccounts();
619
- const account = accounts.find((a) => a.id === checkinMatch[1]);
620
- const { results, summary } = account?.provider === 'zcode'
621
- ? await pollZcodeNow([account.id])
622
- : await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
623
- return json(res, 200, { results, summary });
624
- }
625
-
626
- // 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
627
- if (p === '/api/checkin' && method === 'POST') {
628
- const body = await readBody(req).catch(() => ({}));
629
- const opts = {
630
- provider: body?.provider || undefined,
631
- product: body?.product || undefined,
632
- skipIfCheckedToday: body?.skipIfCheckedToday !== false,
633
- };
634
- const { results, summary } = opts.provider === 'zcode' || opts.product === 'zcode'
635
- ? await pollZcodeNow()
636
- : await runCheckinTick(opts);
637
- return json(res, 200, { results, summary });
638
- }
639
-
640
- // ── WorkBuddy 临时活动(额外补偿 / 首月赠礼)──
641
- // 用已存 Bearer 凭据批量领取,免逐个网页点击。GET 预览可领项,POST 批量领。
642
- // body 可选 { accountIds: [], kinds: [] } 过滤;默认扫全部 workbuddy 账号 × 全部活动。
643
- if (p === '/api/workbuddy/activities' && method === 'GET') {
644
- const accounts = await loadAccounts();
645
- const targets = accounts.filter((a) => String(a.provider).startsWith('workbuddy'));
646
- const { fetchWbActivities } = await import('./workbuddyClient.js');
647
- const results = [];
648
- for (const a of targets) {
649
- const acts = await fetchWbActivities(a).catch((e) => [{ error: e.message }]);
650
- results.push({ accountId: a.id, name: a.name || a.id, provider: a.provider, activities: acts });
651
- }
652
- return json(res, 200, { results });
653
- }
654
- if (p === '/api/workbuddy/claim-activities' && method === 'POST') {
655
- const body = await readBody(req).catch(() => ({}));
656
- const onlyIds = Array.isArray(body?.accountIds) && body.accountIds.length ? new Set(body.accountIds) : null;
657
- const onlyKinds = Array.isArray(body?.kinds) && body.kinds.length ? body.kinds : null;
658
- const accounts = await loadAccounts();
659
- const targets = accounts.filter((a) => String(a.provider).startsWith('workbuddy') && (!onlyIds || onlyIds.has(a.id)));
660
- const { claimWbActivity, fetchWbActivities } = await import('./workbuddyClient.js');
661
- const results = [];
662
- for (const a of targets) {
663
- // 先查可领项,只对 active && !claimed 的发领取请求(幂等,多号批量安全)
664
- let acts = [];
665
- try { acts = await fetchWbActivities(a); } catch (e) { acts = [{ error: e.message }]; }
666
- const claimable = acts.filter((x) => x.claimable && (!onlyKinds || onlyKinds.includes(x.kind)));
667
- if (!claimable.length) {
668
- results.push({ accountId: a.id, name: a.name || a.id, activities: acts, claimed: [] });
669
- continue;
670
- }
671
- const claimed = [];
672
- for (const act of claimable) {
673
- const r = await claimWbActivity(a, act.kind).catch((e) => ({ accountId: a.id, account: a.name || a.id, kind: act.kind, status: 'failed', error: e.message }));
674
- claimed.push(r);
675
- const ok = r.status === 'checked-in';
676
- (ok ? logger.info : logger.warn)('WB-ACT', '{name} {label}:{msg}', { name: a.name || a.id, label: r.activityLabel || act.kind, msg: ok ? `领取成功 +${r.claimedAmount} 积分` : (r.error || r.message || r.status) });
677
- }
678
- results.push({ accountId: a.id, name: a.name || a.id, activities: acts, claimed });
679
- }
680
- const summary = {
681
- claimed: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'checked-in').length, 0),
682
- already: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'already').length, 0),
683
- failed: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'failed').length, 0),
684
- total: results.reduce((s, r) => s + r.claimed.length, 0),
685
- };
686
- return json(res, 200, { results, summary });
687
- }
688
-
689
- // 积分查询(统一结构:total / used / remaining / parts[])
690
- const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
691
- if (quotaMatch && method === 'GET') {
692
- const accounts = await loadAccounts();
693
- const account = accounts.find((a) => a.id === quotaMatch[1]);
694
- if (!account) return json(res, 404, { error: '账号不存在' });
695
- // 网页会话账号(cookie-site 五家)不在 PRODUCTS 表里——productImpl 会 undefined 崩
696
- // (502 且 lastProbe 不更新,呼吸灯纹丝不动、「重探后才行」的由来)。改走重探同款
697
- // 语义:live 拉余额 → 回填快照 → 写探活痕迹 → 推对应卡,响应归一成统一 quota 结构。
698
- if (account.meta?.cookieSiteId) {
699
- const site = getSite(account.meta.cookieSiteId);
700
- if (!site) return json(res, 404, { error: `站点 ${account.meta.cookieSiteId} 未配置` });
701
- if (site.disabled === true) return json(res, 410, { error: '该站点已停用(issue #35),不再探测上游' });
702
- // 频率治理(面板 2 分钟自动轮询曾把五家上游全轰一遍):默认读缓存快照零网络,
703
- // 仅 ?live=1(手动「刷新积分」按钮 / 单卡回调)才真探上游并回填快照。缓存保鲜交给
704
- // 后台推送循环(default 60 分钟)——它 live 拉到的余额本就该落库,顺手把这条链补上。
705
- const live = url.searchParams.get('live') === '1';
706
- let snap = account.meta?.quotaSnapshot || null;
707
- if (live || !snap) {
708
- const rep = await reprobeCookieAccount(site, account).catch((e) => ({ ok: false, error: e.message }));
709
- if (!rep.ok) return json(res, 502, { error: rep.error || '探测失败' });
710
- snap = rep.quotaSnapshot || {};
711
- }
712
- // 快照形状两样:API 站 total=余额;HTML 探针站(tokenharbor)total 缺失、
713
- // used=已用百分比——统一结构按各自语义归一(HTML 站 remaining=100-used)。
714
- const total = Number(snap.total);
715
- const used = Number(snap.used) || 0;
716
- const hasTotal = Number.isFinite(total) && total > 0;
717
- // 单位标注:各家余额单位互异(Token / USD / CNY / 魔粒 / 积分)且都不是「积分」
718
- // 口径——带 unit 返回,面板 creditSum 按 unit 有无决定是否计入「剩余积分」合计
719
- //(有单位 = 不同量纲,不相加)。HTML 探针站(tokenharbor)是百分比,同样排除。
720
- const UNIT_BY_SITE = { 'xiaomi-mimo': 'CNY', 'river': 'USD', 'longcat': 'Token', 'modelscope': '魔粒' };
721
- const unit = UNIT_BY_SITE[site.id] || (hasTotal ? '' : '%');
722
- return json(res, 200, { quota: {
723
- total: hasTotal ? total : 100,
724
- used,
725
- remaining: hasTotal ? total : Math.max(0, 100 - used),
726
- ...(unit ? { unit } : {}),
727
- exceeded: false,
728
- parts: [],
729
- } });
730
- }
731
- try {
732
- const log = (m) => logger.info('QUOTA', '{name}:{msg}', { name: account.name || account.id, msg: m });
733
- const ctx = { log, ...refreshContext(account, log) };
734
- return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
735
- } catch (e) {
736
- return json(res, 502, { error: e.message });
737
- }
738
- }
739
-
740
- // ZCode 活动领取
741
- const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
742
- if (zcodePlansMatch && method === 'GET') {
743
- const accounts = await loadAccounts();
744
- const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
745
- if (!account) return json(res, 404, { error: '账号不存在' });
746
- if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
747
- try {
748
- return json(res, 200, await fetchClaimPlans(account));
749
- } catch (e) {
750
- return json(res, 502, { error: e.message });
751
- }
752
- }
753
- const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
754
- if (zcodeClaimMatch && method === 'POST') {
755
- const body = await readBody(req).catch(() => ({}));
756
- if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
757
- const accounts = await loadAccounts();
758
- const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
759
- if (!account) return json(res, 404, { error: '账号不存在' });
760
- if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
761
- try {
762
- const outcome = await claimPlan(account, body.planId.slice(0, 128), {
763
- captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
764
- region: typeof body.region === 'string' ? body.region : '',
765
- });
766
- logger.info('DAEMON', 'ZCode {name} 领取成功:{plan}', { name: account.name || account.id, plan: outcome.planName });
767
- await zcodeGateway.clearQuotaMark(account.id);
768
- await withAccounts((list) => {
769
- const cur = list.find((a) => a.id === account.id);
770
- if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
771
- });
772
- return json(res, 200, outcome);
773
- } catch (e) {
774
- logger.warn('DAEMON', 'ZCode {name} 领取失败:{err}', { name: account.name || account.id, err: e.message });
775
- return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
776
- }
777
- }
778
- if (p === '/api/zcode/captcha-config' && method === 'GET') {
779
- try {
780
- return json(res, 200, await fetchCaptchaConfig());
781
- } catch (e) {
782
- return json(res, 502, { error: e.message });
783
- }
784
- }
785
- // 网络出口偏好(全局——Qoder/ZCode/cookie 探测等全部出口都走它):默认「直连优先、代理兜底」,
786
- // 可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600;键名 zcode 前缀是历史遗留)
787
- // 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
788
- if (p === '/api/zcode-gateway' && method === 'GET') {
789
- return json(res, 200, await zcodeGateway.gatewayStatus());
790
- }
791
- if (p === '/api/zcode-gateway' && method === 'PUT') {
792
- const body = await readBody(req).catch(() => ({}));
793
- if (body?.enabled !== undefined) await zcodeGateway.setGatewayEnabled(body.enabled === true);
794
- if (body?.lan !== undefined || body?.allow !== undefined) {
795
- const patch = {};
796
- if (body?.lan !== undefined) patch.zcodeGatewayLan = body.lan === true;
797
- if (body?.allow !== undefined) {
798
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
799
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
800
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
801
- patch.zcodeGatewayAllow = cleaned;
802
- }
803
- await (await import('./store.js')).saveSettings(patch);
804
- const lanNow = patch.zcodeGatewayLan ?? (await (await import('./store.js')).loadSettings()).zcodeGatewayLan === true;
805
- const allowText = patch.zcodeGatewayAllow ? patch.zcodeGatewayAllow.join(', ') : '';
806
- logger.info('DAEMON', 'ZCode 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
807
- }
808
- return json(res, 200, await zcodeGateway.gatewayStatus());
809
- }
810
- if (p === '/api/i18n' && method === 'GET') {
811
- const locale = getLocale();
812
- return json(res, 200, { locale, dict: await loadDict(locale) });
813
- }
814
- // MiniMax 本地网关控制
815
- if (p === '/api/minimax-gateway' && method === 'GET') {
816
- return json(res, 200, await minimaxGateway.gatewayStatus());
817
- }
818
- if (p === '/api/minimax-gateway' && method === 'PUT') {
819
- const body = await readBody(req).catch(() => ({}));
820
- if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
821
- if (body?.lan !== undefined || body?.allow !== undefined) {
822
- const patch = {};
823
- if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
824
- if (body?.allow !== undefined) {
825
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
826
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
827
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
828
- patch.minimaxGatewayAllow = cleaned;
829
- }
830
- await (await import('./store.js')).saveSettings(patch);
831
- const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
832
- const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
833
- logger.info('DAEMON', 'MiniMax 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
834
- }
835
- return json(res, 200, await minimaxGateway.gatewayStatus());
836
- }
837
- // Trae 本地网关控制
838
- if (p === '/api/trae-gateway' && method === 'GET') {
839
- return json(res, 200, await traeGateway.gatewayStatus());
840
- }
841
- if (p === '/api/trae-gateway' && method === 'PUT') {
842
- const body = await readBody(req).catch(() => ({}));
843
- if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
844
- if (body?.lan !== undefined || body?.allow !== undefined) {
845
- const patch = {};
846
- if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
847
- if (body?.allow !== undefined) {
848
- if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
849
- const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
850
- if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
851
- patch.traeGatewayAllow = cleaned;
852
- }
853
- await (await import('./store.js')).saveSettings(patch);
854
- const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
855
- const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
856
- logger.info('DAEMON', 'Trae 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
857
- }
858
- return json(res, 200, await traeGateway.gatewayStatus());
859
- }
860
- if (p === '/api/zcode/net' && method === 'GET') {
861
- const u = proxyUrl();
862
- const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
863
- const ac = autoClaimEnabled();
864
- return json(res, 200, {
865
- proxyFirst: proxyFirst(), proxyUrlMasked: masked,
866
- autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
867
- claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
868
- hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy),
869
- });
870
- }
871
- if (p === '/api/zcode/net' && method === 'PUT') {
872
- const body = await readBody(req).catch(() => ({}));
873
- try {
874
- if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
875
- if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
876
- // 自动领取计划(轮询周期 / 运行时长)先落盘,再按新配置启动
877
- if (body?.claimIntervalMin !== undefined) setClaimIntervalMin(body.claimIntervalMin);
878
- if (body?.claimWindowMin !== undefined) setClaimWindowMin(body.claimWindowMin);
879
- if (body?.claimIntervalMin !== undefined || body?.claimWindowMin !== undefined) refreshZcodeScheduler();
880
- if (body?.autoClaim !== undefined) {
881
- if (body.autoClaim === true) enableZcodeAutoClaimWindow(); // 开 = 按「自动领取计划」启动(2 分钟后首轮)
882
- else { setAutoClaimEnabled(false); refreshZcodeScheduler(); } // 关 = 立即停表
883
- }
884
- } catch (e) { return json(res, 400, { error: e.message }); }
885
- const ac = autoClaimEnabled();
886
- logger.info('DAEMON', '网络出口(全局):{route},ZCode 自动领取:{claim},代理 {proxy}', { route: proxyFirst() ? '代理优先' : '直连优先', claim: ac ? '开(每 {min} 分钟,{window})' : '关', min: claimIntervalMin(), window: claimWindowMin() > 0 ? '运行 {windowMin} 分钟' : '一直运行', windowMin: claimWindowMin(), proxy: proxyUrl() || '(环境变量/未设置)' });
887
- return json(res, 200, {
888
- proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
889
- claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
890
- });
891
- }
892
-
893
- // 切换 WorkBuddy / ZCode 客户端当前登录账号
894
- const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
895
- if (switchMatch && method === 'POST') {
896
- const body = await readBody(req).catch(() => ({}));
897
- const accounts = await loadAccounts();
898
- const target = accounts.find((a) => a.id === switchMatch[1]);
899
- if (!target) return json(res, 404, { error: '账号不存在' });
900
- if (target.provider === 'zcode') {
901
- // 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
902
- const live = zcodeLiveAccount();
903
- if (live && live.uid !== target.uid) {
904
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:{err}', { err: e.message }));
905
- }
906
- try {
907
- // 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
908
- let closedClient = false;
909
- if (body?.force === true) {
910
- const t = terminateZcode();
911
- closedClient = t.closed === true;
912
- if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)', {});
913
- else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换', {});
914
- }
915
- ensureVirtualDeviceMid(target);
916
- const r = zcodeSwitchTo(target, { force: body?.force === true });
917
- // 虚拟设备 ID 首次生成时需落盘
918
- await withAccounts((list) => {
919
- const cur = list.find((a) => a.id === target.id);
920
- if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
921
- });
922
- logger.info('DAEMON', r.alreadyActive ? 'ZCode 当前已是 {name}' : 'ZCode 已切换到 {name}', { name: target.name || target.id });
923
- return json(res, 200, { ok: true, closedClient, ...r });
924
- } catch (e) {
925
- return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
926
- }
927
- }
928
- if (target.provider === 'mirasim') {
929
- // 防丢号:先把当前登录同步进账号库
930
- try {
931
- const live = await mirasimLiveAccount();
932
- if (live && live.uid !== target.uid) {
933
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:{err}', { err: e.message }));
934
- }
935
- } catch (e) {}
936
- try {
937
- let closedClient = false;
938
- if (body?.force === true) {
939
- const t = terminateMirasim();
940
- closedClient = t.closed === true;
941
- if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)', {});
942
- }
943
- const r = await mirasimSwitchTo(target, { force: body?.force === true });
944
- logger.info('DAEMON', r.alreadyActive ? 'mirasim 当前已是 {name}' : 'mirasim 已切换到 {name}', { name: target.name || target.id });
945
- return json(res, 200, { ok: true, closedClient, ...r });
946
- } catch (e) {
947
- return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
948
- }
949
- }
950
- if (target.provider === 'catpaw') {
951
- // 防丢号:先把妙手当前登录同步进账号库
952
- try {
953
- const live = await catpawLiveAccount();
954
- if (live && live.token !== target.token) {
955
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步妙手当前登录失败:{err}', { err: e.message }));
956
- }
957
- } catch (e) {}
958
- try {
959
- let closedClient = false;
960
- if (body?.force === true) {
961
- const t = terminateCatpaw();
962
- closedClient = t.closed === true;
963
- if (closedClient) logger.info('DAEMON', '已关闭妙手客户端(强制切换)', {});
964
- else if (t.running) logger.warn('DAEMON', '未能完全结束妙手进程,继续强制切换', {});
965
- }
966
- const r = await catpawSwitchTo(target, { force: body?.force === true });
967
- logger.info('DAEMON', r.alreadyActive ? '妙手当前已是 {name}' : '妙手已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
968
- return json(res, 200, { ok: true, closedClient, ...r });
969
- } catch (e) {
970
- return json(res, e.catpawRunning ? 409 : 400, { error: e.message, code: e.catpawRunning ? 'CATPAW_RUNNING' : undefined });
971
- }
972
- }
973
- if (target.provider === 'qoder' || target.provider === 'qoder-cn') {
974
- // 防丢号:先把本机当前登录的最新 auth 快照回写账号库(客户端会静默刷新 token,旧快照可能已失效),再覆盖
975
- try {
976
- const lr = await readQoderAppAccounts();
977
- const live = lr.accounts.find((c) => c.provider === target.provider);
978
- if (live && live.user.id && String(live.user.id) !== String(target.uid)) {
979
- await addAccount({
980
- provider: live.provider, token: live.token, name: live.user.name || live.user.email,
981
- uid: live.user.id, email: live.user.email, refreshToken: live.refreshToken, expiresAt: live.expiresAt,
982
- source: 'local-app', meta: { qoderAuth: live.authJson, qoderAuthFile: live.file },
983
- }, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Qoder 当前登录失败:{err}', { err: e.message }));
984
- }
985
- } catch (e) { logger.warn('DAEMON', '读取 Qoder 当前登录失败:{err}', { err: e.message }); }
986
- try {
987
- // 强制切换:先关掉运行中的 Qoder,否则它退出时会把内存里的旧登录覆盖回文件
988
- let closedClient = false;
989
- if (body?.force === true) {
990
- const t = terminateQoder();
991
- closedClient = t.closed === true;
992
- if (closedClient) logger.info('DAEMON', '已关闭 Qoder 客户端(强制切换)', {});
993
- else if (t.running) logger.warn('DAEMON', '未能完全结束 Qoder 进程,继续强制切换', {});
994
- }
995
- const r = await qoderSwitchTo(target, { force: body?.force === true });
996
- // status 里的「Qoder 当前登录」缓存 1 分钟:不放掉的话切完后面板仍把旧账号当当前账号
997
- // (新当前账号不亮角标、切换键显隐全错,用户看到的就是「切完切换键没了」)
998
- qoderCurCache = { at: 0, data: null };
999
- if (r.file && r.file !== target.meta?.qoderAuthFile) {
1000
- // 快照路径已失效、实际写进了本机新的数据目录:账号上的路径一并改成生效的那个
1001
- await withAccounts((list) => {
1002
- const cur = list.find((a) => a.id === target.id);
1003
- if (cur) cur.meta = { ...(cur.meta || {}), qoderAuthFile: r.file };
1004
- }).catch((e) => logger.warn('DAEMON', '更新 Qoder 快照路径失败:{err}', { err: e.message }));
1005
- }
1006
- logger.info('DAEMON', r.alreadyActive ? 'Qoder 当前已是 {name}' : 'Qoder 已切换到 {name}(重新打开 Qoder 生效)', { name: target.name || target.id });
1007
- return json(res, 200, { ok: true, closedClient, ...r });
1008
- } catch (e) {
1009
- return json(res, e.qoderRunning ? 409 : 400, { error: e.message, code: e.qoderRunning ? 'QODER_RUNNING' : undefined });
1010
- }
1011
- }
1012
- if (target.provider === 'trae') {
1013
- // 防丢号:先把 Trae 当前登录同步进账号库并建快照,再覆盖成目标账号的登录态
1014
- try {
1015
- const live = await traeLiveAccount();
1016
- if (live && live.token !== target.token) {
1017
- await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Trae 当前登录失败:{err}', { err: e.message }));
1018
- traeSnapshotLive();
1019
- }
1020
- } catch (e) {}
1021
- try {
1022
- // 退进程交给 switchTo:它要先确认目标有快照才会动手,避免「白关一次 Trae」
1023
- const r = await traeSwitchTo(target, { force: body?.force === true });
1024
- if (r.closedClient) logger.info('DAEMON', '已关闭 Trae 客户端(强制切换)', {});
1025
- logger.info('DAEMON', r.alreadyActive ? 'Trae 当前已是 {name}' : 'Trae 已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
1026
- return json(res, 200, { ok: true, ...r });
1027
- } catch (e) {
1028
- return json(res, e.traeRunning ? 409 : 400, { error: e.message, code: e.traeRunning ? 'TRAE_RUNNING' : undefined });
1029
- }
1030
- }
1031
- // MiniMax 不做切换:客户端凭据只有一条路径派生的活动记录,且 refreshToken 轮换式一次性,
1032
- // CreditDaddy 与客户端共用必然互抢(invalid_grant),切号能力已按用户决定移除。
1033
- if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim / 妙手 / Trae 账号支持切换' });
1034
- // 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
1035
- const cur = readWorkbuddySessions().accounts.find((a) => a.current);
1036
- if (cur && cur.uid !== target.uid) {
1037
- const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
1038
- await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:{err}', { err: e.message }));
1039
- }
1040
- try {
1041
- // 本机没装 WorkBuddy / CodeBuddy 客户端时明确拒绝:写会话文件是死路——
1042
- // 之前不拦会 mkdir 硬写后返回成功,用户以为切好了,客户端却永远不会应用它
1043
- const wbDet = detectWorkbuddy();
1044
- if (!wbDet.installed) {
1045
- return json(res, 400, { error: '本机未安装 WorkBuddy / CodeBuddy 客户端,无法切换(切换是把登录写入客户端凭据,由客户端应用;可先安装客户端后在本机导入再切换)', code: 'WORKBUDDY_NOT_INSTALLED' });
1046
- }
1047
- const r = writeWorkbuddySession(target);
1048
- logger.info('DAEMON', 'WorkBuddy 已切换到 {name}', { name: target.name || target.id });
1049
- return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
1050
- } catch (e) {
1051
- return json(res, 400, { error: e.message });
1052
- }
1053
- }
1054
-
1055
- // ── 设备码登录(Qoder) ──
1056
- if (p === '/api/auth/device/start' && method === 'POST') {
1057
- const body = await readBody(req).catch(() => ({}));
1058
- try {
1059
- const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
1060
- const flow = await startDeviceFlow(kind);
1061
- return json(res, 200, flow);
1062
- } catch (e) { return json(res, 500, { error: e.message }); }
1063
- }
1064
- if (p === '/api/auth/device/poll' && method === 'POST') {
1065
- const body = await readBody(req).catch(() => ({}));
1066
- if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
1067
- try {
1068
- return json(res, 200, await pollDeviceFlow(body.sessionId));
1069
- } catch (e) { return json(res, 500, { error: e.message }); }
1070
- }
1071
-
1072
- // ── Qoder 网页会话(桌面版登录窗口关闭前抓取,用于逐资源包用量明细) ──
1073
- // Cookie 先探测归属(响应里的 user_id),只写到同 uid 的 qoder 账号上;探测失败视为会话无效。
1074
- if (p === '/api/auth/qoder-web-session' && method === 'POST') {
1075
- const body = await readBody(req).catch(() => ({}));
1076
- const cookie = String(body?.cookie || '').trim();
1077
- const kind = body?.kind === 'qoder-cn' ? 'qoder-cn' : 'qoder';
1078
- // 诊断:登录窗关闭时两个域都会上报;收到 0 条 Cookie 说明窗口内根本没建立该域的网站会话
1079
- const count = body?.diag?.cookieCount;
1080
- logger.info('DAEMON', 'Qoder 网页会话收割({kind}):{result}', { kind, result: typeof count === 'number' ? '{count} 条 Cookie' : '已上报', count });
1081
- if (!cookie || cookie.length > 16384) return json(res, 200, { ok: true, bound: 0, empty: true });
1082
- const { probeWebSession } = await import('./qoderClient.js');
1083
- const probe = await probeWebSession(kind, cookie);
1084
- if (!probe?.user_id) {
1085
- logger.warn('DAEMON', 'Qoder {edition}网页会话探测未通过(接口不认这份 Cookie),已丢弃', { edition: kind === 'qoder-cn' ? '国内版' : '国际版' });
1086
- return json(res, 400, { error: '网页会话无效(探测接口未通过)' });
1087
- }
1088
- const now = new Date().toISOString();
1089
- const bound = await withAccounts((list) => {
1090
- const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
1091
- for (const a of hits) {
1092
- a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
1093
- delete a.meta.qoderWebDead; // 新 cookie 落地即清失效标记(保活监视下一轮重新评估)
1094
- a.updatedAt = now;
1095
- }
1096
- return hits.length;
1097
- });
1098
- if (!bound) {
1099
- // 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
1100
- await withSettings((settings) => {
1101
- settings.pendingQoderWebSessions = {
1102
- ...(settings.pendingQoderWebSessions || {}),
1103
- [probe.user_id]: { kind, cookie, capturedAt: now },
1104
- };
1105
- });
1106
- logger.info('DAEMON', '收到 Qoder 网页会话(uid {uid}...),暂无匹配账号,已暂存待绑定', { uid: probe.user_id.slice(0, 8) });
1107
- } else {
1108
- logger.info('DAEMON', '已绑定 Qoder 网页会话到 {n} 个账号(逐资源包明细可用)', { n: bound });
1109
- }
1110
- return json(res, 200, { ok: true, bound });
1111
- }
1112
-
1113
- // ── 通用 cookie 站点捕获(issue #23):登录窗口收割 → 探测 → 入账号库(provider=site.provider)
1114
- if (p === '/api/cookie-sites' && method === 'GET') {
1115
- const accounts = await loadAccounts();
1116
- return json(res, 200, {
1117
- sites: COOKIE_SITES.map((site) => {
1118
- const rows = accounts
1119
- .filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id)
1120
- .map((a) => ({
1121
- id: a.id,
1122
- name: a.name || site.accountName,
1123
- capturedAt: a.meta?.cookieSiteSession?.capturedAt || null,
1124
- quota: a.meta?.quotaSnapshot || null,
1125
- mimoBalance: a.meta?.mimoBalance || null,
1126
- riverBalance: a.meta?.riverBalance || null,
1127
- msBalance: a.meta?.msBalance || null,
1128
- longcatPacks: a.meta?.longcatPacks || null,
1129
- lastProbe: a.meta?.lastProbe || null, // 呼吸灯数据源:ok / warn / dead + 失败原因
1130
- }));
1131
- const latest = rows.reduce((t, r) => (r.capturedAt && (!t || r.capturedAt > t) ? r.capturedAt : t), null);
1132
- return {
1133
- id: site.id, name: site.name, label: site.label, loginUrl: site.loginUrl, hint: site.hint,
1134
- accountId: rows[0]?.id || null, capturedAt: latest, accounts: rows,
1135
- };
1136
- }),
1137
- });
1138
- }
1139
-
1140
- if (p.startsWith('/api/cookie-sites/') && p.endsWith('/capture') && method === 'POST') {
1141
- const siteId = p.slice('/api/cookie-sites/'.length, -'/capture'.length);
1142
- const site = getSite(siteId);
1143
- if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1144
- const body = await readBody(req).catch(() => ({}));
1145
- const cookie = String(body?.cookie || '').trim();
1146
- // localStorage 凭据线(river = WorkOS SPA 会话):cookie 无关,探测走站点自有 API
1147
- const storage = (body?.storage && typeof body.storage === 'object' && !Array.isArray(body.storage)) ? body.storage : {};
1148
- const storageSite = Array.isArray(site.storageKeys) && site.storageKeys.length > 0 ? site : null;
1149
- const count = body?.diag?.cookieCount;
1150
- logger.info('COOKIE-SITE', '{name} cookie 收割上报({count} 条)', { name: site.name, count: typeof count === 'number' ? count : '-' });
1151
- if (storageSite) {
1152
- if (cookie.length > 32768) return json(res, 400, { error: 'cookie 体积异常,请重新登录后再捕获' });
1153
- } else if (!cookie || cookie.length > 32768) {
1154
- return json(res, 400, { error: 'cookie 不能为空,请重新登录后再捕获' });
1155
- }
1156
-
1157
- // 探测策略:桌面壳隐藏 Chromium 窗口探针产的 probeHtml 优先(真浏览器视角,过风控);
1158
- // 无 probeHtml(CLI / 非桌面)回退 Node fetch 探测(fetchJsonRace,跟全局代理)。
1159
- // storage 站点(river)与 verify:'api' 站点(modelscope)跳过 HTML 探针——余额 API 调用本身就是探测。
1160
- const skipGenericProbe = Boolean(storageSite) || site.verify === 'api';
1161
- const probeHtml = skipGenericProbe ? null : (typeof body?.probeHtml === 'string' && body.probeHtml.length > 0 && body.probeHtml.length < 4 * 1024 * 1024 ? body.probeHtml : null);
1162
- const probe = skipGenericProbe ? { ok: true } : (probeHtml ? verifyProbeHtml(site, probeHtml) : await verifySiteCookie(site, cookie));
1163
- if (probeHtml && !probe.ok) {
1164
- // 临时调试:浏览器探针页首 120 字进日志,便于区分「风控挑战页 / 登录页壳 / 结构变化」三元
1165
- const dbg = JSON.stringify(String(body.probeHtml).replace(/\s+/g, ' ').slice(0, 120));
1166
- logger.warn('COOKIE-SITE', '{name} 浏览器探针未中(reason={reason}) head={head}', { name: site.name, reason: probe.reason, head: dbg });
1167
- }
1168
- if (!probe.ok) {
1169
- const reason = probe.reason === 'invalid' ? '探测 401/403(会话无效)'
1170
- : probe.reason === 'no-match' ? '探测页面缺少配额标记(登录未完成或已登出)'
1171
- : `探测失败(${probe.reason}${probe.status ? ' HTTP ' + probe.status : ''})`;
1172
- logger.warn('COOKIE-SITE', '{name} cookie 捕获被拒:{reason}', { name: site.name, reason });
1173
- 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) });
1174
- }
1175
-
1176
- const now = new Date().toISOString();
1177
- // 身份探测(免费账号要多账号轮换):从浏览器探针 HTML 里抽一次身份;
1178
- // 抽不到 → 无身份单例(与初版一致),不静默覆盖有身份的行。
1179
- let identity = probeHtml ? extractIdentity(site, probeHtml) : null;
1180
- // issue #24:浏览器探针 HTML 顺手解析额度读数(推送时不再打网络;解析不出保持旧快照)
1181
- let quotaSnapshot = probeHtml ? extractQuotaSnapshot(site, probeHtml) : null;
1182
- let mimoUserId = null;
1183
- let mimoBalance = null;
1184
-
1185
- // issue #26:小米 MiMo 控制台资料与余额抓取
1186
- if (site.id === 'xiaomi-mimo') {
1187
- try {
1188
- const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
1189
- const prof = await fetchXiaomiMimoProfile(cookie);
1190
- if (prof.ok && prof.userId) {
1191
- mimoUserId = prof.userId;
1192
- identity = prof.userName ? `${prof.userName} (${prof.userId})` : prof.userId;
1193
- const bal = await fetchXiaomiMimoBalance(cookie, mimoUserId);
1194
- if (bal.ok) {
1195
- mimoBalance = { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now };
1196
- quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true, at: now };
1197
- }
1198
- }
1199
- } catch (mimoErr) {
1200
- logger.warn('COOKIE-SITE', '小米 MiMo 控制台资料/余额抓取异常:{err}', { err: mimoErr.message });
1201
- }
1202
- }
1203
-
1204
- // river(WorkOS 会话线):cookie 直连 → Bearer → 刷新兑换,探测/身份/额度一步完成。
1205
- // 实测(2026-10-14):SPA token 只在内存,localStorage 通常空——cookie 是主通道。
1206
- let riverSession = null;
1207
- let riverBalance = null;
1208
- if (site.id === 'river') {
1209
- try {
1210
- const { parseRiverSession, fetchRiverBalance } = await import('./riverClient.js');
1211
- const sess = parseRiverSession(storage);
1212
- sess.cookie = cookie; // 多源合并 cookie 串(auth.console.river.ai 的会话凭据在内)
1213
- const storageCount = Object.values(storage || {}).filter((v) => typeof v === 'string' && v.trim()).length;
1214
- const cookieNames = Array.isArray(body?.diag?.cookieNames) ? body.diag.cookieNames.slice(0, 40).join(',') : '';
1215
- 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 || '—' });
1216
- if (!sess.accessToken && !cookie) {
1217
- logger.warn('COOKIE-SITE', '{name} 未读到任何会话凭据(storage token 与 cookie 均空)', { name: site.name });
1218
- return json(res, 400, { error: '未读到 River 登录会话(WorkOS token / 会话 cookie 均无),请在登录窗完成登录后再关闭' });
1219
- }
1220
- const bal = await fetchRiverBalance({ meta: { workosSession: sess } }, {
1221
- log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
1222
- });
1223
- if (!bal.ok) {
1224
- logger.warn('COOKIE-SITE', '{name} 余额探测被拒:{reason}', { name: site.name, reason: bal.error });
1225
- return json(res, (bal.status === 401 || bal.status === 403) ? 401 : 502, {
1226
- error: (bal.status === 401 || bal.status === 403)
1227
- ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1228
- : '探测站点失败:' + (bal.error || ''),
1229
- });
1230
- }
1231
- if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落库(单次轮换)
1232
- riverSession = sess;
1233
- riverBalance = { balanceUsd: bal.balance, at: now };
1234
- identity = sess.user?.email || sess.user?.id || null;
1235
- quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true };
1236
- logger.info('COOKIE-SITE', '{name} 余额探测通过(通道 {channel},{balance} USD)', { name: site.name, channel: bal.channel, balance: bal.balance });
1237
- } catch (riverErr) {
1238
- logger.warn('COOKIE-SITE', '{name} 余额抓取异常:{err}', { name: site.name, err: riverErr.message });
1239
- return json(res, 502, { error: '探测站点失败:' + (riverErr.message || '') });
1240
- }
1241
- }
1242
-
1243
- // modelsscope(CD#25):魔粒余额 API 即探测;每日额度自动发放(无签到动作端点),只读余额
1244
- let msSession = null;
1245
- let msBalance = null;
1246
- if (site.id === 'modelscope') {
1247
- try {
1248
- const { probeMagicubeSession } = await import('./modelscopeClient.js');
1249
- if (!cookie) {
1250
- logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1251
- return json(res, 400, { error: '未读到 ModelScope 登录会话,请在登录窗完成登录后再关闭' });
1252
- }
1253
- const probe = await probeMagicubeSession(cookie);
1254
- if (!probe.ok) {
1255
- logger.warn('COOKIE-SITE', '{name} 魔粒余额探测被拒:{reason}', { name: site.name, reason: probe.error });
1256
- return json(res, (probe.status === 401 || probe.status === 403) ? 401 : 502, {
1257
- error: (probe.status === 401 || probe.status === 403)
1258
- ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1259
- : '探测站点失败:' + (probe.error || ''),
1260
- });
1261
- }
1262
- msSession = probe.msSession;
1263
- msBalance = probe.msBalance;
1264
- identity = probe.identity;
1265
- quotaSnapshot = { used: 0, total: probe.balance, isCreditBalance: true };
1266
- logger.info('COOKIE-SITE', '{name} 魔粒余额探测通过({balance} 个,今日 {today} / 长期 {long})', { name: site.name, balance: probe.balance, today: probe.msBalance.today ?? '—', long: probe.msBalance.long ?? '—' });
1267
- if (!identity) logger.warn('COOKIE-SITE', '{name} 身份未解析(login/info 响应字段:{keys})', { name: site.name, keys: probe.keys || '—' });
1268
- } catch (msErr) {
1269
- logger.warn('COOKIE-SITE', '{name} 魔粒余额抓取异常:{err}', { name: site.name, err: msErr.message });
1270
- return json(res, 502, { error: '探测站点失败:' + (msErr.message || '') });
1271
- }
1272
- }
1273
-
1274
- // LongCat(issue #29):Token 资源包 summary API 即探测(美团 SSO cookie 直连,
1275
- // 未登录 401「用户未登录」)。纯只读——包为购买/赠送发放,无签到动作端点。
1276
- let lcPacks = null;
1277
- let lcSession = null;
1278
- let longcatSnapshot = null;
1279
- if (site.id === 'longcat') {
1280
- try {
1281
- const lcMod = await import('./longcatClient.js');
1282
- const { fetchLongcatPacks, fetchLongcatIdentity } = lcMod;
1283
- longcatSnapshot = lcMod.longcatSnapshot;
1284
- if (!cookie) {
1285
- logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1286
- return json(res, 400, { error: '未读到 LongCat 登录会话,请在登录窗完成登录后再关闭' });
1287
- }
1288
- const packs = await fetchLongcatPacks(cookie);
1289
- if (!packs.ok) {
1290
- logger.warn('COOKIE-SITE', '{name} 资源包探测被拒:{reason}', { name: site.name, reason: packs.error });
1291
- return json(res, packs.authExpired ? 401 : 502, {
1292
- error: packs.authExpired
1293
- ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1294
- : '探测站点失败:' + (packs.error || ''),
1295
- });
1296
- }
1297
- lcPacks = packs.packs;
1298
- const lcTotal = lcPacks.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
1299
- quotaSnapshot = { used: 0, total: lcTotal, isCreditBalance: true };
1300
- // 身份 fail-soft:GET /api/v1/user-current 解析不出走无身份单例,不阻断捕获
1301
- const ident = await fetchLongcatIdentity(cookie).catch(() => null);
1302
- if (ident?.ok) {
1303
- lcSession = ident.session;
1304
- identity = ident.session?.email || ident.session?.name || ident.session?.userId || null;
1305
- }
1306
- logger.info('COOKIE-SITE', '{name} 资源包探测通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: lcPacks.length, identity: identity || '—' });
1307
- } catch (lcErr) {
1308
- logger.warn('COOKIE-SITE', '{name} 资源包抓取异常:{err}', { name: site.name, err: lcErr.message });
1309
- return json(res, 502, { error: '探测站点失败:' + (lcErr.message || '') });
1310
- }
1311
- }
1312
-
1313
- const ih = identityHashOf(mimoUserId || riverSession?.user?.id || msSession?.id || lcSession?.userId || identity);
1314
- const quotaMeta = quotaSnapshot ? { quotaSnapshot: { ...quotaSnapshot, at: now } } : {};
1315
- const cred = riverSession ? riverSession.accessToken : cookie; // 落盘的凭据主体
1316
- const riverUid = riverSession?.user?.id || null;
1317
- let accountId = null;
1318
- await withAccounts((list) => {
1319
- const hit = list.find((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1320
- && (ih ? a.meta?.identityHash === ih : !a.meta?.identityHash));
1321
- const accName = mimoUserId
1322
- ? `Xiaomi MiMo (UID: ${mimoUserId})`
1323
- : (site.id === 'river'
1324
- ? `River AI (${identity || '会话'})`
1325
- : (site.id === 'modelscope'
1326
- ? `ModelScope (${identity || '会话'})`
1327
- : (site.id === 'longcat'
1328
- ? `LongCat (${identity || '会话'})`
1329
- : (site.accountName + (identity ? `(${identity})` : '')))));
1330
- // 自动行名只在「没手改过且现名属自动名家族」时写入(meta.renamed + isAutoGeneratedName 双闸)
1331
- const autoName = (row) => { if (canAutoRename(site, row)) row.name = accName; };
1332
- if (hit) {
1333
- hit.token = cred;
1334
- if (mimoUserId) { hit.uid = mimoUserId; autoName(hit); }
1335
- if (riverUid) { hit.uid = riverUid; autoName(hit); }
1336
- if (msSession && (msSession.id || msSession.email || msSession.name)) {
1337
- if (msSession.id) hit.uid = String(msSession.id);
1338
- autoName(hit);
1339
- }
1340
- hit.meta = {
1341
- ...(hit.meta || {}),
1342
- cookieSiteSession: { cookie, capturedAt: now },
1343
- ...(ih ? { identityHash: ih } : {}),
1344
- ...quotaMeta,
1345
- ...(mimoUserId ? { userId: mimoUserId } : {}),
1346
- ...(mimoBalance ? { mimoBalance } : {}),
1347
- ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1348
- ...(riverBalance ? { riverBalance } : {}),
1349
- ...(msSession ? { msSession, msBalance } : {}),
1350
- ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1351
- ...(lcSession ? { longcatSession: lcSession } : {}),
1352
- };
1353
- if (lcSession?.userId) { hit.uid = String(lcSession.userId); autoName(hit); }
1354
- hit.updatedAt = now;
1355
- accountId = hit.id;
1356
- } else {
1357
- const acc = normalizeAccountInput({
1358
- name: accName,
1359
- provider: site.provider,
1360
- token: cred,
1361
- uid: mimoUserId || riverUid || (msSession?.id ? String(msSession.id) : undefined) || (lcSession?.userId ? String(lcSession.userId) : undefined) || undefined,
1362
- source: 'web-session',
1363
- meta: {
1364
- cookieSiteId: site.id,
1365
- cookieSiteSession: { cookie, capturedAt: now },
1366
- ...(ih ? { identityHash: ih } : {}),
1367
- ...quotaMeta,
1368
- ...(mimoUserId ? { userId: mimoUserId } : {}),
1369
- ...(mimoBalance ? { mimoBalance } : {}),
1370
- ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1371
- ...(riverBalance ? { riverBalance } : {}),
1372
- ...(msSession ? { msSession, msBalance } : {}),
1373
- ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1374
- ...(lcSession ? { longcatSession: lcSession } : {}),
1375
- },
1376
- });
1377
- acc.verified = true;
1378
- list.push(acc);
1379
- accountId = acc.id;
1380
- }
1381
- });
1382
- if (ih) logger.info('COOKIE-SITE', '{name} 身份归户({identity},账号轮换键生效)', { name: site.name, identity });
1383
- logger.info('COOKIE-SITE', site.no10rSync ? '{name} cookie 捕获成功(探测通过,额度卡片已就绪)' : '{name} cookie 捕获成功(探测通过,同步到 10Router 后卡片点亮)', { name: site.name });
1384
- // 捕获成功后只推当前站点的额度卡(单号精准推送,绝不全量刷屏)
1385
- (async () => {
1386
- try {
1387
- if (site.id === 'tokenharbor') {
1388
- await tenrouter.pushQuotaCards({ onlySources: ['tokenharbor'] });
1389
- } else if (accountId) {
1390
- const accs = await loadAccounts();
1391
- const target = accs.find((x) => x.id === accountId);
1392
- if (target) {
1393
- let src = null;
1394
- if (site.id === 'xiaomi-mimo') {
1395
- const { buildXiaomiMimoQuotaPayload } = await import('./xiaomiMimoClient.js');
1396
- src = buildXiaomiMimoQuotaPayload(target).source;
1397
- } else if (site.id === 'river') {
1398
- const { buildRiverQuotaPayload } = await import('./riverClient.js');
1399
- src = buildRiverQuotaPayload(target).source;
1400
- } else if (site.id === 'modelscope') {
1401
- const { buildModelScopeQuotaPayload } = await import('./modelscopeClient.js');
1402
- src = buildModelScopeQuotaPayload(target).source;
1403
- } else if (site.id === 'longcat') {
1404
- const { buildLongcatQuotaPayload } = await import('./longcatClient.js');
1405
- src = buildLongcatQuotaPayload(target).source;
1406
- }
1407
- if (src) await tenrouter.pushQuotaCards({ onlySources: [src] });
1408
- }
1409
- }
1410
- } catch (pushErr) {
1411
- logger.warn('COOKIE-SITE', '{name} 捕获后单路推卡失败:{err}', { name: site.name, err: pushErr.message });
1412
- }
1413
- })().catch(() => {});
1414
- return json(res, 200, { ok: true, accountId });
1415
- }
1416
-
1417
- // ── 就地重探:用已存会话 cookie 重跑「身份+余额+流水」并回填行(免登录窗重捕)──
1418
- if (p.startsWith('/api/cookie-sites/') && p.endsWith('/reprobe') && method === 'POST') {
1419
- const siteId = p.slice('/api/cookie-sites/'.length, -'/reprobe'.length);
1420
- const site = getSite(siteId);
1421
- if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1422
- const body = await readBody(req).catch(() => ({}));
1423
- const onlyId = body?.accountId ? String(body.accountId) : null;
1424
- const accounts = await loadAccounts();
1425
- const targets = accounts.filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1426
- && (!onlyId || a.id === onlyId));
1427
- if (site.disabled === true) return json(res, 410, { error: '该站点已停用(issue #35),不再探测上游' });
1428
- if (!targets.length) return json(res, 404, { error: '没有可重探的会话(先登录捕获一次)' });
1429
- // 重探只推涉及的那几路 source(其余卡不打上游、不重推):
1430
- // tokenharbor 是单例卡(source 固定);mimo/river/ms 按账号算 source;失败行也进名单
1431
- // ——失败说明会话可能已死,恰好要把 message 状态卡推上去让 10R 卡面可见。
1432
- const touchedSources = new Set();
1433
- const sourceOf = async (a) => {
1434
- if (site.id === 'tokenharbor') return 'tokenharbor';
1435
- try {
1436
- const m = await import(
1437
- site.id === 'xiaomi-mimo' ? './xiaomiMimoClient.js'
1438
- : site.id === 'river' ? './riverClient.js'
1439
- : site.id === 'longcat' ? './longcatClient.js' : './modelscopeClient.js');
1440
- const build = site.id === 'xiaomi-mimo' ? m.buildXiaomiMimoQuotaPayload
1441
- : site.id === 'river' ? m.buildRiverQuotaPayload
1442
- : site.id === 'longcat' ? m.buildLongcatQuotaPayload : m.buildModelScopeQuotaPayload;
1443
- return build(a).source;
1444
- } catch { return null; }
1445
- };
1446
- const results = [];
1447
- for (const a of targets) {
1448
- const touched = await sourceOf(a);
1449
- if (touched) touchedSources.add(touched);
1450
- const row = await reprobeCookieAccount(site, a);
1451
- results.push(row);
1452
- }
1453
- // 重探后只同步涉及的额度卡(label/数字都可能变;失败行也已被纳入 touchedSources)
1454
- if (touchedSources.size) tenrouter.pushQuotaCards({ onlySources: [...touchedSources] }).catch(() => {});
1455
- return json(res, 200, { results });
1456
- }
1457
-
1458
- // ── 本机检测 / 凭据扫描 ──
1459
- if (p === '/api/local/detect' && method === 'GET') {
1460
- return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
1461
- }
1462
- if (p === '/api/local/scan' && method === 'POST') {
1463
- const existing = await loadAccounts();
1464
- const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
1465
- && (a.token === token || (uid && a.uid === uid)));
1466
- const candidates = [];
1467
- const errors = [];
1468
- const addRecord = (record, extra) => {
1469
- candidates.push({
1470
- id: putCandidate({ token: record.token, record }),
1471
- kind: 'app', provider: record.provider,
1472
- name: record.name, email: record.email, uid: record.uid, phone: record.meta?.phone ? record.meta.phone.slice(0, 3) + '****' + record.meta.phone.slice(-4) : null,
1473
- tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
1474
- expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
1475
- imported: known(record.provider, record.token, record.uid),
1476
- ...extra,
1477
- });
1478
- };
1479
- // 1) Qoder 客户端:解密 auth.v1.dat
1480
- const app = await readQoderAppAccounts();
1481
- errors.push(...app.errors);
1482
- for (const c of app.accounts) {
1483
- addRecord({
1484
- provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
1485
- refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
1486
- // 存整份解密后的 auth.v1.dat:切换时要原样写回(里面除 token 还有客户端读的其它字段)
1487
- meta: { qoderAuth: c.authJson, qoderAuthFile: c.file },
1488
- }, { source: c.source });
1489
- }
1490
- // 2) WorkBuddy 客户端:当前会话 + 历史会话
1491
- const wb = readWorkbuddySessions();
1492
- errors.push(...wb.errors);
1493
- for (const c of wb.accounts) {
1494
- const { file: _f, fileTime: _t, current, ...record } = c;
1495
- addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
1496
- }
1497
- // 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
1498
- try {
1499
- const z = zcodeLiveAccount();
1500
- if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
1501
- } catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
1502
- // 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
1503
- try {
1504
- const m = await mirasimLiveAccount();
1505
- if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
1506
- } catch (e) {
1507
- // MISSING_SECRET_KEY / DECRYPT_FAILED 的文案已在 mirasimLocal 里写清检查过的位置
1508
- errors.push({ file: '~/.mirasim/setting.json', error: e.message });
1509
- }
1510
- // 5) 妙手客户端:解密 %APPDATA%\catpaw-moon\catx-credential.json 的当前登录
1511
- try {
1512
- const cp = await catpawLiveAccount();
1513
- if (cp) addRecord(cp, { source: '妙手当前登录', current: true });
1514
- } catch (e) { errors.push({ file: 'catpaw-moon/catx-credential.json', error: e.message }); }
1515
- // 6) Trae 客户端:解密 <userData>\User\globalStorage\storage.json 的 tc 信封当前登录
1516
- try {
1517
- const tr = await traeLiveAccount();
1518
- // 国际版客户端(信封 host 为 *.trae.ai):CD 的 Trae 线只打 api.trae.cn,导入只会有
1519
- // 一张空额度卡——照常列出让用户可见,但带 intlWarning 由前端明确提示,不静默。
1520
- if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true, ...(tr.meta?.isIntl ? { intlWarning: '检测到 Trae 国际版客户端(trae.ai)——CreditDaddy 的 Trae 线仅支持国内版(api.trae.cn),导入后额度与签到不可用' } : {}) });
1521
- } catch (e) { errors.push({ file: 'TRAE/User/globalStorage/storage.json', error: e.message }); }
1522
- // 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
1523
- try {
1524
- const mm = await minimaxLiveAccount();
1525
- if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
1526
- } catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
1527
- // 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
1528
- const det = detectInstalls();
1529
- const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
1530
- if (det.cliDir.exists) dirs.push(det.cliDir.path);
1531
- const legacy = await scanLocalTokens(dirs);
1532
- for (const c of legacy.candidates) {
1533
- candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
1534
- }
1535
- return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
1536
- }
1537
- if (p === '/api/local/import' && method === 'POST') {
1538
- const body = await readBody(req);
1539
- const cand = peekCandidate(String(body?.candidateId || ''));
1540
- if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
1541
- const record = cand.record || {
1542
- provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
1543
- token: cand.token, source: 'local-scan',
1544
- };
1545
- if (record.provider?.startsWith('workbuddy') && record.refreshToken) {
1546
- try {
1547
- const fresh = await refreshWorkbuddyToken(record);
1548
- Object.assign(record, { token: fresh.token, refreshToken: fresh.refreshToken, expiresAt: fresh.expiresAt });
1549
- } catch (e) {
1550
- if (e.authInvalid) return json(res, 409, { error: e.message });
1551
- return json(res, 502, { error: `WorkBuddy 本机凭据校验失败:${e.message}` });
1552
- }
1553
- }
1554
- const { account, duplicate, updated } = await addAccount(
1555
- { ...record, name: body.name || record.name || null },
1556
- { trusted: Boolean(cand.record) },
1557
- );
1558
- if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
1559
- // Trae 的登录态是 15 项文件快照而非单个凭据文件,导入时就建一次快照,否则该账号不可切换
1560
- if (account.provider === 'trae') traeSnapshotLive();
1561
- logger.info('DAEMON', updated ? '已用本机凭据更新:{name}' : '从本机导入账号:{name}', { name: account.name || account.id });
1562
- return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
1563
- }
1564
-
1565
- // ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
1566
- if (p === '/api/tenrouter' && method === 'GET') {
1567
- return json(res, 200, await tenrouter.publicConfig());
1568
- }
1569
- if (p === '/api/tenrouter' && method === 'PUT') {
1570
- const body = await readBody(req).catch(() => ({}));
1571
- try {
1572
- await tenrouter.updateConfig({
1573
- endpoint: body?.endpoint,
1574
- key: body?.key,
1575
- adminPassword: body?.adminPassword,
1576
- syncEnabled: body?.syncEnabled,
1577
- sources: body?.sources,
1578
- quotaPushEnabled: body?.quotaPushEnabled,
1579
- quotaPushIntervalMin: body?.quotaPushIntervalMin,
1580
- accountSyncEnabled: body?.accountSyncEnabled,
1581
- accountSyncProviders: body?.accountSyncProviders,
1582
- quotaPushDisabledProviders: body?.quotaPushDisabledProviders,
1583
- });
1584
- return json(res, 200, await tenrouter.publicConfig());
1585
- } catch (e) { return json(res, 400, { error: e.message }); }
1586
- }
1587
- if (p === '/api/tenrouter' && method === 'DELETE') {
1588
- await tenrouter.updateConfig({ endpoint: '' });
1589
- return json(res, 200, await tenrouter.publicConfig());
1590
- }
1591
- if (p === '/api/tenrouter/test' && method === 'POST') {
1592
- const body = await readBody(req).catch(() => ({}));
1593
- let override;
1594
- try {
1595
- // 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
1596
- if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
1597
- if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
1598
- } catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
1599
- return json(res, 200, await tenrouter.testConnection(override));
1600
- }
1601
- if (p === '/api/tenrouter/quotas' && method === 'GET') {
1602
- try {
1603
- const force = url.searchParams.get('force') === '1';
1604
- // 用户主动刷新额度时(force=1):先主动向 10Router 推一轮最新免费额度卡,再拉取全量额度
1605
- if (force) {
1606
- await tenrouter.pushQuotaCards().catch(() => {});
1607
- }
1608
- return json(res, 200, await tenrouter.fetchQuotas({ force }));
1609
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
1610
- }
1611
- if (p === '/api/tenrouter/health' && method === 'GET') {
1612
- return json(res, 200, await tenrouter.fetchHealth());
1613
- }
1614
- if (p === '/api/tenrouter/sync' && method === 'POST') {
1615
- const body = await readBody(req).catch(() => ({}));
1616
- try {
1617
- return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
1618
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
1619
- }
1620
- if (p === '/api/tenrouter/sync-accounts' && method === 'POST') {
1621
- const body = await readBody(req).catch(() => ({}));
1622
- try {
1623
- // 请求里带了面板密码就先落盘(下次同步不必重填),否则用已保存的
1624
- if (typeof body?.adminPassword === 'string' && body.adminPassword) {
1625
- await tenrouter.updateConfig({ adminPassword: body.adminPassword });
1626
- }
1627
- const c = tenrouter.loadConfig();
1628
- // 渠道开关(账号管理弹窗勾选):providers 空 = 不限;否则只推勾选的 CD provider
1629
- const enabled = c.accountSync?.enabled !== false;
1630
- if (!enabled) {
1631
- return json(res, 200, { channel: 'disabled', results: [], summary: '账号同步已关闭(账号管理弹窗可重新开启)' });
1632
- }
1633
- const allow = Array.isArray(c.accountSync?.providers) ? c.accountSync.providers : [];
1634
- const accounts = await loadAccounts();
1635
- const filtered = allow.length ? accounts.filter((a) => allow.includes(a.provider)) : accounts;
1636
- return json(res, 200, await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: filtered }));
1637
- } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
1638
- }
1639
- // 额度卡推送(issue #24):手动触发一轮 external-quota 推送(面板「免费额度」页同步按钮附带)
1640
- if (p === '/api/tenrouter/push-quota' && method === 'POST') {
1641
- try {
1642
- return json(res, 200, await tenrouter.pushQuotaCards());
1643
- } catch (e) { return json(res, 500, { error: e.message, code: e.code }); }
1644
- }
1645
-
1646
- // Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
1647
- if (p === '/api/qoder/umid' && method === 'GET') {
1648
- return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
1649
- }
1650
- if (p === '/api/qoder/umid/install' && method === 'POST') {
1651
- try {
1652
- await installUmid();
1653
- return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
1654
- } catch (e) { return json(res, 500, { error: e.message }); }
1655
- }
1656
-
1657
- // 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
1658
- if (p === '/api/logs' && method === 'GET') {
1659
- const tag = url.searchParams.get('tag') || null;
1660
- // ?history=1:读当日结构化归档(.jsonl),突破环形缓冲 300 条上限,按当前语言重渲染
1661
- if (url.searchParams.get('history') === '1') {
1662
- let lines = readArchiveJsonl({ limit: 2000 });
1663
- lines = tag ? lines.filter((l) => l.tag === tag) : lines.filter((l) => !(l.tag || '').endsWith('-GW'));
1664
- return json(res, 200, { logs: lines });
1665
- }
1666
- return json(res, 200, { logs: getLogs(200, tag) });
1667
- }
1668
-
1669
- // 状态
1670
- if (p === '/api/status' && method === 'GET') {
1671
- const accounts = await loadAccounts();
1672
- const state = await loadState();
1673
- // Qoder 当前登录(面板轮询每 30s 打一次,DPAPI 解密结果缓存 1 分钟)
1674
- const qoder = await qoderCurrentInfo();
1675
- // 旧账号没赶上「整份 auth 快照」的导入:本机 Qoder 有登录且账号库里有缺快照的同 uid 账号时,顺手回填(可逆、只在缺失时发生)
1676
- const needBackfill = qoder.backfill.length
1677
- && accounts.some((a) => (a.provider === 'qoder' || a.provider === 'qoder-cn')
1678
- && a.uid && !a.meta?.qoderAuth
1679
- && qoder.backfill.some((b) => b.provider === a.provider && String(b.uid) === String(a.uid)));
1680
- if (needBackfill) {
1681
- await withAccounts((list) => {
1682
- for (const b of qoder.backfill) {
1683
- const cur = list.find((a) => a.provider === b.provider && a.uid && b.uid && String(a.uid) === String(b.uid) && !a.meta?.qoderAuth);
1684
- if (cur) cur.meta = { ...(cur.meta || {}), qoderAuth: b.authJson, qoderAuthFile: b.file };
1685
- }
1686
- }).catch((e) => logger.warn('DAEMON', '回填 Qoder 登录快照失败:{err}', { err: e.message }));
1687
- }
1688
- // 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
1689
- const cpToken = currentCatpawToken();
1690
- const mmToken = currentMiniMaxToken();
1691
- const mmRecKey = currentMiniMaxRecordKey();
1692
- // 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
1693
- let mmLiveUid = null;
1694
- if (mmToken) {
1695
- mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
1696
- }
1697
- if (!mmLiveUid && mmRecKey) {
1698
- // record-key 匹配可能命中多个(历史脏数据里本机导入与浏览器登录挂过同一个 key),
1699
- // 此时用 uid 缓存的当前值收敛,避免随机取到「不是客户端当前登录」的那个账号。
1700
- const byRec = accounts.filter((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey);
1701
- if (byRec.length === 1) mmLiveUid = byRec[0].uid;
1702
- else if (byRec.length > 1) {
1703
- const mmCachedUid = currentMiniMaxUid();
1704
- mmLiveUid = byRec.find((a) => a.uid === mmCachedUid)?.uid || null;
1705
- }
1706
- }
1707
- if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
1708
- const traeDet = detectTrae();
1709
- return json(res, 200, {
1710
- ok: true,
1711
- app: 'CreditDaddy',
1712
- version: APP_VERSION,
1713
- build: loadBuildInfo() || undefined, // #21 构建身份(打包产物才有;无文件不返回该键)
1714
- homepage: PROJECT_URL,
1715
- accountsCount: accounts.length,
1716
- dataDir: dataDir(),
1717
- today: dayKey(),
1718
- todayDone: state?.qoderDailyDone || {},
1719
- todayByProduct: {
1720
- qoder: dayKey(Date.now(), 'qoder'),
1721
- workbuddy: dayKey(Date.now(), 'workbuddy'),
1722
- trae: dayKey(Date.now(), 'trae'),
1723
- minimax: dayKey(Date.now(), 'minimax'),
1724
- },
1725
- scheduler: await getSchedulerInfo(),
1726
- riskIdentity: riskIdentityAvailable(),
1727
- riskSource: riskIdentitySource(),
1728
- umid: umidInfo(),
1729
- platform: process.platform,
1730
- workbuddyCurrentUid: currentWorkbuddyUid(),
1731
- workbuddyClient: (() => { const d = detectWorkbuddy(); return { installed: d.installed, signedIn: d.signedIn, running: false }; })(),
1732
- qoderCurrent: qoderCurrentIds(accounts, qoder),
1733
- qoderCurrentToken: qoder.tokens,
1734
- qoderCurrentUid: qoder.uids,
1735
- qoderClient: { installed: qoder.apps.some((x) => x.installed), signedIn: qoder.apps.some((x) => x.signedIn), running: qoderRunning() },
1736
- zcodeCurrentUid: currentZcodeUid(),
1737
- zcodeCurrentIdentity: currentZcodeIdentity(),
1738
- zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
1739
- mirasimCurrentUid: currentMirasimUid(),
1740
- mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1741
- catpawCurrentUid: (cpToken && accounts.find((a) => a.provider === 'catpaw' && a.token === cpToken)?.uid) || null,
1742
- catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1743
- traeCurrentUid: traeDet.uid,
1744
- traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
1745
- minimaxCurrentUid: mmLiveUid,
1746
- minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
1747
- keyRequired: panelKeyRequired(),
1748
- });
1749
- }
1750
-
1751
- // Panel 设置:GET 读取(只回状态不回显密码),PUT 设置 / 修改 / 关闭访问密码
1752
- if (p === '/api/settings' && method === 'GET') {
1753
- return json(res, 200, { panelKeyEnabled: panelKeyRequired(), locale: getLocale() });
1754
- }
1755
- if (p === '/api/settings' && method === 'PUT') {
1756
- const body = await readBody(req).catch(() => ({}));
1757
- if (body?.locale !== undefined) {
1758
- const locale = normalizeLocale(body.locale);
1759
- setLocale(locale);
1760
- await saveSettings({ locale });
1761
- }
1762
- // 全局自动签到 开关 / 频率(面板「设置 → 通用」)
1763
- if (typeof body?.autoCheckin === 'boolean') await saveSettings({ autoCheckin: body.autoCheckin });
1764
- if (body?.checkinIntervalMin !== undefined) {
1765
- const min = Math.max(15, Math.min(1440, Number(body.checkinIntervalMin) || 120));
1766
- await saveSettings({ checkinIntervalMin: min });
1767
- }
1768
- if (body?.autoCheckin !== undefined || body?.checkinIntervalMin !== undefined) {
1769
- await resyncAutoCheckinScheduler();
1770
- return json(res, 200, { ok: true });
1771
- }
1772
- // 只有携带密码字段才走密码分支:纯语言/其它设置的请求此前会掉进
1773
- // 「请提供新密码」400,面板 api() 抛错导致语言切换点了不刷新(i18n 切换失效根因)
1774
- if (body?.panelKey !== undefined || body?.disable === true) return await handleSettingsPut(res, body);
1775
- if (body?.locale !== undefined) return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired(), locale: getLocale() });
1776
- return json(res, 400, { error: '请求未携带任何设置字段' });
1777
- }
1778
-
1779
- // 导出(加密口令必填——账号含 token,不允许再导出明文文件)
1780
- if (p === '/api/export' && method === 'POST') {
1781
- const body = await readBody(req).catch(() => ({}));
1782
- const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
1783
- const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
1784
- const password = typeof body?.password === 'string' ? body.password : '';
1785
- // 日志只记范围与条数——口令、token 一概不落
1786
- const scope = provider ? (PROVIDER_LABEL[provider] || provider) : (product ? `${product}(全部版本)` : '全部账号');
1787
- if (password.length < 4) {
1788
- logger.warn('DAEMON', '导出被拒({scope}):未设置加密口令', { scope });
1789
- return json(res, 400, { error: '导出必须设置加密口令(至少 4 位)', code: 'PASSWORD_REQUIRED' });
1790
- }
1791
- try {
1792
- const accounts = await loadAccounts();
1793
- const blob = exportAccounts(accounts, { password, provider, product });
1794
- // 信封是密文、不带条数,用同参数重算一遍过滤结果记进日志
1795
- logger.info('DAEMON', '导出账号({scope}):{n} 个(密文口令导出)', { scope, n: buildExportPayload(accounts, { provider, product }).accounts.length });
1796
- return json(res, 200, blob);
1797
- } catch (e) {
1798
- if (e instanceof TransferError) {
1799
- logger.warn('DAEMON', '导出失败({scope}):{code}', { scope, code: e.code });
1800
- return json(res, 400, { error: e.message, code: e.code });
1801
- }
1802
- throw e;
1803
- }
1804
- }
1805
-
1806
- // 导入
1807
- if (p === '/api/import' && method === 'POST') {
1808
- const body = await readBody(req);
1809
- // 新格式 {data, password};兼容直接 POST 文件内容
1810
- const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
1811
- let parsed;
1812
- try {
1813
- parsed = parseImport(data, { password: body?.password });
1814
- } catch (e) {
1815
- if (e instanceof TransferError) {
1816
- logger.warn('DAEMON', '导入失败:{code}({message})', { code: e.code, message: e.message });
1817
- return json(res, 400, { error: e.message, code: e.code });
1818
- }
1819
- throw e;
1820
- }
1821
- const r = await importAccounts(parsed.accounts);
1822
- const skipped = r.skipped + parsed.skipped;
1823
- logger.info('DAEMON', '导入完成({source}):新增 {added},续期 {updated},跳过 {skipped}', { source: parsed.source, added: r.added, updated: r.updated, skipped });
1824
- return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
1825
- }
1826
-
1827
- // 重启服务(桌面客户端或 CLI)
1828
- if (p === '/api/system/restart' && method === 'POST') {
1829
- json(res, 200, { ok: true, message: '正在重启服务…' });
1830
- setTimeout(async () => {
1831
- try {
1832
- if (restartHandler) {
1833
- await restartHandler();
1834
- return;
1835
- }
1836
- // Node CLI 环境兜底重启:派生自身并退出当前进程
1837
- const { spawn } = await import('node:child_process');
1838
- spawn(process.execPath, process.argv.slice(1), {
1839
- detached: true,
1840
- stdio: 'inherit',
1841
- env: process.env,
1842
- }).unref();
1843
- process.exit(0);
1844
- } catch (err) {
1845
- logger.error('DAEMON', '自动重启失败: {err}', { err: err?.message || err });
1846
- }
1847
- }, 400);
1848
- return;
1849
- }
1850
-
1851
- return json(res, 404, { error: '未知接口' });
1852
- }
1853
-
1854
- let restartHandler = null;
1855
- /** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
1856
- export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
1857
-
1858
- /** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
1859
- export function getPanelKey() { return PANEL_KEY; }
1860
- export function getSessionKey() { return PANEL_SESSION; }
1861
- export function verifyPanelPassword(input) {
1862
- if (PANEL_KEY_MODE === 'hash') return scryptMatches(String(input), PANEL_KEY_HASH);
1863
- if (PANEL_KEY_MODE === 'plain') return keyMatches(String(input));
1864
- return false;
1865
- }
1866
-
1867
- /** 出口代理地址(面板「网络出口(全局)」里存的,环境变量不算——登录窗显式设置用) */
1868
- export function getLoginProxyUrl() { return proxyUrl() || null; }
1869
-
1870
- export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
1871
- await initPanelKey();
1872
- // 跨进程单实例守护(P0-3 层):daemon.json 记录 pid+port,端口探活信带——两个实例
1873
- // 共用同一 dataDir 时 accounts/settings/state.json 的后写会覆盖前写(进程内串行队列
1874
- // 不跨进程)。发现活实例直接拒启,不要悄悄抬到下一个端口共存。
1875
- {
1876
- const { statusRuntime } = await import('./bgdaemon.js');
1877
- const cur = await statusRuntime().catch(() => null);
1878
- if (cur && cur.running) {
1879
- logger.warn('DAEMON', 'CreditDaddy 守护进程已在运行(pid {pid},{host}:{port})—— 拒绝再起实例共用同一数据目录', { pid: cur.pid, host: cur.host, port: cur.port });
1880
- const err = new Error('CreditDaddy 守护进程已在运行(pid ' + cur.pid + ',' + (cur.host || '127.0.0.1') + ':' + cur.port + '),如需重启请先停止旧实例(CLI 可用 creditdaddy stop)');
1881
- err.code = 'ALREADY_RUNNING';
1882
- throw err;
1883
- }
1884
- }
1885
- // 一次性清理:1.3.3 曾短暂支持 minimax-intl(MiniMax 国际版),实测其 API 侧对国内出口
1886
- // 全链路 401(区域识别无法绕过),双版本策略已撤回——存量国际版账号移除,避免签到 /
1887
- // 额度轮询对未知 provider 反复报错。仅在真的命中存量时落盘。
1888
- {
1889
- const list = await loadAccounts();
1890
- const dead = list.filter((a) => a.provider === 'minimax-intl');
1891
- if (dead.length) {
1892
- await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'minimax-intl')); });
1893
- logger.info('DAEMON', '已移除 {count} 个 MiniMax 国际版账号(该渠道已下线,额度接口对国内网络不可用)', { count: dead.length });
1894
- }
1895
- }
1896
- // 一次性清理(issue #35):Inception Labs 线已整体删除(Cloudflare 对代理出口 /api/*
1897
- // 持续 429),存量账号留着只会被签到/额度轮询反复打上游(401/429 报错刷屏)。命中才落盘。
1898
- {
1899
- const list = await loadAccounts();
1900
- const dead = list.filter((a) => a.provider === 'inception');
1901
- if (dead.length) {
1902
- await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'inception')); });
1903
- logger.info('DAEMON', '已移除 {count} 个 Inception Labs 会话(该渠道已删除,网页会话线不可用)', { count: dead.length });
1904
- }
1905
- }
1906
- let panelHtml = '';
1907
- fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
1908
-
1909
- const server = http.createServer(async (req, res) => {
1910
- const url = new URL(req.url, 'http://localhost');
1911
- try {
1912
- const denied = rejectForeignRequest(req.headers, host, url.pathname);
1913
- if (denied) {
1914
- 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 || '' });
1915
- return json(res, 403, { error: denied });
1916
- }
1917
- // ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
1918
- // 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
1919
- // gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
1920
- // 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
1921
- // 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
1922
- if (
1923
- url.pathname === '/gateway/zcode/v1/messages'
1924
- || url.pathname === '/gateway/v1/messages'
1925
- || url.pathname === '/v1/messages'
1926
- ) {
1927
- return await zcodeGateway.handleGateway(req, res);
1928
- }
1929
- // MiniMax Code 本地 Anthropic 兼容网关
1930
- if (url.pathname === '/gateway/minimax/v1/messages') {
1931
- return await minimaxGateway.handleGateway(req, res);
1932
- }
1933
- // Trae SOLO 本地 Anthropic 兼容网关
1934
- if (url.pathname === '/gateway/trae/v1/messages') {
1935
- return await traeGateway.handleGateway(req, res);
1936
- }
1937
- // 10Router 外配额卡「刷新配额」回调(自鉴权:HMAC 签名,不走面板密码门;浏览器直连需 CORS)
1938
- if (url.pathname === '/api/quota-refresh') {
1939
- res.setHeader('Access-Control-Allow-Origin', '*');
1940
- res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
1941
- res.setHeader('Access-Control-Allow-Headers', 'content-type');
1942
- if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
1943
- if (req.method !== 'POST') return json(res, 405, { error: '仅支持 POST' });
1944
- const remote = remoteKeyAddr(req);
1945
- const { verifyQuotaRefresh, logRefresh, refreshRateLimited } = await import('./quotaRefresh.js');
1946
- if (refreshRateLimited(remote)) {
1947
- logger.warn('10R', '外配额刷新回调限流 {remote}', { remote });
1948
- return json(res, 429, { error: '刷新过于频繁,请稍后再试' });
1949
- }
1950
- const v = await verifyQuotaRefresh({
1951
- source: url.searchParams.get('source'),
1952
- exp: url.searchParams.get('exp'),
1953
- sig: url.searchParams.get('sig'),
1954
- });
1955
- if (!v.ok) {
1956
- logger.warn('10R', '外配额刷新回调签名无效({reason},source={source})', { reason: v.reason || '?', source: url.searchParams.get('source') || '—' });
1957
- return json(res, 401, { error: '签名校验失败' });
1958
- }
1959
- const source = String(url.searchParams.get('source'));
1960
- logRefresh(source);
1961
- // 只推这一路 source:现拉余额 → 推卡(载荷重新签发新 refreshUrl,签名滚动)→ 返回结果
1962
- const { runQuotaPush } = await import('./externalQuota.js');
1963
- const cfg = tenrouter.loadConfig();
1964
- if (!cfg.endpoint || !cfg.key) return json(res, 503, { error: '未配置 10Router 地址与虚拟 key' });
1965
- // 已排除的渠道不回源(10R 端不该再留着这张卡——留给用户侧删卡;这里只拒绝刷新)
1966
- const disabled = Array.isArray(cfg.quotaPushDisabledProviders) ? cfg.quotaPushDisabledProviders : [];
1967
- const sourceFamily = /^(minimax-free|xiaomi-mimo|river|modelscope|longcat|tokenharbor)-/.exec(source);
1968
- const familyProvider = sourceFamily ? sourceFamily[1] : (source === 'tokenharbor' ? 'tokenharbor' : null);
1969
- if (familyProvider && disabled.includes(familyProvider)) {
1970
- return json(res, 410, { error: '该渠道的额度卡推送已关闭(CreditDaddy 账号管理弹窗可重新开启)' });
1971
- }
1972
- const accounts = await loadAccounts();
1973
- const r = await runQuotaPush({ endpoint: cfg.endpoint, key: cfg.key, accounts, fetchImpl: undefined, onlySource: source, noProbeMark: true });
1974
- return json(res, 200, { ok: true, results: r.results });
1975
- }
1976
- if (url.pathname.startsWith('/api/')) {
1977
- return await handleApi(req, res, url);
1978
- }
1979
- // Web 面板(未设密码时同 API 口径 fail-closed)
1980
- if (url.pathname === '/' || url.pathname === '/index.html') {
1981
- if (!panelKeyRequired()) {
1982
- const remote = (req.socket && req.socket.remoteAddress) || '';
1983
- if (!isLoopbackSocketAddr(remote)) {
1984
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
1985
- return res.end('CreditDaddy:未设置面板访问密码时,面板仅允许本机访问——请在本机面板「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问');
1986
- }
1987
- }
1988
- if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
1989
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
1990
- return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
1991
- }
1992
- // 静态资产(仅捐赠二维码三张 SVG;白名单精确匹配,面板密码门与面板一致)
1993
- if (url.pathname.startsWith('/assets/donate-')) {
1994
- const file = {
1995
- '/assets/donate-alipay.svg': 'donate-alipay.svg', '/assets/donate-ifdian.svg': 'donate-ifdian.svg', '/assets/donate-wechat.svg': 'donate-wechat.svg',
1996
- }[url.pathname];
1997
- if (!file) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
1998
- if (!panelKeyRequired()) {
1999
- const remote = (req.socket && req.socket.remoteAddress) || '';
2000
- if (!isLoopbackSocketAddr(remote)) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Forbidden'); }
2001
- }
2002
- try {
2003
- const svg = await fs.readFile(path.join(__dirname, 'assets', file));
2004
- res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' });
2005
- return res.end(svg);
2006
- } catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
2007
- }
2008
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
2009
- res.end('Not Found');
2010
- } catch (err) {
2011
- const status = err instanceof HttpError ? err.status : 500;
2012
- if (status >= 500) logger.error('DAEMON', '请求处理失败:{err}', { err: err?.message || err });
2013
- if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
2014
- }
2015
- });
2016
-
2017
- return new Promise((resolve, reject) => {
2018
- const startPort = pickBindablePort(Number(port) || DEFAULT_PORT);
2019
- let attempts = 0;
2020
- let triedFallback = false;
2021
- const bind = (p) => {
2022
- // 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
2023
- const onError = (err) => {
2024
- server.off('listening', onListening);
2025
- const code = err && err.code;
2026
- // EACCES = 落进系统保留段(WSL2 镜像网络 / Hyper-V excludedportrange),EADDRINUSE = 被别的进程占
2027
- const blocked = code === 'EADDRINUSE' || code === 'EACCES';
2028
- if (blocked && attempts < 100 && !triedFallback) {
2029
- attempts += 1;
2030
- logger.warn('DAEMON', '端口 {port} 不可用({code}),改试 {next}', { port: p, code: code || '未知', next: p + 1 });
2031
- server.close();
2032
- setTimeout(() => bind(p + 1), 30);
2033
- } else if (blocked && !triedFallback) {
2034
- // 起始端口起整段都不可用(保留段异常大):回退 OS 随机空闲端口,端口记进 daemon.json
2035
- triedFallback = true;
2036
- logger.warn('DAEMON', '端口 {start}~{end} 均不可用,回退系统随机空闲端口', { start: startPort, end: p });
2037
- server.close();
2038
- setTimeout(() => bind(0), 30);
2039
- } else {
2040
- // 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
2041
- // 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
2042
- const msg = err?.message || String(err);
2043
- logger.error('DAEMON', '监听失败:{msg}', { msg });
2044
- reject(new Error(`无法监听 ${host}:${p}:${msg}`));
2045
- }
2046
- };
2047
- const onListening = () => {
2048
- server.off('error', onError);
2049
- const bound = server.address().port;
2050
- logger.info('DAEMON', 'CreditDaddy 守护进程已启动:http://{host}:{port}', { host, port: bound });
2051
- if ((host === '0.0.0.0' || host === '::') && !panelKeyRequired()) {
2052
- logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥', {});
2053
- }
2054
- resolve({ server, port: bound });
2055
- };
2056
- server.once('error', onError);
2057
- server.once('listening', onListening);
2058
- server.listen(p, host);
2059
- };
2060
- bind(startPort);
2061
- });
2062
- }
1
+ /**
2
+ * CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
3
+ *
4
+ * 架构参考 WorkDaddy:
5
+ * - 默认只监听 127.0.0.1,数据不出本机
6
+ * - 无依赖,Node 18+ 原生 http 模块
7
+ * - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
8
+ *
9
+ * API:
10
+ * GET /api/accounts 账号列表(脱敏)
11
+ * POST /api/accounts 添加账号 {name, provider, token}
12
+ * PATCH /api/accounts/:id 修改备注名 {name}
13
+ * DELETE /api/accounts/:id 删除账号
14
+ * POST /api/accounts/:id/checkin 手动为单个账号签到
15
+ * GET /api/accounts/:id/quota 查询积分(统一结构)
16
+ * POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
17
+ * GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
18
+ * POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
19
+ * GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
20
+ * POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
21
+ * POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai / minimax}
22
+ * POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
23
+ * GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
24
+ * POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
25
+ * POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
26
+ * GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
27
+ * POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
28
+ * GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
29
+ * GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
30
+ * POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
31
+ * POST /api/tenrouter/sync-accounts 把本机账号推送到 10Router 连接(OAuth 通道需已配面板密码)
32
+ * GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
33
+ * POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
34
+ * GET /api/logs 最近日志
35
+ * GET /api/status 守护进程状态
36
+ * GET /api/settings 面板设置(访问密码开关状态,不回显密码)
37
+ * PUT /api/settings 设置/修改/关闭面板访问密码 {panelKey? | disable?}
38
+ * POST /api/export 导出账号 {password, provider?}(口令必填,10router 兼容加密文件)
39
+ * POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
40
+ * GET / Web 面板
41
+ */
42
+
43
+ import http from 'node:http';
44
+ import crypto from 'node:crypto';
45
+ import os from 'node:os';
46
+ import fs from 'node:fs/promises';
47
+ import path from 'node:path';
48
+ import { execFileSync } from 'node:child_process';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { logger, getLogs, readArchiveJsonl } from './logger.js';
51
+ import {
52
+ loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings, normalizeAccountInput,
53
+ } from './store.js';
54
+ import { addAccount, importAccounts, refreshContext } from './accounts.js';
55
+ import { productImpl } from './providers.js';
56
+ import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid, detectWorkbuddy } from './workbuddyLocal.js';
57
+ import { refreshWorkbuddyToken } from './workbuddyClient.js';
58
+ import { loadBuildInfo } from './buildInfo.js';
59
+ import { COOKIE_SITES, getSite, verifySiteCookie, verifyProbeHtml, extractIdentity, extractQuotaSnapshot, identityHashOf, isAutoGeneratedName } from './cookieSites.js';
60
+ import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
61
+ import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
62
+ import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
63
+ import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
64
+ import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
65
+ import { liveToAccount as loomyLiveAccount, hardSwitchLoomy, currentLoomyUid, detectLoomy } from './loomyLocal.js';
66
+ import * as minimaxGateway from './minimaxGateway.js';
67
+ import * as traeGateway from './traeGateway.js';
68
+ import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
69
+ import { exportAccounts, buildExportPayload, parseImport, TransferError } from './transfer.js';
70
+ import { syncAccountsTo10r } from './tenrouterAccounts.js';
71
+ import { runCheckinTick, getSchedulerInfo, dayKey, enableZcodeAutoClaimWindow, refreshZcodeScheduler, pollZcodeNow, resyncAutoCheckinScheduler } from './checkin.js';
72
+ import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource, switchTo as qoderSwitchTo, qoderRunning, terminateQoder } from './qoderApp.js';
73
+ import { umidInfo, installUmid } from './qoderUmid.js';
74
+ import * as tenrouter from './tenrouter.js';
75
+ import * as zcodeGateway from './zcodeGateway.js';
76
+ import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
77
+ import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
78
+ import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
79
+ import { getLocale, setLocale, normalizeLocale, loadDict, t as tr } from './i18n.js';
80
+
81
+ const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax', 'loomy'];
82
+
83
+ /** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
84
+ * 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
85
+ * 保证安装向导密码在 NAS 部署里始终有效。设置后所有 /api/* 需要 x-qd-key 头;密码不回显。 */
86
+ let PANEL_KEY = '';
87
+ let PANEL_KEY_HASH = null; // 'scrypt:<salt_hex>:<hash_hex>' — 面板密码只存这个,不存明文
88
+ let PANEL_SESSION = null; // 进程级会话钥(重启随机):桌面壳寄主的 loopback 凭证,外部没人知道
89
+ let PANEL_KEY_MODE = 'none'; // 'plain' | 'hash' | 'none'
90
+
91
+ function scryptPanelKey(peer) {
92
+ const salt = crypto.randomBytes(8);
93
+ const h = crypto.scryptSync(String(peer), salt, 32);
94
+ return `scrypt:${salt.toString('hex')}:${h.toString('hex')}`;
95
+ }
96
+ function scryptMatches(peer, stored) {
97
+ try {
98
+ const [algo, saltHex, hashHex] = String(stored).split(':');
99
+ if (algo !== 'scrypt' || !saltHex || !hashHex) return false;
100
+ const h = crypto.scryptSync(String(peer), Buffer.from(saltHex, 'hex'), 32);
101
+ return crypto.timingSafeEqual(h, Buffer.from(hashHex, 'hex'));
102
+ } catch { return false; }
103
+ }
104
+
105
+ /** 面板密码是否生效(哈希 / 明文 / 环境变量任一路径都算)—— PANEL_KEY 字符串本身在哈希模式下为空。 */
106
+ export function panelKeyRequired() { return PANEL_KEY_MODE !== 'none'; }
107
+
108
+ async function initPanelKey() {
109
+ let s = {};
110
+ try { s = await loadSettings(); } catch {}
111
+ const env = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
112
+ // 存储面板密码只存 scrypt 哈希——此前的明文 panelKey 见了顺手升级(写为哈希并清除明文)。
113
+ if (typeof s.panelKeyHash === 'string' && s.panelKeyHash.startsWith('scrypt:')) {
114
+ PANEL_KEY_HASH = s.panelKeyHash;
115
+ PANEL_KEY_MODE = 'hash';
116
+ PANEL_KEY = '';
117
+ if (s.panelKey) await saveSettings({ panelKey: '' });
118
+ } else {
119
+ const plain = typeof s.panelKey === 'string' && s.panelKey ? s.panelKey : '';
120
+ if (plain) {
121
+ PANEL_KEY_HASH = scryptPanelKey(plain);
122
+ PANEL_KEY_MODE = 'hash';
123
+ PANEL_KEY = '';
124
+ await saveSettings({ panelKeyHash: PANEL_KEY_HASH, panelKey: '' });
125
+ } else if (env) {
126
+ PANEL_KEY = env;
127
+ PANEL_KEY_HASH = null;
128
+ PANEL_KEY_MODE = 'plain'; // 环境变量来源的密码按现状走(部署侧管理)
129
+ } else {
130
+ PANEL_KEY = '';
131
+ PANEL_KEY_HASH = null;
132
+ PANEL_KEY_MODE = 'none';
133
+ }
134
+ }
135
+ PANEL_SESSION = crypto.randomBytes(16).toString('hex'); // 每次拉起重新随机(桌面上次用过期票不会误通行)
136
+ setLocale(s.locale);
137
+ }
138
+
139
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
140
+ const PANEL_FILE = path.join(__dirname, 'panel.html');
141
+ const DEFAULT_PORT = 47860;
142
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
143
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
144
+
145
+ /**
146
+ * Windows 系统保留端口段。WSL2 镜像网络模式会把一段端口圈进 Hyper-V 的
147
+ * excludedportrange(实测 47860~47870 落进去),bind 该段直接 EACCES(或 EADDRINUSE),
148
+ * 应用根本拿不到绑定权限。查询 netsh 拿到全量保留段供选端口避开。
149
+ * 非 Windows / 查询失败返回 [](退化为 +1 重试逃逸,见 bind)。
150
+ */
151
+ function windowsExcludedTcpRanges() {
152
+ if (process.platform !== 'win32') return [];
153
+ try {
154
+ const out = execFileSync('netsh', ['interface', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'],
155
+ { encoding: 'utf8', windowsHide: true, timeout: 3000 });
156
+ // 行形如「 47860 47870」;表头/分隔线无两列纯数字,自然跳过
157
+ return [...String(out).matchAll(/^\s*(\d+)\s+(\d+)\s*$/gm)].map((m) => [Number(m[1]), Number(m[2])]);
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 从 start 起挑第一个不在系统保留段内的端口——确定性选择,避免每次启动漂到不同端口
165
+ * (10Router 网关节点 / 书签硬编码了端口,漂了就断)。netsh 不可用时原样返回 start,
166
+ * 交给 bind 的 +1 重试逃逸。ranges 可注入(测试用),默认查 netsh。
167
+ */
168
+ export function pickBindablePort(start, ranges = windowsExcludedTcpRanges()) {
169
+ let p = Number(start) || DEFAULT_PORT;
170
+ for (let i = 0; i < 20000 && p <= 65535; i++, p++) {
171
+ if (!ranges.some(([a, b]) => p >= a && p <= b)) return p;
172
+ }
173
+ return Number(start) || DEFAULT_PORT;
174
+ }
175
+
176
+ // Qoder 当前登录缓存:面板 30s 一轮 status,DPAPI 解密结果没必要每轮重算
177
+ let qoderCurCache = { at: 0, data: null };
178
+ async function qoderCurrentInfo() {
179
+ if (qoderCurCache.data && Date.now() - qoderCurCache.at < 60_000) return qoderCurCache.data;
180
+ const data = { apps: detectQoderApps(), tokens: {}, uids: {}, backfill: [] };
181
+ try {
182
+ const r = await readQoderAppAccounts();
183
+ for (const c of r.accounts) {
184
+ // 当前登录以 token 为准:客户端静默刷新 / 重新登录后 token 会变而 uid 不变,
185
+ // 只比 uid 时「当前账号」与切换键状态会错位(用户现场:切完切换键没了)
186
+ data.tokens[c.provider] = c.token || null;
187
+ data.uids[c.provider] = c.user.id || null;
188
+ if (c.user.id) data.backfill.push({ provider: c.provider, uid: c.user.id, authJson: c.authJson, file: c.file });
189
+ }
190
+ } catch { /* 解密失败只留检测信息 */ }
191
+ qoderCurCache = { at: Date.now(), data };
192
+ return data;
193
+ }
194
+
195
+ /**
196
+ * 账号库里哪个账号是本机 Qoder 当前登录:先按 token 精确对齐,token 没对上再退回 uid。
197
+ * 面板的「客户端当前」角标 / 切换按钮显隐都以这里为准(status.qoderCurrent 的取值)。
198
+ */
199
+ function qoderCurrentIds(accounts, qoder) {
200
+ const ids = {};
201
+ for (const provider of ['qoder', 'qoder-cn']) {
202
+ const token = qoder.tokens?.[provider];
203
+ const uid = qoder.uids?.[provider];
204
+ const sameProvider = accounts.filter((a) => a.provider === provider);
205
+ const byToken = token ? sameProvider.find((a) => a.token === token) : null;
206
+ const hit = byToken || (uid ? sameProvider.find((a) => a.uid && String(a.uid) === String(uid)) : null);
207
+ ids[provider] = hit?.id || null;
208
+ }
209
+ return ids;
210
+ }
211
+
212
+ class HttpError extends Error {
213
+ constructor(status, message) { super(message); this.status = status; }
214
+ }
215
+
216
+ function json(res, code, obj) {
217
+ const body = JSON.stringify(obj);
218
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
219
+ res.end(body);
220
+ }
221
+
222
+ /**
223
+ * 自动行名守卫:只有「用户没手改过(无 meta.renamed)且现名仍在自动名家族」才允许覆盖。
224
+ * 两道闸缺一不可——renamed 标记护新改名,家族判定护历史改名(旧版改名没有标记)。
225
+ */
226
+ function canAutoRename(site, row) {
227
+ return !row.meta?.renamed && isAutoGeneratedName(site, row.name);
228
+ }
229
+
230
+ /**
231
+ * 单账号就地重探:用已存会话 cookie live 拉「身份+余额」→ 回填 meta → 探活痕迹(lastProbe)
232
+ * 一并落盘。重探路由(POST /api/cookie-sites/:id/reprobe)与 /api/accounts/:id/quota 的
233
+ * 网页会话分流共用——后者原直闯 PRODUCTS 表拿 undefined 崩 502,lastProbe 不更新,
234
+ * 呼吸灯「只有点重探才会变」的根因。
235
+ * @returns {Promise<{id, name, ok, identity?, balance?, today?, long?, error?, quotaSnapshot?}>}
236
+ */
237
+ async function reprobeCookieAccount(site, a) {
238
+ const now = new Date().toISOString();
239
+ const cookie = String(a.meta?.cookieSiteSession?.cookie || a.token || '').trim();
240
+ const finish = async (row) => {
241
+ // 每行都落探活痕迹(GET /api/cookie-sites 回显 lastProbe → 面板呼吸灯三态);
242
+ // 401 类失效红 / 其他失败黄 / 成功绿。await 串行队列排空,防调用方直改文件竞态。
243
+ const status = row.ok ? 'ok' : (/会话失效|401|403|会话不可用|重新登录/i.test(String(row.error || '')) ? 'dead' : 'warn');
244
+ await withAccounts((list) => {
245
+ const hit = list.find((x) => x.id === a.id);
246
+ if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: Boolean(row.ok), status, error: row.ok ? null : String(row.error || '').slice(0, 200), at: now } };
247
+ }).catch(() => {});
248
+ if (!row.ok) {
249
+ // 失败落运行日志(只 toast 一闪的话日志时间线无迹可查)
250
+ logger.warn('COOKIE-SITE', '{name} 就地重探失败({account}):{err}', { name: site.name, account: row.name || a.id, err: row.error || '未知原因' });
251
+ }
252
+ return row;
253
+ };
254
+ if (!cookie) return finish({ id: a.id, name: a.name, ok: false, error: '未存会话 cookie,请重新登录捕获' });
255
+ try {
256
+ if (site.id === 'modelscope') {
257
+ const { probeMagicubeSession } = await import('./modelscopeClient.js');
258
+ const probe = await probeMagicubeSession(cookie);
259
+ if (!probe.ok) return finish({ id: a.id, name: a.name, ok: false, error: probe.error || '探测失败,请稍后再试或重新登录捕获' });
260
+ const ih = identityHashOf(probe.msSession?.id || probe.identity);
261
+ await withAccounts((list) => {
262
+ const hit = list.find((x) => x.id === a.id);
263
+ if (!hit) return;
264
+ hit.meta = {
265
+ ...(hit.meta || {}),
266
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
267
+ ...(ih ? { identityHash: ih } : {}),
268
+ msSession: probe.msSession,
269
+ msBalance: probe.msBalance,
270
+ quotaSnapshot: { used: 0, total: probe.balance, isCreditBalance: true, at: now },
271
+ };
272
+ if (probe.msSession?.id) hit.uid = String(probe.msSession.id);
273
+ if (canAutoRename(site, hit) && probe.identity) hit.name = `ModelScope (${probe.identity})`;
274
+ hit.updatedAt = now;
275
+ });
276
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} 个,身份 {identity})', { name: site.name, balance: probe.balance, identity: probe.identity || '—' });
277
+ 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 } });
278
+ }
279
+ if (site.id === 'longcat') {
280
+ // LongCat(issue #29):资源包 summary API 即探测(cookie 直连),重探刷新全部 ACTIVE 包快照
281
+ const { fetchLongcatPacks, longcatSnapshot, fetchLongcatIdentity } = await import('./longcatClient.js');
282
+ const packs = await fetchLongcatPacks(cookie);
283
+ if (!packs.ok) {
284
+ return finish({ id: a.id, name: a.name, ok: false, error: packs.authExpired ? (packs.error || '会话失效') : (packs.error || '资源包探测失败') });
285
+ }
286
+ const lcTotal = packs.packs.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
287
+ const ident = await fetchLongcatIdentity(cookie).catch(() => null);
288
+ const lcSession = ident?.ok ? ident.session : null;
289
+ await withAccounts((list) => {
290
+ const hit = list.find((x) => x.id === a.id);
291
+ if (!hit) return;
292
+ hit.meta = {
293
+ ...(hit.meta || {}),
294
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
295
+ longcatPacks: longcatSnapshot(packs.packs),
296
+ ...(lcSession ? { longcatSession: lcSession } : {}),
297
+ quotaSnapshot: { used: 0, total: lcTotal, isCreditBalance: true, at: now },
298
+ };
299
+ if (lcSession?.userId) hit.uid = String(lcSession.userId);
300
+ const nameStr = lcSession?.email || lcSession?.name || lcSession?.userId || null;
301
+ if (canAutoRename(site, hit) && nameStr) hit.name = `LongCat (${nameStr})`;
302
+ hit.updatedAt = now;
303
+ });
304
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: packs.packs.length, identity: lcSession?.email || lcSession?.userId || '—' });
305
+ 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 } });
306
+ }
307
+ if (site.id === 'xiaomi-mimo') {
308
+ const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
309
+ const prof = await fetchXiaomiMimoProfile(cookie);
310
+ if (!prof.ok || !prof.userId) return finish({ id: a.id, name: a.name, ok: false, error: prof.error || '资料探测失败' });
311
+ const bal = await fetchXiaomiMimoBalance(cookie, prof.userId);
312
+ const mimoBalance = bal.ok ? { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now } : (a.meta?.mimoBalance || null);
313
+ const accName = `Xiaomi MiMo (UID: ${prof.userId})`;
314
+ await withAccounts((list) => {
315
+ const hit = list.find((x) => x.id === a.id);
316
+ if (!hit) return;
317
+ hit.uid = prof.userId;
318
+ if (canAutoRename(site, hit)) hit.name = accName;
319
+ hit.meta = {
320
+ ...(hit.meta || {}),
321
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
322
+ userId: prof.userId,
323
+ ...(mimoBalance ? { mimoBalance } : {}),
324
+ ...(bal.ok ? { quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now } } : {}),
325
+ };
326
+ hit.updatedAt = now;
327
+ });
328
+ logger.info('COOKIE-SITE', '{name} 就地重探通过(UID {uid},余额 {balance})', { name: site.name, uid: prof.userId, balance: bal.ok ? bal.balance : '—' });
329
+ 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 });
330
+ }
331
+ if (site.id === 'river') {
332
+ const { fetchRiverBalance } = await import('./riverClient.js');
333
+ const bal = await fetchRiverBalance(a, {
334
+ log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
335
+ });
336
+ if (!bal.ok) return finish({ id: a.id, name: a.name, ok: false, error: bal.error || '余额探测失败' });
337
+ await withAccounts((list) => {
338
+ const hit = list.find((x) => x.id === a.id);
339
+ if (!hit) return;
340
+ const sess = hit.meta?.workosSession || {};
341
+ if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落盘(单次轮换)
342
+ hit.meta = {
343
+ ...(hit.meta || {}),
344
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
345
+ ...(sess.accessToken || sess.cookie ? { workosSession: sess } : {}),
346
+ riverBalance: { balanceUsd: bal.balance, at: now },
347
+ quotaSnapshot: { used: 0, total: bal.balance, isCreditBalance: true, at: now },
348
+ };
349
+ const ident = sess.user?.email || sess.user?.id || null;
350
+ if (sess.user?.id) hit.uid = String(sess.user.id);
351
+ if (canAutoRename(site, hit) && ident) hit.name = `River AI (${ident})`;
352
+ hit.updatedAt = now;
353
+ });
354
+ logger.info('COOKIE-SITE', '{name} 就地重探通过({balance} USD)', { name: site.name, balance: bal.balance });
355
+ 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 } });
356
+ }
357
+ if (site.id === 'doubao') {
358
+ // 豆包(issue #32):detail 接口一次拿全(会话有效性 + 签到状态 + 累计点数),
359
+ // 重探 = 刷新额度快照;无 10R 同步(豆包不推外部配额卡,错误文案不再误导)
360
+ const { fetchDoubaoStatus } = await import('./doubaoClient.js');
361
+ const st = await fetchDoubaoStatus(cookie, a.uid || '').catch((e) => ({ auth: false, code: e?.message || 'network' }));
362
+ if (!st.auth) return finish({ id: a.id, name: a.name, ok: false, error: '豆包会话已失效,请重新登录捕获' });
363
+ await withAccounts((list) => {
364
+ const hit = list.find((x) => x.id === a.id);
365
+ if (!hit) return;
366
+ hit.meta = {
367
+ ...(hit.meta || {}),
368
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
369
+ quotaSnapshot: { used: 0, total: st.totalAmount, isCreditBalance: true, at: now },
370
+ };
371
+ hit.updatedAt = now;
372
+ });
373
+ logger.info('COOKIE-SITE', '{name} 就地重探通过(累计 {amount} 点,签到{signed})', { name: site.name, amount: st.totalAmount, signed: st.checkin?.checkedIn ? '已签' : '未签' });
374
+ return finish({ id: a.id, name: a.name, ok: true, identity: null, balance: st.totalAmount, quotaSnapshot: { used: 0, total: st.totalAmount, isCreditBalance: true, at: now } });
375
+ }
376
+ if (site.verify === 'api' || site.probe) {
377
+ // tokenharbor 等 HTML 探针站点:探测 + 身份 + 读数一并刷新
378
+ const v = await verifySiteCookie(site, cookie);
379
+ if (!v.ok) return finish({ id: a.id, name: a.name, ok: false, error: v.reason === 'invalid' ? '会话已失效,请重新登录捕获' : '探测失败,请稍后再试或重新登录捕获' });
380
+ const identity = extractIdentity(site, v.html) || null;
381
+ const quota = extractQuotaSnapshot(site, v.html) || null;
382
+ const ih = identityHashOf(identity);
383
+ await withAccounts((list) => {
384
+ const hit = list.find((x) => x.id === a.id);
385
+ if (!hit) return;
386
+ hit.meta = {
387
+ ...(hit.meta || {}),
388
+ cookieSiteSession: { cookie, capturedAt: hit.meta?.cookieSiteSession?.capturedAt || now },
389
+ ...(ih ? { identityHash: ih } : {}),
390
+ ...(quota ? { quotaSnapshot: { ...quota, at: now } } : {}),
391
+ };
392
+ if (canAutoRename(site, hit)) hit.name = site.accountName + (identity ? `(${identity})` : '');
393
+ hit.updatedAt = now;
394
+ });
395
+ logger.info('COOKIE-SITE', '{name} 就地重探通过(身份 {identity})', { name: site.name, identity: identity || '—' });
396
+ return finish({ id: a.id, name: a.name, ok: true, identity, quotaSnapshot: quota || null });
397
+ }
398
+ return finish({ id: a.id, name: a.name, ok: false, error: '该站点不支持重探,请重新登录捕获' });
399
+ } catch (e) {
400
+ return finish({ id: a.id, name: a.name, ok: false, error: e.message });
401
+ }
402
+ }
403
+
404
+ async function readBody(req) {
405
+ const chunks = [];
406
+ let size = 0;
407
+ for await (const c of req) {
408
+ size += c.length;
409
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
410
+ chunks.push(c);
411
+ }
412
+ const raw = Buffer.concat(chunks).toString('utf8');
413
+ try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
414
+ }
415
+
416
+ /** 常量时间比较,避免通过响应时间逐字符猜出密钥;
417
+ * mode 'hash' → scrypt 推导比;
418
+ * mode 'plain' → 仅环境变量来源,按原常量时间比;
419
+ * mode 'none' → 永远 false(本函数不该进来) */
420
+ function keyMatches(given) {
421
+ const head = String(given);
422
+ if (PANEL_SESSION && head === PANEL_SESSION) return true; // 桌面寄主会话钥(重启随机,外部不可伪造)
423
+ if (PANEL_KEY_MODE === 'hash') return scryptMatches(head, PANEL_KEY_HASH);
424
+ const a = Buffer.from(head);
425
+ const b = Buffer.from(PANEL_KEY);
426
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
427
+ }
428
+
429
+ function hostnameOf(hostHeader) {
430
+ try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
431
+ }
432
+
433
+ /** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
434
+ let localHostCache = { at: 0, set: null };
435
+ function localHosts() {
436
+ const now = Date.now();
437
+ if (!localHostCache.set || now - localHostCache.at > 60_000) {
438
+ const s = new Set(LOOPBACK_HOSTS);
439
+ try { s.add(os.hostname().toLowerCase()); } catch {}
440
+ try {
441
+ for (const list of Object.values(os.networkInterfaces())) {
442
+ for (const it of list || []) {
443
+ if (!it?.address) continue;
444
+ const a = it.address.toLowerCase();
445
+ s.add(a);
446
+ if (a.includes(':')) s.add(`[${a}]`);
447
+ }
448
+ }
449
+ } catch {}
450
+ localHostCache = { at: now, set: s };
451
+ }
452
+ return localHostCache.set;
453
+ }
454
+
455
+ /**
456
+ * 浏览器侧防护,返回拒绝原因(null = 放行):
457
+ * - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
458
+ * · 回环监听:只允许 127.0.0.1 / localhost / ::1
459
+ * · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
460
+ * 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
461
+ * - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
462
+ * 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
463
+ * 例外:`/api/quota-refresh` 是 HMAC 自鉴权回调(10R 配额卡按钮浏览器直连,Origin 必然
464
+ * 是 10R 站点)——该路径豁免跨站检查,防伪交给签名 + 限流;Host 闸照常(防 DNS 重绑定)。
465
+ */
466
+ export function rejectForeignRequest(headers, bindHost, pathname) {
467
+ const host = headers.host || '';
468
+ const hostName = hostnameOf(host).toLowerCase();
469
+ const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
470
+ if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
471
+ return 'Host 不被允许';
472
+ }
473
+ if (!loopbackBind && !panelKeyRequired() && !localHosts().has(hostName)) {
474
+ return 'Host 不被允许';
475
+ }
476
+ const origin = headers.origin;
477
+ if (origin && pathname !== '/api/quota-refresh') {
478
+ let originHost = '';
479
+ try { originHost = new URL(origin).host; } catch {}
480
+ if (originHost !== host) return '跨站请求被拒绝';
481
+ }
482
+ return null;
483
+ }
484
+
485
+ /**
486
+ * PUT /api/settings 的实际处理(鉴权已在入口完成):
487
+ * { panelKey: '新密码' } 设置 / 修改面板访问密码(≥4 位、不含空白)
488
+ * { disable: true } 关闭面板访问密码(导出账号的加密口令不受影响,始终必填)
489
+ * 口令校验规则与导出加密一致(sealTransfer:至少 4 个字符)。
490
+ */
491
+ async function handleSettingsPut(res, body) {
492
+ if (body?.disable === true) {
493
+ // 明文与 scrypt 哈希一起清——漏清 panelKeyHash 的话 initPanelKey 重读又把模式拉回 hash,
494
+ // 「关闭」永不生效,还会误报成「部署环境强制注入」。
495
+ await saveSettings({ panelKey: '', panelKeyHash: '' });
496
+ await initPanelKey(); // 环境变量部署(fnOS)下关闭后仍保留安装向导密码
497
+ logger.warn('DAEMON', panelKeyRequired() ? '面板访问密码已在设置里关闭,但部署环境仍注入密码(保持开启)' : '面板访问密码已关闭(导出账号仍需加密口令)', {});
498
+ return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
499
+ }
500
+ const next = typeof body?.panelKey === 'string' ? body.panelKey.trim() : '';
501
+ if (!next) return json(res, 400, { error: '请提供新密码,或提交 disable 关闭' });
502
+ if (next.length < 4) return json(res, 400, { error: '面板访问密码至少 4 位' });
503
+ if (/\s/.test(next)) return json(res, 400, { error: '面板访问密码不能包含空格' });
504
+ // 只存 scrypt 哈希,明文设置完立刻请出数据目录
505
+ await saveSettings({ panelKeyHash: scryptPanelKey(next), panelKey: '' });
506
+ await initPanelKey();
507
+ logger.info('DAEMON', '面板访问密码已更新(scrypt 哈希落盘)', {});
508
+ return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired() });
509
+ }
510
+
511
+ // socket 地址级回环判定(不是 hostname):PANEL_KEY 未设时 fail-closed 的关键输入
512
+ function isLoopbackSocketAddr(remote) {
513
+ const r = String(remote || '');
514
+ return !r || /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/.test(r);
515
+ }
516
+
517
+ // PANEL_KEY 按源 IP 失败计数限速:401 暴力猜密是免费的网络噪音――连错 10 次锁 5 分钟。
518
+ // timingSafeEqual 防了侧信道,防不了离线字典速度撞库;失败计数只留进程内,重启归零。
519
+ const keyFails = new Map(); // socketRemote → { n, lockedUntil }
520
+ const KEY_FAIL_LIMIT = 10;
521
+ const KEY_FAIL_LOCK_MS = 5 * 60_000;
522
+ const KEY_FAIL_SWEEP_MS = 10 * 60_000;
523
+
524
+ function remoteKeyAddr(req) { return String((req.socket && req.socket.remoteAddress) || ''); }
525
+ function keyThrottled(remote) {
526
+ const rec = keyFails.get(remote);
527
+ if (!rec) return false;
528
+ if (rec.lockedUntil && Date.now() < rec.lockedUntil) return true;
529
+ if (rec.lockedUntil) keyFails.delete(remote);
530
+ return false;
531
+ }
532
+ function noteKeyMiss(remote) {
533
+ // 顺手清扫:无锁且 10 分钟没再失败的来源直接清掉——0.0.0.0 部署时全网段扫描器
534
+ // 每个来源都会留一条记录,进程不重启就永不回收
535
+ const now = Date.now();
536
+ for (const [k, v] of keyFails) {
537
+ if (!v.lockedUntil && now - (v.at || 0) > KEY_FAIL_SWEEP_MS) keyFails.delete(k);
538
+ }
539
+ const rec = keyFails.get(remote) || { n: 0 };
540
+ rec.n += 1;
541
+ rec.at = now;
542
+ if (rec.n >= KEY_FAIL_LIMIT) { rec.lockedUntil = Date.now() + KEY_FAIL_LOCK_MS; rec.n = 0;
543
+ logger.warn('DAEMON', '面板密码连续 {n} 次错误,来源 {remote} 已限流 5 分钟', { n: KEY_FAIL_LIMIT, remote }); }
544
+ keyFails.set(remote, rec);
545
+ }
546
+
547
+ async function handleApi(req, res, url) {
548
+ if (!panelKeyRequired()) {
549
+ const remote = (req.socket && req.socket.remoteAddress) || '';
550
+ if (!isLoopbackSocketAddr(remote)) {
551
+ logger.warn('DAEMON', '拒绝非本机请求(未设面板访问密码):{method} {path} 来自 {remote}', { method: req.method, path: url.pathname, remote });
552
+ return json(res, 403, { error: '未设置面板访问密码时,面板账号接口仅允许本机访问——请在「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问' });
553
+ }
554
+ }
555
+ if (panelKeyRequired()) {
556
+ const remote = remoteKeyAddr(req);
557
+ if (keyThrottled(remote)) {
558
+ return json(res, 429, { error: '面板密码连续错误过多,已限流 5 分钟' });
559
+ }
560
+ // 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
561
+ const key = req.headers['x-qd-key'] || '';
562
+ if (!keyMatches(key)) {
563
+ noteKeyMiss(remote);
564
+ return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
565
+ }
566
+ keyFails.delete(remote); // 猜对了立即清零
567
+ }
568
+ const p = url.pathname;
569
+ const method = req.method;
570
+
571
+ // 账号列表
572
+ if (p === '/api/accounts' && method === 'GET') {
573
+ const accounts = await loadAccounts();
574
+ return json(res, 200, {
575
+ accounts: accounts.map(publicAccount),
576
+ providers: PROVIDER_LABEL,
577
+ dataDir: dataDir(),
578
+ });
579
+ }
580
+
581
+ // 添加账号(自动拉取 userinfo 补全名字)
582
+ if (p === '/api/accounts' && method === 'POST') {
583
+ const body = await readBody(req);
584
+ let result;
585
+ try {
586
+ result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
587
+ } catch (e) { return json(res, 400, { error: e.message }); }
588
+ const { account, duplicate } = result;
589
+ if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
590
+ if (account.verified === false) {
591
+ logger.warn('DAEMON', '账号 {name} 校验失败:{err}', { name: account.name || account.id, err: account.verifyError });
592
+ }
593
+ logger.info('DAEMON', '添加账号 {name}({provider})', { name: account.name || account.id, provider: PROVIDER_LABEL[account.provider] });
594
+ return json(res, 201, { account: publicAccount(account) });
595
+ }
596
+
597
+ // 修改备注名
598
+ const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
599
+ if (idMatch && method === 'PATCH') {
600
+ const body = await readBody(req);
601
+ const name = String(body?.name || '').trim().slice(0, 64) || null;
602
+ const found = await withAccounts((accounts) => {
603
+ const a = accounts.find((x) => x.id === idMatch[1]);
604
+ // meta.renamed = 用户手改的备注名(10R 同名认领键)——捕获/重探不得再自动覆盖
605
+ if (a) { a.name = name; a.meta = { ...(a.meta || {}), renamed: Boolean(name) }; }
606
+ return a || null;
607
+ });
608
+ if (!found) return json(res, 404, { error: '账号不存在' });
609
+ return json(res, 200, { account: publicAccount(found) });
610
+ }
611
+
612
+ // 删除账号
613
+ const delMatch = idMatch;
614
+ if (delMatch && method === 'DELETE') {
615
+ const removed = await withAccounts((accounts) => {
616
+ const i = accounts.findIndex((a) => a.id === delMatch[1]);
617
+ return i < 0 ? null : accounts.splice(i, 1)[0];
618
+ });
619
+ if (!removed) return json(res, 404, { error: '账号不存在' });
620
+ // 网关侧的轮换/冷却/粘滞状态同步清掉:账号 id 每次导入都是新 UUID,残留条目永远无人触碰
621
+ try { zcodeGateway.forgetAccount(removed.id); } catch {}
622
+ try { traeGateway.forgetAccount(removed.id); } catch {}
623
+ try { minimaxGateway.forgetAccount(removed.id); } catch {}
624
+ try { (await import('./zcodeAutoClaim.js')).forgetAccount(removed.id); } catch {}
625
+ logger.info('DAEMON', '删除账号 {name}', { name: removed.name || removed.id });
626
+ return json(res, 200, { ok: true });
627
+ }
628
+
629
+ // 单账号签到
630
+ const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
631
+ if (checkinMatch && method === 'POST') {
632
+ const accounts = await loadAccounts();
633
+ const account = accounts.find((a) => a.id === checkinMatch[1]);
634
+ const { results, summary } = account?.provider === 'zcode'
635
+ ? await pollZcodeNow([account.id])
636
+ : await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
637
+ return json(res, 200, { results, summary });
638
+ }
639
+
640
+ // 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
641
+ if (p === '/api/checkin' && method === 'POST') {
642
+ const body = await readBody(req).catch(() => ({}));
643
+ const opts = {
644
+ provider: body?.provider || undefined,
645
+ product: body?.product || undefined,
646
+ skipIfCheckedToday: body?.skipIfCheckedToday !== false,
647
+ };
648
+ const { results, summary } = opts.provider === 'zcode' || opts.product === 'zcode'
649
+ ? await pollZcodeNow()
650
+ : await runCheckinTick(opts);
651
+ return json(res, 200, { results, summary });
652
+ }
653
+
654
+ // ── WorkBuddy 临时活动(额外补偿 / 首月赠礼)──
655
+ // 用已存 Bearer 凭据批量领取,免逐个网页点击。GET 预览可领项,POST 批量领。
656
+ // body 可选 { accountIds: [], kinds: [] } 过滤;默认扫全部 workbuddy 账号 × 全部活动。
657
+ if (p === '/api/workbuddy/activities' && method === 'GET') {
658
+ const accounts = await loadAccounts();
659
+ const targets = accounts.filter((a) => String(a.provider).startsWith('workbuddy'));
660
+ const { fetchWbActivities } = await import('./workbuddyClient.js');
661
+ const results = [];
662
+ for (const a of targets) {
663
+ const acts = await fetchWbActivities(a).catch((e) => [{ error: e.message }]);
664
+ results.push({ accountId: a.id, name: a.name || a.id, provider: a.provider, activities: acts });
665
+ }
666
+ return json(res, 200, { results });
667
+ }
668
+ if (p === '/api/workbuddy/claim-activities' && method === 'POST') {
669
+ const body = await readBody(req).catch(() => ({}));
670
+ const onlyIds = Array.isArray(body?.accountIds) && body.accountIds.length ? new Set(body.accountIds) : null;
671
+ const onlyKinds = Array.isArray(body?.kinds) && body.kinds.length ? body.kinds : null;
672
+ const accounts = await loadAccounts();
673
+ const targets = accounts.filter((a) => String(a.provider).startsWith('workbuddy') && (!onlyIds || onlyIds.has(a.id)));
674
+ const { claimWbActivity, fetchWbActivities } = await import('./workbuddyClient.js');
675
+ const results = [];
676
+ for (const a of targets) {
677
+ // 先查可领项,只对 active && !claimed 的发领取请求(幂等,多号批量安全)
678
+ let acts = [];
679
+ try { acts = await fetchWbActivities(a); } catch (e) { acts = [{ error: e.message }]; }
680
+ const claimable = acts.filter((x) => x.claimable && (!onlyKinds || onlyKinds.includes(x.kind)));
681
+ if (!claimable.length) {
682
+ results.push({ accountId: a.id, name: a.name || a.id, activities: acts, claimed: [] });
683
+ continue;
684
+ }
685
+ const claimed = [];
686
+ for (const act of claimable) {
687
+ const r = await claimWbActivity(a, act.kind).catch((e) => ({ accountId: a.id, account: a.name || a.id, kind: act.kind, status: 'failed', error: e.message }));
688
+ claimed.push(r);
689
+ const ok = r.status === 'checked-in';
690
+ (ok ? logger.info : logger.warn)('WB-ACT', '{name} {label}:{msg}', { name: a.name || a.id, label: r.activityLabel || act.kind, msg: ok ? `领取成功 +${r.claimedAmount} 积分` : (r.error || r.message || r.status) });
691
+ }
692
+ results.push({ accountId: a.id, name: a.name || a.id, activities: acts, claimed });
693
+ }
694
+ const summary = {
695
+ claimed: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'checked-in').length, 0),
696
+ already: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'already').length, 0),
697
+ failed: results.reduce((s, r) => s + r.claimed.filter((x) => x.status === 'failed').length, 0),
698
+ total: results.reduce((s, r) => s + r.claimed.length, 0),
699
+ };
700
+ return json(res, 200, { results, summary });
701
+ }
702
+
703
+ // 积分查询(统一结构:total / used / remaining / parts[])
704
+ const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
705
+ if (quotaMatch && method === 'GET') {
706
+ const accounts = await loadAccounts();
707
+ const account = accounts.find((a) => a.id === quotaMatch[1]);
708
+ if (!account) return json(res, 404, { error: '账号不存在' });
709
+ // 网页会话账号(cookie-site 五家)不在 PRODUCTS 表里——productImpl 会 undefined 崩
710
+ // (502 且 lastProbe 不更新,呼吸灯纹丝不动、「重探后才行」的由来)。改走重探同款
711
+ // 语义:live 拉余额 → 回填快照 → 写探活痕迹 → 推对应卡,响应归一成统一 quota 结构。
712
+ if (account.meta?.cookieSiteId) {
713
+ const site = getSite(account.meta.cookieSiteId);
714
+ if (!site) return json(res, 404, { error: `站点 ${account.meta.cookieSiteId} 未配置` });
715
+ if (site.disabled === true) return json(res, 410, { error: '该站点已停用(issue #35),不再探测上游' });
716
+ // 频率治理(面板 2 分钟自动轮询曾把五家上游全轰一遍):默认读缓存快照零网络,
717
+ // 仅 ?live=1(手动「刷新积分」按钮 / 单卡回调)才真探上游并回填快照。缓存保鲜交给
718
+ // 后台推送循环(default 60 分钟)——它 live 拉到的余额本就该落库,顺手把这条链补上。
719
+ const live = url.searchParams.get('live') === '1';
720
+ let snap = account.meta?.quotaSnapshot || null;
721
+ if (live || !snap) {
722
+ const rep = await reprobeCookieAccount(site, account).catch((e) => ({ ok: false, error: e.message }));
723
+ if (!rep.ok) return json(res, 502, { error: rep.error || '探测失败' });
724
+ snap = rep.quotaSnapshot || {};
725
+ }
726
+ // 快照形状两样:API 站 total=余额;HTML 探针站(tokenharbor)total 缺失、
727
+ // used=已用百分比——统一结构按各自语义归一(HTML 站 remaining=100-used)。
728
+ const total = Number(snap.total);
729
+ const used = Number(snap.used) || 0;
730
+ const hasTotal = Number.isFinite(total) && total > 0;
731
+ // 单位标注:各家余额单位互异(Token / USD / CNY / 魔粒 / 积分)且都不是「积分」
732
+ // 口径——带 unit 返回,面板 creditSum 按 unit 有无决定是否计入「剩余积分」合计
733
+ //(有单位 = 不同量纲,不相加)。HTML 探针站(tokenharbor)是百分比,同样排除。
734
+ const UNIT_BY_SITE = { 'xiaomi-mimo': 'CNY', 'river': 'USD', 'longcat': 'Token', 'modelscope': '魔粒' };
735
+ const unit = UNIT_BY_SITE[site.id] || (hasTotal ? '' : '%');
736
+ return json(res, 200, { quota: {
737
+ total: hasTotal ? total : 100,
738
+ used,
739
+ remaining: hasTotal ? total : Math.max(0, 100 - used),
740
+ ...(unit ? { unit } : {}),
741
+ exceeded: false,
742
+ parts: [],
743
+ } });
744
+ }
745
+ try {
746
+ const log = (m) => logger.info('QUOTA', '{name}:{msg}', { name: account.name || account.id, msg: m });
747
+ const ctx = { log, ...refreshContext(account, log) };
748
+ return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
749
+ } catch (e) {
750
+ return json(res, 502, { error: e.message });
751
+ }
752
+ }
753
+
754
+ // ZCode 活动领取
755
+ const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
756
+ if (zcodePlansMatch && method === 'GET') {
757
+ const accounts = await loadAccounts();
758
+ const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
759
+ if (!account) return json(res, 404, { error: '账号不存在' });
760
+ if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
761
+ try {
762
+ return json(res, 200, await fetchClaimPlans(account));
763
+ } catch (e) {
764
+ return json(res, 502, { error: e.message });
765
+ }
766
+ }
767
+ const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
768
+ if (zcodeClaimMatch && method === 'POST') {
769
+ const body = await readBody(req).catch(() => ({}));
770
+ if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
771
+ const accounts = await loadAccounts();
772
+ const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
773
+ if (!account) return json(res, 404, { error: '账号不存在' });
774
+ if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
775
+ try {
776
+ const outcome = await claimPlan(account, body.planId.slice(0, 128), {
777
+ captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
778
+ region: typeof body.region === 'string' ? body.region : '',
779
+ });
780
+ logger.info('DAEMON', 'ZCode {name} 领取成功:{plan}', { name: account.name || account.id, plan: outcome.planName });
781
+ await zcodeGateway.clearQuotaMark(account.id);
782
+ await withAccounts((list) => {
783
+ const cur = list.find((a) => a.id === account.id);
784
+ if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
785
+ });
786
+ return json(res, 200, outcome);
787
+ } catch (e) {
788
+ logger.warn('DAEMON', 'ZCode {name} 领取失败:{err}', { name: account.name || account.id, err: e.message });
789
+ return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
790
+ }
791
+ }
792
+ if (p === '/api/zcode/captcha-config' && method === 'GET') {
793
+ try {
794
+ return json(res, 200, await fetchCaptchaConfig());
795
+ } catch (e) {
796
+ return json(res, 502, { error: e.message });
797
+ }
798
+ }
799
+ // Loomy 邀请码绑定:POST /api/v1/points/activation(协议逆向自客户端 points-service.bindInviteCode)
800
+ const loomyInviteMatch = p.match(/^\/api\/accounts\/([\w-]+)\/loomy\/invite$/);
801
+ if (loomyInviteMatch && method === 'POST') {
802
+ const body = await readBody(req).catch(() => ({}));
803
+ const code = String(body?.code || '').trim();
804
+ if (!code) return json(res, 400, { error: '缺少邀请码' });
805
+ const accounts = await loadAccounts();
806
+ const account = accounts.find((a) => a.id === loomyInviteMatch[1] && a.provider === 'loomy');
807
+ if (!account) return json(res, 404, { error: 'Loomy 账号不存在' });
808
+ try {
809
+ const { bindLoomyInviteCode, fetchLoomyActivation } = await import('./loomyClient.js');
810
+ const bind = await bindLoomyInviteCode(account, code);
811
+ let activation = null;
812
+ try { activation = await fetchLoomyActivation(account); } catch { /* 状态查询失败不影响绑定结果 */ }
813
+ logger.info('DAEMON', 'Loomy {name} 邀请码已绑定:{code}', { name: account.name, code });
814
+ return json(res, 200, { ok: true, bind, activation });
815
+ } catch (e) {
816
+ logger.warn('DAEMON', 'Loomy {name} 邀请码绑定失败:{err}', { name: account.name, err: e.message });
817
+ return json(res, e.auth ? 401 : 400, { error: e.message });
818
+ }
819
+ }
820
+ // preview 查不到的「隐藏活动」兜底:逐个试已知 planId(WEEKEND BUILD 实测 preview 空、claim 可领)
821
+ const zcodeClaimKnownMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim-known$/);
822
+ if (zcodeClaimKnownMatch && method === 'POST') {
823
+ const accounts = await loadAccounts();
824
+ const account = accounts.find((a) => a.id === zcodeClaimKnownMatch[1]);
825
+ if (!account) return json(res, 404, { error: '账号不存在' });
826
+ if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
827
+ const { ZCODE_KNOWN_CLAIM_PLANS } = await import('./zcodeClient.js');
828
+ const claimed = [], already = [], skipped = [];
829
+ for (const known of ZCODE_KNOWN_CLAIM_PLANS) {
830
+ try {
831
+ const outcome = await claimPlan(account, known.planId, {});
832
+ claimed.push(outcome.planName || known.name || known.planId);
833
+ logger.info('DAEMON', 'ZCode {name} 领取隐藏活动成功:{plan}', { name: account.name || account.id, plan: outcome.planName || known.planId });
834
+ await zcodeGateway.clearQuotaMark(account.id);
835
+ await withAccounts((list) => {
836
+ const cur = list.find((a) => a.id === account.id);
837
+ if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName || known.planId}`, amount: 0, at: new Date().toISOString() };
838
+ });
839
+ } catch (e) {
840
+ if (e.code === 1003) already.push(known.name || known.planId);
841
+ else if (e.code === 1001 || e.code === 1002) skipped.push(known.name || known.planId);
842
+ else return json(res, 502, { error: e.message, code: e.code, partial: { claimed, already, skipped } });
843
+ }
844
+ }
845
+ return json(res, 200, { claimed, already, skipped });
846
+ }
847
+ // 网络出口偏好(全局——Qoder/ZCode/cookie 探测等全部出口都走它):默认「直连优先、代理兜底」,
848
+ // 可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600;键名 zcode 前缀是历史遗留)
849
+ // 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
850
+ if (p === '/api/zcode-gateway' && method === 'GET') {
851
+ return json(res, 200, await zcodeGateway.gatewayStatus());
852
+ }
853
+ if (p === '/api/zcode-gateway' && method === 'PUT') {
854
+ const body = await readBody(req).catch(() => ({}));
855
+ if (body?.enabled !== undefined) await zcodeGateway.setGatewayEnabled(body.enabled === true);
856
+ if (body?.lan !== undefined || body?.allow !== undefined) {
857
+ const patch = {};
858
+ if (body?.lan !== undefined) patch.zcodeGatewayLan = body.lan === true;
859
+ if (body?.allow !== undefined) {
860
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
861
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
862
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
863
+ patch.zcodeGatewayAllow = cleaned;
864
+ }
865
+ await (await import('./store.js')).saveSettings(patch);
866
+ const lanNow = patch.zcodeGatewayLan ?? (await (await import('./store.js')).loadSettings()).zcodeGatewayLan === true;
867
+ const allowText = patch.zcodeGatewayAllow ? patch.zcodeGatewayAllow.join(', ') : '';
868
+ logger.info('DAEMON', 'ZCode 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
869
+ }
870
+ return json(res, 200, await zcodeGateway.gatewayStatus());
871
+ }
872
+ if (p === '/api/i18n' && method === 'GET') {
873
+ const locale = getLocale();
874
+ return json(res, 200, { locale, dict: await loadDict(locale) });
875
+ }
876
+ // MiniMax 本地网关控制
877
+ if (p === '/api/minimax-gateway' && method === 'GET') {
878
+ return json(res, 200, await minimaxGateway.gatewayStatus());
879
+ }
880
+ if (p === '/api/minimax-gateway' && method === 'PUT') {
881
+ const body = await readBody(req).catch(() => ({}));
882
+ if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
883
+ if (body?.lan !== undefined || body?.allow !== undefined) {
884
+ const patch = {};
885
+ if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
886
+ if (body?.allow !== undefined) {
887
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
888
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
889
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
890
+ patch.minimaxGatewayAllow = cleaned;
891
+ }
892
+ await (await import('./store.js')).saveSettings(patch);
893
+ const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
894
+ const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
895
+ logger.info('DAEMON', 'MiniMax 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
896
+ }
897
+ return json(res, 200, await minimaxGateway.gatewayStatus());
898
+ }
899
+ // Trae 本地网关控制
900
+ if (p === '/api/trae-gateway' && method === 'GET') {
901
+ return json(res, 200, await traeGateway.gatewayStatus());
902
+ }
903
+ if (p === '/api/trae-gateway' && method === 'PUT') {
904
+ const body = await readBody(req).catch(() => ({}));
905
+ if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
906
+ if (body?.lan !== undefined || body?.allow !== undefined) {
907
+ const patch = {};
908
+ if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
909
+ if (body?.allow !== undefined) {
910
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
911
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
912
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
913
+ patch.traeGatewayAllow = cleaned;
914
+ }
915
+ await (await import('./store.js')).saveSettings(patch);
916
+ const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
917
+ const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
918
+ logger.info('DAEMON', 'Trae 网关局域网:{state}{allow}(绑定改动重启后生效)', { state: lanNow ? '开' : '关(仅本机)', allow: allowText ? ',白名单:{list}' : '', list: allowText || '' });
919
+ }
920
+ return json(res, 200, await traeGateway.gatewayStatus());
921
+ }
922
+ if (p === '/api/zcode/net' && method === 'GET') {
923
+ const u = proxyUrl();
924
+ const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
925
+ const ac = autoClaimEnabled();
926
+ return json(res, 200, {
927
+ proxyFirst: proxyFirst(), proxyUrlMasked: masked,
928
+ autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
929
+ claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
930
+ hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy),
931
+ });
932
+ }
933
+ if (p === '/api/zcode/net' && method === 'PUT') {
934
+ const body = await readBody(req).catch(() => ({}));
935
+ try {
936
+ if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
937
+ if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
938
+ // 自动领取计划(轮询周期 / 运行时长)先落盘,再按新配置启动
939
+ if (body?.claimIntervalMin !== undefined) setClaimIntervalMin(body.claimIntervalMin);
940
+ if (body?.claimWindowMin !== undefined) setClaimWindowMin(body.claimWindowMin);
941
+ if (body?.claimIntervalMin !== undefined || body?.claimWindowMin !== undefined) refreshZcodeScheduler();
942
+ if (body?.autoClaim !== undefined) {
943
+ if (body.autoClaim === true) enableZcodeAutoClaimWindow(); // 开 = 按「自动领取计划」启动(2 分钟后首轮)
944
+ else { setAutoClaimEnabled(false); refreshZcodeScheduler(); } // 关 = 立即停表
945
+ }
946
+ } catch (e) { return json(res, 400, { error: e.message }); }
947
+ const ac = autoClaimEnabled();
948
+ logger.info('DAEMON', '网络出口(全局):{route},ZCode 自动领取:{claim},代理 {proxy}', { route: proxyFirst() ? '代理优先' : '直连优先', claim: ac ? '开(每 {min} 分钟,{window})' : '关', min: claimIntervalMin(), window: claimWindowMin() > 0 ? '运行 {windowMin} 分钟' : '一直运行', windowMin: claimWindowMin(), proxy: proxyUrl() || '(环境变量/未设置)' });
949
+ return json(res, 200, {
950
+ proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null,
951
+ claimIntervalMin: claimIntervalMin(), claimWindowMin: claimWindowMin(),
952
+ });
953
+ }
954
+
955
+ // 切换 WorkBuddy / ZCode 客户端当前登录账号
956
+ const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
957
+ if (switchMatch && method === 'POST') {
958
+ const body = await readBody(req).catch(() => ({}));
959
+ const accounts = await loadAccounts();
960
+ const target = accounts.find((a) => a.id === switchMatch[1]);
961
+ if (!target) return json(res, 404, { error: '账号不存在' });
962
+ if (target.provider === 'zcode') {
963
+ // 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
964
+ const live = zcodeLiveAccount();
965
+ if (live && live.uid !== target.uid) {
966
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:{err}', { err: e.message }));
967
+ }
968
+ try {
969
+ // 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
970
+ let closedClient = false;
971
+ if (body?.force === true) {
972
+ const t = terminateZcode();
973
+ closedClient = t.closed === true;
974
+ if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)', {});
975
+ else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换', {});
976
+ }
977
+ ensureVirtualDeviceMid(target);
978
+ const r = zcodeSwitchTo(target, { force: body?.force === true });
979
+ // 虚拟设备 ID 首次生成时需落盘
980
+ await withAccounts((list) => {
981
+ const cur = list.find((a) => a.id === target.id);
982
+ if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
983
+ });
984
+ logger.info('DAEMON', r.alreadyActive ? 'ZCode 当前已是 {name}' : 'ZCode 已切换到 {name}', { name: target.name || target.id });
985
+ return json(res, 200, { ok: true, closedClient, ...r });
986
+ } catch (e) {
987
+ return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
988
+ }
989
+ }
990
+ if (target.provider === 'mirasim') {
991
+ // 防丢号:先把当前登录同步进账号库
992
+ try {
993
+ const live = await mirasimLiveAccount();
994
+ if (live && live.uid !== target.uid) {
995
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:{err}', { err: e.message }));
996
+ }
997
+ } catch (e) {}
998
+ try {
999
+ let closedClient = false;
1000
+ if (body?.force === true) {
1001
+ const t = terminateMirasim();
1002
+ closedClient = t.closed === true;
1003
+ if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)', {});
1004
+ }
1005
+ const r = await mirasimSwitchTo(target, { force: body?.force === true });
1006
+ logger.info('DAEMON', r.alreadyActive ? 'mirasim 当前已是 {name}' : 'mirasim 已切换到 {name}', { name: target.name || target.id });
1007
+ return json(res, 200, { ok: true, closedClient, ...r });
1008
+ } catch (e) {
1009
+ return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
1010
+ }
1011
+ }
1012
+ if (target.provider === 'catpaw') {
1013
+ // 防丢号:先把妙手当前登录同步进账号库
1014
+ try {
1015
+ const live = await catpawLiveAccount();
1016
+ if (live && live.token !== target.token) {
1017
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步妙手当前登录失败:{err}', { err: e.message }));
1018
+ }
1019
+ } catch (e) {}
1020
+ try {
1021
+ let closedClient = false;
1022
+ if (body?.force === true) {
1023
+ const t = terminateCatpaw();
1024
+ closedClient = t.closed === true;
1025
+ if (closedClient) logger.info('DAEMON', '已关闭妙手客户端(强制切换)', {});
1026
+ else if (t.running) logger.warn('DAEMON', '未能完全结束妙手进程,继续强制切换', {});
1027
+ }
1028
+ const r = await catpawSwitchTo(target, { force: body?.force === true });
1029
+ logger.info('DAEMON', r.alreadyActive ? '妙手当前已是 {name}' : '妙手已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
1030
+ return json(res, 200, { ok: true, closedClient, ...r });
1031
+ } catch (e) {
1032
+ return json(res, e.catpawRunning ? 409 : 400, { error: e.message, code: e.catpawRunning ? 'CATPAW_RUNNING' : undefined });
1033
+ }
1034
+ }
1035
+ if (target.provider === 'loomy') {
1036
+ try {
1037
+ const live = await loomyLiveAccount();
1038
+ if (live && live.token && live.token !== target.token) {
1039
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Loomy 当前登录失败:{err}', { err: e.message }));
1040
+ }
1041
+ } catch (e) {}
1042
+ try {
1043
+ // 硬切换:总是先关客户端再写(客户端退出清理会回写旧登录,温和路径会被冲掉),
1044
+ // 写完延时复核;面板收到 closedClient 后自动重新拉起客户端
1045
+ const r = await hardSwitchLoomy(target);
1046
+ logger.info('DAEMON', r.alreadyActive ? 'Loomy 当前已是 {name}' : r.verified ? 'Loomy 已切换到 {name}(凭据已写入并重开客户端;客户端界面登录态以其内部存储为准,若仍显示旧账号需在客户端内退出重登)' : 'Loomy 已切换到 {name},但凭据复核未落定(疑似有进程回写)', { name: target.name || target.id });
1047
+ return json(res, 200, { ok: true, ...r });
1048
+ } catch (e) {
1049
+ logger.warn('DAEMON', 'Loomy 切换到 {name} 失败:{err}', { name: target.name || target.id, err: e.message });
1050
+ return json(res, e.loomyRunning ? 409 : 400, { error: e.message, code: e.loomyRunning ? 'LOOMY_RUNNING' : undefined });
1051
+ }
1052
+ }
1053
+ if (target.provider === 'qoder' || target.provider === 'qoder-cn') {
1054
+ // 防丢号:先把本机当前登录的最新 auth 快照回写账号库(客户端会静默刷新 token,旧快照可能已失效),再覆盖
1055
+ try {
1056
+ const lr = await readQoderAppAccounts();
1057
+ const live = lr.accounts.find((c) => c.provider === target.provider);
1058
+ if (live && live.user.id && String(live.user.id) !== String(target.uid)) {
1059
+ await addAccount({
1060
+ provider: live.provider, token: live.token, name: live.user.name || live.user.email,
1061
+ uid: live.user.id, email: live.user.email, refreshToken: live.refreshToken, expiresAt: live.expiresAt,
1062
+ source: 'local-app', meta: { qoderAuth: live.authJson, qoderAuthFile: live.file },
1063
+ }, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Qoder 当前登录失败:{err}', { err: e.message }));
1064
+ }
1065
+ } catch (e) { logger.warn('DAEMON', '读取 Qoder 当前登录失败:{err}', { err: e.message }); }
1066
+ try {
1067
+ // 强制切换:先关掉运行中的 Qoder,否则它退出时会把内存里的旧登录覆盖回文件
1068
+ let closedClient = false;
1069
+ if (body?.force === true) {
1070
+ const t = terminateQoder();
1071
+ closedClient = t.closed === true;
1072
+ if (closedClient) logger.info('DAEMON', '已关闭 Qoder 客户端(强制切换)', {});
1073
+ else if (t.running) logger.warn('DAEMON', '未能完全结束 Qoder 进程,继续强制切换', {});
1074
+ }
1075
+ const r = await qoderSwitchTo(target, { force: body?.force === true });
1076
+ // status 里的「Qoder 当前登录」缓存 1 分钟:不放掉的话切完后面板仍把旧账号当当前账号
1077
+ // (新当前账号不亮角标、切换键显隐全错,用户看到的就是「切完切换键没了」)
1078
+ qoderCurCache = { at: 0, data: null };
1079
+ if (r.file && r.file !== target.meta?.qoderAuthFile) {
1080
+ // 快照路径已失效、实际写进了本机新的数据目录:账号上的路径一并改成生效的那个
1081
+ await withAccounts((list) => {
1082
+ const cur = list.find((a) => a.id === target.id);
1083
+ if (cur) cur.meta = { ...(cur.meta || {}), qoderAuthFile: r.file };
1084
+ }).catch((e) => logger.warn('DAEMON', '更新 Qoder 快照路径失败:{err}', { err: e.message }));
1085
+ }
1086
+ logger.info('DAEMON', r.alreadyActive ? 'Qoder 当前已是 {name}' : 'Qoder 已切换到 {name}(重新打开 Qoder 生效)', { name: target.name || target.id });
1087
+ return json(res, 200, { ok: true, closedClient, ...r });
1088
+ } catch (e) {
1089
+ return json(res, e.qoderRunning ? 409 : 400, { error: e.message, code: e.qoderRunning ? 'QODER_RUNNING' : undefined });
1090
+ }
1091
+ }
1092
+ if (target.provider === 'trae') {
1093
+ // 防丢号:先把 Trae 当前登录同步进账号库并建快照,再覆盖成目标账号的登录态
1094
+ try {
1095
+ const live = await traeLiveAccount();
1096
+ if (live && live.token !== target.token) {
1097
+ await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 Trae 当前登录失败:{err}', { err: e.message }));
1098
+ traeSnapshotLive();
1099
+ }
1100
+ } catch (e) {}
1101
+ try {
1102
+ // 退进程交给 switchTo:它要先确认目标有快照才会动手,避免「白关一次 Trae」
1103
+ const r = await traeSwitchTo(target, { force: body?.force === true });
1104
+ if (r.closedClient) logger.info('DAEMON', '已关闭 Trae 客户端(强制切换)', {});
1105
+ logger.info('DAEMON', r.alreadyActive ? 'Trae 当前已是 {name}' : 'Trae 已切换到 {name}(重新打开客户端生效)', { name: target.name || target.id });
1106
+ return json(res, 200, { ok: true, ...r });
1107
+ } catch (e) {
1108
+ return json(res, e.traeRunning ? 409 : 400, { error: e.message, code: e.traeRunning ? 'TRAE_RUNNING' : undefined });
1109
+ }
1110
+ }
1111
+ // MiniMax 不做切换:客户端凭据只有一条路径派生的活动记录,且 refreshToken 轮换式一次性,
1112
+ // CreditDaddy 与客户端共用必然互抢(invalid_grant),切号能力已按用户决定移除。
1113
+ // Loomy 的切换在上方 provider 链已处理;这里只剩 WorkBuddy 系
1114
+ if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim / 妙手 / Trae / Loomy 账号支持切换' });
1115
+ // 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
1116
+ const cur = readWorkbuddySessions().accounts.find((a) => a.current);
1117
+ if (cur && cur.uid !== target.uid) {
1118
+ const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
1119
+ await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:{err}', { err: e.message }));
1120
+ }
1121
+ try {
1122
+ // 本机没装 WorkBuddy / CodeBuddy 客户端时明确拒绝:写会话文件是死路——
1123
+ // 之前不拦会 mkdir 硬写后返回成功,用户以为切好了,客户端却永远不会应用它
1124
+ const wbDet = detectWorkbuddy();
1125
+ if (!wbDet.installed) {
1126
+ return json(res, 400, { error: '本机未安装 WorkBuddy / CodeBuddy 客户端,无法切换(切换是把登录写入客户端凭据,由客户端应用;可先安装客户端后在本机导入再切换)', code: 'WORKBUDDY_NOT_INSTALLED' });
1127
+ }
1128
+ const r = writeWorkbuddySession(target);
1129
+ logger.info('DAEMON', 'WorkBuddy 已切换到 {name}', { name: target.name || target.id });
1130
+ return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
1131
+ } catch (e) {
1132
+ return json(res, 400, { error: e.message });
1133
+ }
1134
+ }
1135
+
1136
+ // ── 设备码登录(Qoder) ──
1137
+ if (p === '/api/auth/device/start' && method === 'POST') {
1138
+ const body = await readBody(req).catch(() => ({}));
1139
+ try {
1140
+ const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
1141
+ const flow = await startDeviceFlow(kind);
1142
+ return json(res, 200, flow);
1143
+ } catch (e) { return json(res, 500, { error: e.message }); }
1144
+ }
1145
+ if (p === '/api/auth/device/poll' && method === 'POST') {
1146
+ const body = await readBody(req).catch(() => ({}));
1147
+ if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
1148
+ try {
1149
+ return json(res, 200, await pollDeviceFlow(body.sessionId));
1150
+ } catch (e) { return json(res, 500, { error: e.message }); }
1151
+ }
1152
+
1153
+ // ── Qoder 网页会话(桌面版登录窗口关闭前抓取,用于逐资源包用量明细) ──
1154
+ // Cookie 先探测归属(响应里的 user_id),只写到同 uid 的 qoder 账号上;探测失败视为会话无效。
1155
+ if (p === '/api/auth/qoder-web-session' && method === 'POST') {
1156
+ const body = await readBody(req).catch(() => ({}));
1157
+ const cookie = String(body?.cookie || '').trim();
1158
+ const kind = body?.kind === 'qoder-cn' ? 'qoder-cn' : 'qoder';
1159
+ // 诊断:登录窗关闭时两个域都会上报;收到 0 条 Cookie 说明窗口内根本没建立该域的网站会话
1160
+ const count = body?.diag?.cookieCount;
1161
+ logger.info('DAEMON', 'Qoder 网页会话收割({kind}):{result}', { kind, result: typeof count === 'number' ? '{count} 条 Cookie' : '已上报', count });
1162
+ if (!cookie || cookie.length > 16384) return json(res, 200, { ok: true, bound: 0, empty: true });
1163
+ const { probeWebSession } = await import('./qoderClient.js');
1164
+ const probe = await probeWebSession(kind, cookie);
1165
+ if (!probe?.user_id) {
1166
+ logger.warn('DAEMON', 'Qoder {edition}网页会话探测未通过(接口不认这份 Cookie),已丢弃', { edition: kind === 'qoder-cn' ? '国内版' : '国际版' });
1167
+ return json(res, 400, { error: '网页会话无效(探测接口未通过)' });
1168
+ }
1169
+ const now = new Date().toISOString();
1170
+ const bound = await withAccounts((list) => {
1171
+ const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
1172
+ for (const a of hits) {
1173
+ a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
1174
+ delete a.meta.qoderWebDead; // 新 cookie 落地即清失效标记(保活监视下一轮重新评估)
1175
+ a.updatedAt = now;
1176
+ }
1177
+ return hits.length;
1178
+ });
1179
+ if (!bound) {
1180
+ // 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上。
1181
+ // 暂存区顺手做 TTL 清理(48h):没等到绑定的 uid 会话一直滞留 settings.json——
1182
+ // 既是无限增长的存档,也是过期凭据长期躺在盘上
1183
+ const TTL_MS = 48 * 3600 * 1000;
1184
+ await withSettings((settings) => {
1185
+ const pending = { ...(settings.pendingQoderWebSessions || {}) };
1186
+ for (const [uid, it] of Object.entries(pending)) {
1187
+ const at = Date.parse(it?.capturedAt || '');
1188
+ if (!Number.isFinite(at) || Date.now() - at > TTL_MS) delete pending[uid];
1189
+ }
1190
+ pending[probe.user_id] = { kind, cookie, capturedAt: now };
1191
+ settings.pendingQoderWebSessions = pending;
1192
+ });
1193
+ logger.info('DAEMON', '收到 Qoder 网页会话(uid {uid}...),暂无匹配账号,已暂存待绑定', { uid: probe.user_id.slice(0, 8) });
1194
+ } else {
1195
+ logger.info('DAEMON', '已绑定 Qoder 网页会话到 {n} 个账号(逐资源包明细可用)', { n: bound });
1196
+ }
1197
+ return json(res, 200, { ok: true, bound });
1198
+ }
1199
+
1200
+ // ── 通用 cookie 站点捕获(issue #23):登录窗口收割 → 探测 → 入账号库(provider=site.provider)
1201
+ if (p === '/api/cookie-sites' && method === 'GET') {
1202
+ const accounts = await loadAccounts();
1203
+ return json(res, 200, {
1204
+ sites: COOKIE_SITES.map((site) => {
1205
+ const rows = accounts
1206
+ .filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id)
1207
+ .map((a) => ({
1208
+ id: a.id,
1209
+ name: a.name || site.accountName,
1210
+ capturedAt: a.meta?.cookieSiteSession?.capturedAt || null,
1211
+ quota: a.meta?.quotaSnapshot || null,
1212
+ mimoBalance: a.meta?.mimoBalance || null,
1213
+ riverBalance: a.meta?.riverBalance || null,
1214
+ msBalance: a.meta?.msBalance || null,
1215
+ longcatPacks: a.meta?.longcatPacks || null,
1216
+ lastProbe: a.meta?.lastProbe || null, // 呼吸灯数据源:ok / warn / dead + 失败原因
1217
+ }));
1218
+ const latest = rows.reduce((t, r) => (r.capturedAt && (!t || r.capturedAt > t) ? r.capturedAt : t), null);
1219
+ return {
1220
+ id: site.id, name: site.name, label: site.label, loginUrl: site.loginUrl, hint: site.hint,
1221
+ accountId: rows[0]?.id || null, capturedAt: latest, accounts: rows,
1222
+ };
1223
+ }),
1224
+ });
1225
+ }
1226
+
1227
+ if (p.startsWith('/api/cookie-sites/') && p.endsWith('/capture') && method === 'POST') {
1228
+ const siteId = p.slice('/api/cookie-sites/'.length, -'/capture'.length);
1229
+ const site = getSite(siteId);
1230
+ if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1231
+ const body = await readBody(req).catch(() => ({}));
1232
+ const cookie = String(body?.cookie || '').trim();
1233
+ // localStorage 凭据线(river = WorkOS SPA 会话):cookie 无关,探测走站点自有 API
1234
+ const storage = (body?.storage && typeof body.storage === 'object' && !Array.isArray(body.storage)) ? body.storage : {};
1235
+ const storageSite = Array.isArray(site.storageKeys) && site.storageKeys.length > 0 ? site : null;
1236
+ const count = body?.diag?.cookieCount;
1237
+ logger.info('COOKIE-SITE', '{name} cookie 收割上报({count} 条)', { name: site.name, count: typeof count === 'number' ? count : '-' });
1238
+ if (storageSite) {
1239
+ if (cookie.length > 32768) return json(res, 400, { error: 'cookie 体积异常,请重新登录后再捕获' });
1240
+ } else if (!cookie || cookie.length > 32768) {
1241
+ return json(res, 400, { error: 'cookie 不能为空,请重新登录后再捕获' });
1242
+ }
1243
+
1244
+ // 探测策略:桌面壳隐藏 Chromium 窗口探针产的 probeHtml 优先(真浏览器视角,过风控);
1245
+ // 无 probeHtml(CLI / 非桌面)回退 Node fetch 探测(fetchJsonRace,跟全局代理)。
1246
+ // storage 站点(river)与 verify:'api' 站点(modelscope)跳过 HTML 探针——余额 API 调用本身就是探测。
1247
+ const skipGenericProbe = Boolean(storageSite) || site.verify === 'api';
1248
+ const probeHtml = skipGenericProbe ? null : (typeof body?.probeHtml === 'string' && body.probeHtml.length > 0 && body.probeHtml.length < 4 * 1024 * 1024 ? body.probeHtml : null);
1249
+ const probe = skipGenericProbe ? { ok: true } : (probeHtml ? verifyProbeHtml(site, probeHtml) : await verifySiteCookie(site, cookie));
1250
+ if (probeHtml && !probe.ok) {
1251
+ // 临时调试:浏览器探针页首 120 字进日志,便于区分「风控挑战页 / 登录页壳 / 结构变化」三元
1252
+ const dbg = JSON.stringify(String(body.probeHtml).replace(/\s+/g, ' ').slice(0, 120));
1253
+ logger.warn('COOKIE-SITE', '{name} 浏览器探针未中(reason={reason}) head={head}', { name: site.name, reason: probe.reason, head: dbg });
1254
+ }
1255
+ if (!probe.ok) {
1256
+ const reason = probe.reason === 'invalid' ? '探测 401/403(会话无效)'
1257
+ : probe.reason === 'no-match' ? '探测页面缺少配额标记(登录未完成或已登出)'
1258
+ : `探测失败(${probe.reason}${probe.status ? ' HTTP ' + probe.status : ''})`;
1259
+ logger.warn('COOKIE-SITE', '{name} cookie 捕获被拒:{reason}', { name: site.name, reason });
1260
+ 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) });
1261
+ }
1262
+
1263
+ const now = new Date().toISOString();
1264
+ // 身份探测(免费账号要多账号轮换):从浏览器探针 HTML 里抽一次身份;
1265
+ // 抽不到 → 无身份单例(与初版一致),不静默覆盖有身份的行。
1266
+ let identity = probeHtml ? extractIdentity(site, probeHtml) : null;
1267
+ // issue #24:浏览器探针 HTML 顺手解析额度读数(推送时不再打网络;解析不出保持旧快照)
1268
+ let quotaSnapshot = probeHtml ? extractQuotaSnapshot(site, probeHtml) : null;
1269
+ let mimoUserId = null;
1270
+ let mimoBalance = null;
1271
+
1272
+ // issue #26:小米 MiMo 控制台资料与余额抓取
1273
+ if (site.id === 'xiaomi-mimo') {
1274
+ try {
1275
+ const { fetchXiaomiMimoProfile, fetchXiaomiMimoBalance } = await import('./xiaomiMimoClient.js');
1276
+ const prof = await fetchXiaomiMimoProfile(cookie);
1277
+ if (prof.ok && prof.userId) {
1278
+ mimoUserId = prof.userId;
1279
+ identity = prof.userName ? `${prof.userName} (${prof.userId})` : prof.userId;
1280
+ const bal = await fetchXiaomiMimoBalance(cookie, mimoUserId);
1281
+ if (bal.ok) {
1282
+ mimoBalance = { balance: bal.balance, cash: bal.cash, voucher: bal.voucher, at: now };
1283
+ quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true, at: now };
1284
+ }
1285
+ }
1286
+ } catch (mimoErr) {
1287
+ logger.warn('COOKIE-SITE', '小米 MiMo 控制台资料/余额抓取异常:{err}', { err: mimoErr.message });
1288
+ }
1289
+ }
1290
+
1291
+ // river(WorkOS 会话线):cookie 直连 → Bearer → 刷新兑换,探测/身份/额度一步完成。
1292
+ // 实测(2026-10-14):SPA token 只在内存,localStorage 通常空——cookie 是主通道。
1293
+ let riverSession = null;
1294
+ let riverBalance = null;
1295
+ if (site.id === 'river') {
1296
+ try {
1297
+ const { parseRiverSession, fetchRiverBalance } = await import('./riverClient.js');
1298
+ const sess = parseRiverSession(storage);
1299
+ sess.cookie = cookie; // 多源合并 cookie 串(auth.console.river.ai 的会话凭据在内)
1300
+ const storageCount = Object.values(storage || {}).filter((v) => typeof v === 'string' && v.trim()).length;
1301
+ const cookieNames = Array.isArray(body?.diag?.cookieNames) ? body.diag.cookieNames.slice(0, 40).join(',') : '';
1302
+ 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 || '—' });
1303
+ if (!sess.accessToken && !cookie) {
1304
+ logger.warn('COOKIE-SITE', '{name} 未读到任何会话凭据(storage token 与 cookie 均空)', { name: site.name });
1305
+ return json(res, 400, { error: '未读到 River 登录会话(WorkOS token / 会话 cookie 均无),请在登录窗完成登录后再关闭' });
1306
+ }
1307
+ const bal = await fetchRiverBalance({ meta: { workosSession: sess } }, {
1308
+ log: (m) => logger.info('COOKIE-SITE', '{name} {msg}', { name: site.name, msg: m }),
1309
+ });
1310
+ if (!bal.ok) {
1311
+ logger.warn('COOKIE-SITE', '{name} 余额探测被拒:{reason}', { name: site.name, reason: bal.error });
1312
+ return json(res, (bal.status === 401 || bal.status === 403) ? 401 : 502, {
1313
+ error: (bal.status === 401 || bal.status === 403)
1314
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1315
+ : '探测站点失败:' + (bal.error || ''),
1316
+ });
1317
+ }
1318
+ if (bal.session) Object.assign(sess, bal.session); // 刷新兑换到的新链一并落库(单次轮换)
1319
+ riverSession = sess;
1320
+ riverBalance = { balanceUsd: bal.balance, at: now };
1321
+ identity = sess.user?.email || sess.user?.id || null;
1322
+ quotaSnapshot = { used: 0, total: bal.balance, isCreditBalance: true };
1323
+ logger.info('COOKIE-SITE', '{name} 余额探测通过(通道 {channel},{balance} USD)', { name: site.name, channel: bal.channel, balance: bal.balance });
1324
+ } catch (riverErr) {
1325
+ logger.warn('COOKIE-SITE', '{name} 余额抓取异常:{err}', { name: site.name, err: riverErr.message });
1326
+ return json(res, 502, { error: '探测站点失败:' + (riverErr.message || '') });
1327
+ }
1328
+ }
1329
+
1330
+ // modelsscope(CD#25):魔粒余额 API 即探测;每日额度自动发放(无签到动作端点),只读余额
1331
+ let msSession = null;
1332
+ let msBalance = null;
1333
+ if (site.id === 'modelscope') {
1334
+ try {
1335
+ const { probeMagicubeSession } = await import('./modelscopeClient.js');
1336
+ if (!cookie) {
1337
+ logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1338
+ return json(res, 400, { error: '未读到 ModelScope 登录会话,请在登录窗完成登录后再关闭' });
1339
+ }
1340
+ const probe = await probeMagicubeSession(cookie);
1341
+ if (!probe.ok) {
1342
+ logger.warn('COOKIE-SITE', '{name} 魔粒余额探测被拒:{reason}', { name: site.name, reason: probe.error });
1343
+ return json(res, (probe.status === 401 || probe.status === 403) ? 401 : 502, {
1344
+ error: (probe.status === 401 || probe.status === 403)
1345
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1346
+ : '探测站点失败:' + (probe.error || ''),
1347
+ });
1348
+ }
1349
+ msSession = probe.msSession;
1350
+ msBalance = probe.msBalance;
1351
+ identity = probe.identity;
1352
+ quotaSnapshot = { used: 0, total: probe.balance, isCreditBalance: true };
1353
+ logger.info('COOKIE-SITE', '{name} 魔粒余额探测通过({balance} 个,今日 {today} / 长期 {long})', { name: site.name, balance: probe.balance, today: probe.msBalance.today ?? '—', long: probe.msBalance.long ?? '—' });
1354
+ if (!identity) logger.warn('COOKIE-SITE', '{name} 身份未解析(login/info 响应字段:{keys})', { name: site.name, keys: probe.keys || '—' });
1355
+ } catch (msErr) {
1356
+ logger.warn('COOKIE-SITE', '{name} 魔粒余额抓取异常:{err}', { name: site.name, err: msErr.message });
1357
+ return json(res, 502, { error: '探测站点失败:' + (msErr.message || '') });
1358
+ }
1359
+ }
1360
+
1361
+ // LongCat(issue #29):Token 资源包 summary API 即探测(美团 SSO cookie 直连,
1362
+ // 未登录 401「用户未登录」)。纯只读——包为购买/赠送发放,无签到动作端点。
1363
+ let lcPacks = null;
1364
+ let lcSession = null;
1365
+ let longcatSnapshot = null;
1366
+ if (site.id === 'longcat') {
1367
+ try {
1368
+ const lcMod = await import('./longcatClient.js');
1369
+ const { fetchLongcatPacks, fetchLongcatIdentity } = lcMod;
1370
+ longcatSnapshot = lcMod.longcatSnapshot;
1371
+ if (!cookie) {
1372
+ logger.warn('COOKIE-SITE', '{name} 未读到会话 cookie', { name: site.name });
1373
+ return json(res, 400, { error: '未读到 LongCat 登录会话,请在登录窗完成登录后再关闭' });
1374
+ }
1375
+ const packs = await fetchLongcatPacks(cookie);
1376
+ if (!packs.ok) {
1377
+ logger.warn('COOKIE-SITE', '{name} 资源包探测被拒:{reason}', { name: site.name, reason: packs.error });
1378
+ return json(res, packs.authExpired ? 401 : 502, {
1379
+ error: packs.authExpired
1380
+ ? '会话不可用,请重新登录该站点后再捕获(探测 401/403)'
1381
+ : '探测站点失败:' + (packs.error || ''),
1382
+ });
1383
+ }
1384
+ lcPacks = packs.packs;
1385
+ const lcTotal = lcPacks.reduce((s, p) => s + Math.max(0, Number(p.remaining) || 0), 0);
1386
+ quotaSnapshot = { used: 0, total: lcTotal, isCreditBalance: true };
1387
+ // 身份 fail-soft:GET /api/v1/user-current 解析不出走无身份单例,不阻断捕获
1388
+ const ident = await fetchLongcatIdentity(cookie).catch(() => null);
1389
+ if (ident?.ok) {
1390
+ lcSession = ident.session;
1391
+ identity = ident.session?.email || ident.session?.name || ident.session?.userId || null;
1392
+ }
1393
+ logger.info('COOKIE-SITE', '{name} 资源包探测通过({balance} Token,{n} 个包,身份 {identity})', { name: site.name, balance: lcTotal, n: lcPacks.length, identity: identity || '—' });
1394
+ } catch (lcErr) {
1395
+ logger.warn('COOKIE-SITE', '{name} 资源包抓取异常:{err}', { name: site.name, err: lcErr.message });
1396
+ return json(res, 502, { error: '探测站点失败:' + (lcErr.message || '') });
1397
+ }
1398
+ }
1399
+
1400
+ const ih = identityHashOf(mimoUserId || riverSession?.user?.id || msSession?.id || lcSession?.userId || identity);
1401
+ const quotaMeta = quotaSnapshot ? { quotaSnapshot: { ...quotaSnapshot, at: now } } : {};
1402
+ const cred = riverSession ? riverSession.accessToken : cookie; // 落盘的凭据主体
1403
+ const riverUid = riverSession?.user?.id || null;
1404
+ let accountId = null;
1405
+ await withAccounts((list) => {
1406
+ const hit = list.find((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1407
+ && (ih ? a.meta?.identityHash === ih : !a.meta?.identityHash));
1408
+ // 自动行名:按站点取「产品名 (身份)」形状。此前是四层嵌套三元,增删一个站点就得数括号
1409
+ // (撤码道时正是因此改出语法错误)——改成 if 链,增删站点只动一行
1410
+ let accName;
1411
+ if (mimoUserId) accName = `Xiaomi MiMo (UID: ${mimoUserId})`;
1412
+ else if (site.id === 'river') accName = `River AI (${identity || '会话'})`;
1413
+ else if (site.id === 'modelscope') accName = `ModelScope (${identity || '会话'})`;
1414
+ else if (site.id === 'longcat') accName = `LongCat (${identity || '会话'})`;
1415
+ else accName = site.accountName + (identity ? `(${identity})` : '');
1416
+ // 自动行名只在「没手改过且现名属自动名家族」时写入(meta.renamed + isAutoGeneratedName 双闸)
1417
+ const autoName = (row) => { if (canAutoRename(site, row)) row.name = accName; };
1418
+ if (hit) {
1419
+ hit.token = cred;
1420
+ if (mimoUserId) { hit.uid = mimoUserId; autoName(hit); }
1421
+ if (riverUid) { hit.uid = riverUid; autoName(hit); }
1422
+ if (msSession && (msSession.id || msSession.email || msSession.name)) {
1423
+ if (msSession.id) hit.uid = String(msSession.id);
1424
+ autoName(hit);
1425
+ }
1426
+ hit.meta = {
1427
+ ...(hit.meta || {}),
1428
+ cookieSiteSession: { cookie, capturedAt: now },
1429
+ ...(ih ? { identityHash: ih } : {}),
1430
+ ...quotaMeta,
1431
+ ...(mimoUserId ? { userId: mimoUserId } : {}),
1432
+ ...(mimoBalance ? { mimoBalance } : {}),
1433
+ ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1434
+ ...(riverBalance ? { riverBalance } : {}),
1435
+ ...(msSession ? { msSession, msBalance } : {}),
1436
+ ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1437
+ ...(lcSession ? { longcatSession: lcSession } : {}),
1438
+ };
1439
+ if (lcSession?.userId) { hit.uid = String(lcSession.userId); autoName(hit); }
1440
+ hit.updatedAt = now;
1441
+ accountId = hit.id;
1442
+ } else {
1443
+ const acc = normalizeAccountInput({
1444
+ name: accName,
1445
+ provider: site.provider,
1446
+ token: cred,
1447
+ uid: mimoUserId || riverUid || (msSession?.id ? String(msSession.id) : undefined) || (lcSession?.userId ? String(lcSession.userId) : undefined) || undefined,
1448
+ source: 'web-session',
1449
+ meta: {
1450
+ cookieSiteId: site.id,
1451
+ cookieSiteSession: { cookie, capturedAt: now },
1452
+ ...(ih ? { identityHash: ih } : {}),
1453
+ ...quotaMeta,
1454
+ ...(mimoUserId ? { userId: mimoUserId } : {}),
1455
+ ...(mimoBalance ? { mimoBalance } : {}),
1456
+ ...(riverSession ? { workosSession: riverSession, workosUser: riverSession.user || {} } : {}),
1457
+ ...(riverBalance ? { riverBalance } : {}),
1458
+ ...(msSession ? { msSession, msBalance } : {}),
1459
+ ...(lcPacks ? { longcatPacks: longcatSnapshot(lcPacks) } : {}),
1460
+ ...(lcSession ? { longcatSession: lcSession } : {}),
1461
+ },
1462
+ });
1463
+ acc.verified = true;
1464
+ list.push(acc);
1465
+ accountId = acc.id;
1466
+ }
1467
+ });
1468
+ if (ih) logger.info('COOKIE-SITE', '{name} 身份归户({identity},账号轮换键生效)', { name: site.name, identity });
1469
+ logger.info('COOKIE-SITE', site.no10rSync ? '{name} cookie 捕获成功(探测通过,额度卡片已就绪)' : '{name} cookie 捕获成功(探测通过,同步到 10Router 后卡片点亮)', { name: site.name });
1470
+ // 捕获成功后只推当前站点的额度卡(单号精准推送,绝不全量刷屏)
1471
+ (async () => {
1472
+ try {
1473
+ if (site.id === 'tokenharbor') {
1474
+ await tenrouter.pushQuotaCards({ onlySources: ['tokenharbor'] });
1475
+ } else if (accountId) {
1476
+ const accs = await loadAccounts();
1477
+ const target = accs.find((x) => x.id === accountId);
1478
+ if (target) {
1479
+ let src = null;
1480
+ if (site.id === 'xiaomi-mimo') {
1481
+ const { buildXiaomiMimoQuotaPayload } = await import('./xiaomiMimoClient.js');
1482
+ src = buildXiaomiMimoQuotaPayload(target).source;
1483
+ } else if (site.id === 'river') {
1484
+ const { buildRiverQuotaPayload } = await import('./riverClient.js');
1485
+ src = buildRiverQuotaPayload(target).source;
1486
+ } else if (site.id === 'modelscope') {
1487
+ const { buildModelScopeQuotaPayload } = await import('./modelscopeClient.js');
1488
+ src = buildModelScopeQuotaPayload(target).source;
1489
+ } else if (site.id === 'longcat') {
1490
+ const { buildLongcatQuotaPayload } = await import('./longcatClient.js');
1491
+ src = buildLongcatQuotaPayload(target).source;
1492
+ }
1493
+ if (src) await tenrouter.pushQuotaCards({ onlySources: [src] });
1494
+ }
1495
+ }
1496
+ } catch (pushErr) {
1497
+ logger.warn('COOKIE-SITE', '{name} 捕获后单路推卡失败:{err}', { name: site.name, err: pushErr.message });
1498
+ }
1499
+ })().catch(() => {});
1500
+ return json(res, 200, { ok: true, accountId });
1501
+ }
1502
+
1503
+ // ── 就地重探:用已存会话 cookie 重跑「身份+余额+流水」并回填行(免登录窗重捕)──
1504
+ if (p.startsWith('/api/cookie-sites/') && p.endsWith('/reprobe') && method === 'POST') {
1505
+ const siteId = p.slice('/api/cookie-sites/'.length, -'/reprobe'.length);
1506
+ const site = getSite(siteId);
1507
+ if (!site) return json(res, 404, { error: `站点 ${siteId} 未配置` });
1508
+ const body = await readBody(req).catch(() => ({}));
1509
+ const onlyId = body?.accountId ? String(body.accountId) : null;
1510
+ const accounts = await loadAccounts();
1511
+ const targets = accounts.filter((a) => a.provider === site.provider && a.meta?.cookieSiteId === site.id
1512
+ && (!onlyId || a.id === onlyId));
1513
+ if (site.disabled === true) return json(res, 410, { error: '该站点已停用(issue #35),不再探测上游' });
1514
+ if (!targets.length) return json(res, 404, { error: '没有可重探的会话(先登录捕获一次)' });
1515
+ // 重探只推涉及的那几路 source(其余卡不打上游、不重推):
1516
+ // tokenharbor 是单例卡(source 固定);mimo/river/ms 按账号算 source;失败行也进名单
1517
+ // ——失败说明会话可能已死,恰好要把 message 状态卡推上去让 10R 卡面可见。
1518
+ const touchedSources = new Set();
1519
+ const sourceOf = async (a) => {
1520
+ if (site.id === 'tokenharbor') return 'tokenharbor';
1521
+ try {
1522
+ const m = await import(
1523
+ site.id === 'xiaomi-mimo' ? './xiaomiMimoClient.js'
1524
+ : site.id === 'river' ? './riverClient.js'
1525
+ : site.id === 'longcat' ? './longcatClient.js' : './modelscopeClient.js');
1526
+ const build = site.id === 'xiaomi-mimo' ? m.buildXiaomiMimoQuotaPayload
1527
+ : site.id === 'river' ? m.buildRiverQuotaPayload
1528
+ : site.id === 'longcat' ? m.buildLongcatQuotaPayload : m.buildModelScopeQuotaPayload;
1529
+ return build(a).source;
1530
+ } catch { return null; }
1531
+ };
1532
+ const results = [];
1533
+ for (const a of targets) {
1534
+ const touched = await sourceOf(a);
1535
+ if (touched) touchedSources.add(touched);
1536
+ const row = await reprobeCookieAccount(site, a);
1537
+ results.push(row);
1538
+ }
1539
+ // 重探后只同步涉及的额度卡(label/数字都可能变;失败行也已被纳入 touchedSources)
1540
+ if (touchedSources.size) tenrouter.pushQuotaCards({ onlySources: [...touchedSources] }).catch(() => {});
1541
+ return json(res, 200, { results });
1542
+ }
1543
+
1544
+ // ── 讯飞 Loomy 手机号验证码两步直登 ──
1545
+ if (p === '/api/loomy/sms/send' && method === 'POST') {
1546
+ const body = await readBody(req).catch(() => ({}));
1547
+ const { phone } = body || {};
1548
+ try {
1549
+ const { sendLoomySms } = await import('./loomyAuth.js');
1550
+ const r = await sendLoomySms(phone);
1551
+ return json(res, 200, r);
1552
+ } catch (e) {
1553
+ return json(res, 400, { error: e.message });
1554
+ }
1555
+ }
1556
+ if (p === '/api/loomy/sms/login' && method === 'POST') {
1557
+ const body = await readBody(req).catch(() => ({}));
1558
+ const { phone, code, msgid } = body || {};
1559
+ try {
1560
+ const { loginLoomyBySms, registerLoomyByPhone } = await import('./loomyAuth.js');
1561
+ let r;
1562
+ try {
1563
+ r = await loginLoomyBySms({ phone, code, msgid });
1564
+ } catch (loginErr) {
1565
+ // 未注册手机号服务端会拒登录:自动用同一条验证码注册后重试(新账号一条龙)。
1566
+ // 注册也失败时优先回原始登录错误(更贴近用户操作的真实原因)。
1567
+ try {
1568
+ await registerLoomyByPhone({ phone, code });
1569
+ logger.info('DAEMON', 'Loomy 新手机号已自动注册:{phone}', { phone: String(phone || '').replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') });
1570
+ } catch (regErr) {
1571
+ logger.warn('DAEMON', 'Loomy 自动注册未通过:{err}(原始登录错误:{loginErr})', { err: regErr.message, loginErr: loginErr.message });
1572
+ throw loginErr;
1573
+ }
1574
+ r = await loginLoomyBySms({ phone, code, msgid });
1575
+ }
1576
+ const phoneMasked = r.phone ? `${r.phone.slice(0, 3)}****${r.phone.slice(-4)}` : '';
1577
+ // 昵称尽力而为:认证层 baseInfo 失败不挡导入
1578
+ let nickname = null;
1579
+ try {
1580
+ const { fetchLoomyIdentity } = await import('./loomyAuth.js');
1581
+ nickname = (await fetchLoomyIdentity(r.session))?.nickname || null;
1582
+ } catch {}
1583
+ const result = await addAccount({
1584
+ provider: 'loomy',
1585
+ token: r.session,
1586
+ uid: r.userid,
1587
+ name: nickname ? `Loomy (${nickname})` : (phoneMasked ? `Loomy (${phoneMasked})` : `Loomy (${r.userid.slice(-6)})`),
1588
+ expiresAt: new Date(Date.now() + 14 * 86400e3).toISOString(),
1589
+ source: 'sms-login',
1590
+ meta: { phone: r.phone, loggedInAt: new Date().toISOString() },
1591
+ }, { trusted: true });
1592
+ const account = result.account;
1593
+ logger.info('DAEMON', 'Loomy 手机号直登成功:{name}{dup}', { name: account.name || account.uid, dup: result.duplicate ? '(已有账号,凭据已更新)' : '' });
1594
+ return json(res, 200, { success: true, duplicate: Boolean(result.duplicate), account: publicAccount(account) });
1595
+ } catch (e) {
1596
+ return json(res, 400, { error: e.message });
1597
+ }
1598
+ }
1599
+
1600
+ // ── 本机检测 / 凭据扫描 ──
1601
+ if (p === '/api/local/detect' && method === 'GET') {
1602
+ return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), loomy: detectLoomy(), legacy: detectInstalls() });
1603
+ }
1604
+ if (p === '/api/local/scan' && method === 'POST') {
1605
+ const existing = await loadAccounts();
1606
+ const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
1607
+ && (a.token === token || (uid && a.uid === uid)));
1608
+ const candidates = [];
1609
+ const errors = [];
1610
+ const addRecord = (record, extra) => {
1611
+ candidates.push({
1612
+ id: putCandidate({ token: record.token, record }),
1613
+ kind: 'app', provider: record.provider,
1614
+ name: record.name, email: record.email, uid: record.uid, phone: record.meta?.phone ? record.meta.phone.slice(0, 3) + '****' + record.meta.phone.slice(-4) : null,
1615
+ tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
1616
+ expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
1617
+ imported: known(record.provider, record.token, record.uid),
1618
+ ...extra,
1619
+ });
1620
+ };
1621
+ // 1) Qoder 客户端:解密 auth.v1.dat
1622
+ const app = await readQoderAppAccounts();
1623
+ errors.push(...app.errors);
1624
+ for (const c of app.accounts) {
1625
+ addRecord({
1626
+ provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
1627
+ refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
1628
+ // 存整份解密后的 auth.v1.dat:切换时要原样写回(里面除 token 还有客户端读的其它字段)
1629
+ meta: { qoderAuth: c.authJson, qoderAuthFile: c.file },
1630
+ }, { source: c.source });
1631
+ }
1632
+ // 2) WorkBuddy 客户端:当前会话 + 历史会话
1633
+ const wb = readWorkbuddySessions();
1634
+ errors.push(...wb.errors);
1635
+ for (const c of wb.accounts) {
1636
+ const { file: _f, fileTime: _t, current, ...record } = c;
1637
+ addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
1638
+ }
1639
+ // 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
1640
+ try {
1641
+ const z = zcodeLiveAccount();
1642
+ if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
1643
+ } catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
1644
+ // 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
1645
+ try {
1646
+ const m = await mirasimLiveAccount();
1647
+ if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
1648
+ } catch (e) {
1649
+ // MISSING_SECRET_KEY / DECRYPT_FAILED 的文案已在 mirasimLocal 里写清检查过的位置
1650
+ errors.push({ file: '~/.mirasim/setting.json', error: e.message });
1651
+ }
1652
+ // 5) 妙手客户端:解密 %APPDATA%\catpaw-moon\catx-credential.json 的当前登录
1653
+ try {
1654
+ const cp = await catpawLiveAccount();
1655
+ if (cp) addRecord(cp, { source: '妙手当前登录', current: true });
1656
+ } catch (e) { errors.push({ file: 'catpaw-moon/catx-credential.json', error: e.message }); }
1657
+ // 6) Trae 客户端:解密 <userData>\User\globalStorage\storage.json 的 tc 信封当前登录
1658
+ try {
1659
+ const tr = await traeLiveAccount();
1660
+ // 国际版客户端(信封 host 为 *.trae.ai):CD 的 Trae 线只打 api.trae.cn,导入只会有
1661
+ // 一张空额度卡——照常列出让用户可见,但带 intlWarning 由前端明确提示,不静默。
1662
+ if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true, ...(tr.meta?.isIntl ? { intlWarning: '检测到 Trae 国际版客户端(trae.ai)——CreditDaddy 的 Trae 线仅支持国内版(api.trae.cn),导入后额度与签到不可用' } : {}) });
1663
+ } catch (e) { errors.push({ file: 'TRAE/User/globalStorage/storage.json', error: e.message }); }
1664
+ // 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
1665
+ try {
1666
+ const mm = await minimaxLiveAccount();
1667
+ if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
1668
+ } catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
1669
+ // 8) 讯飞 Loomy 客户端:读取 Public\Loomy\userData\auth-session.json 当前登录
1670
+ try {
1671
+ const lm = await loomyLiveAccount();
1672
+ if (lm) addRecord(lm, { source: 'Loomy 当前登录', current: true });
1673
+ } catch (e) { errors.push({ file: 'loomy/auth-session.json', error: e.message }); }
1674
+ // 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
1675
+ const det = detectInstalls();
1676
+ const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
1677
+ if (det.cliDir.exists) dirs.push(det.cliDir.path);
1678
+ const legacy = await scanLocalTokens(dirs);
1679
+ for (const c of legacy.candidates) {
1680
+ candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
1681
+ }
1682
+ return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
1683
+ }
1684
+ if (p === '/api/local/import' && method === 'POST') {
1685
+ const body = await readBody(req);
1686
+ const cand = peekCandidate(String(body?.candidateId || ''));
1687
+ if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
1688
+ const record = cand.record || {
1689
+ provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
1690
+ token: cand.token, source: 'local-scan',
1691
+ };
1692
+ if (record.provider?.startsWith('workbuddy') && record.refreshToken) {
1693
+ try {
1694
+ const fresh = await refreshWorkbuddyToken(record);
1695
+ Object.assign(record, { token: fresh.token, refreshToken: fresh.refreshToken, expiresAt: fresh.expiresAt });
1696
+ } catch (e) {
1697
+ if (e.authInvalid) return json(res, 409, { error: e.message });
1698
+ return json(res, 502, { error: `WorkBuddy 本机凭据校验失败:${e.message}` });
1699
+ }
1700
+ }
1701
+ const { account, duplicate, updated } = await addAccount(
1702
+ { ...record, name: body.name || record.name || null },
1703
+ { trusted: Boolean(cand.record) },
1704
+ );
1705
+ if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
1706
+ // Trae 的登录态是 15 项文件快照而非单个凭据文件,导入时就建一次快照,否则该账号不可切换
1707
+ if (account.provider === 'trae') traeSnapshotLive();
1708
+ logger.info('DAEMON', updated ? '已用本机凭据更新:{name}' : '从本机导入账号:{name}', { name: account.name || account.id });
1709
+ return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
1710
+ }
1711
+
1712
+ // ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
1713
+ if (p === '/api/tenrouter' && method === 'GET') {
1714
+ return json(res, 200, await tenrouter.publicConfig());
1715
+ }
1716
+ if (p === '/api/tenrouter' && method === 'PUT') {
1717
+ const body = await readBody(req).catch(() => ({}));
1718
+ try {
1719
+ await tenrouter.updateConfig({
1720
+ endpoint: body?.endpoint,
1721
+ key: body?.key,
1722
+ adminPassword: body?.adminPassword,
1723
+ syncEnabled: body?.syncEnabled,
1724
+ sources: body?.sources,
1725
+ quotaPushEnabled: body?.quotaPushEnabled,
1726
+ quotaPushIntervalMin: body?.quotaPushIntervalMin,
1727
+ accountSyncEnabled: body?.accountSyncEnabled,
1728
+ accountSyncProviders: body?.accountSyncProviders,
1729
+ quotaPushDisabledProviders: body?.quotaPushDisabledProviders,
1730
+ });
1731
+ return json(res, 200, await tenrouter.publicConfig());
1732
+ } catch (e) { return json(res, 400, { error: e.message }); }
1733
+ }
1734
+ if (p === '/api/tenrouter' && method === 'DELETE') {
1735
+ await tenrouter.updateConfig({ endpoint: '' });
1736
+ return json(res, 200, await tenrouter.publicConfig());
1737
+ }
1738
+ if (p === '/api/tenrouter/test' && method === 'POST') {
1739
+ const body = await readBody(req).catch(() => ({}));
1740
+ let override;
1741
+ try {
1742
+ // 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
1743
+ if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
1744
+ if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
1745
+ } catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
1746
+ return json(res, 200, await tenrouter.testConnection(override));
1747
+ }
1748
+ if (p === '/api/tenrouter/quotas' && method === 'GET') {
1749
+ try {
1750
+ const force = url.searchParams.get('force') === '1';
1751
+ // 用户主动刷新额度时(force=1):先主动向 10Router 推一轮最新免费额度卡,再拉取全量额度
1752
+ if (force) {
1753
+ await tenrouter.pushQuotaCards().catch(() => {});
1754
+ }
1755
+ return json(res, 200, await tenrouter.fetchQuotas({ force }));
1756
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
1757
+ }
1758
+ if (p === '/api/tenrouter/health' && method === 'GET') {
1759
+ return json(res, 200, await tenrouter.fetchHealth());
1760
+ }
1761
+ if (p === '/api/tenrouter/sync' && method === 'POST') {
1762
+ const body = await readBody(req).catch(() => ({}));
1763
+ try {
1764
+ return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
1765
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
1766
+ }
1767
+ if (p === '/api/tenrouter/sync-accounts' && method === 'POST') {
1768
+ const body = await readBody(req).catch(() => ({}));
1769
+ try {
1770
+ // 请求里带了面板密码就先落盘(下次同步不必重填),否则用已保存的
1771
+ if (typeof body?.adminPassword === 'string' && body.adminPassword) {
1772
+ await tenrouter.updateConfig({ adminPassword: body.adminPassword });
1773
+ }
1774
+ const c = tenrouter.loadConfig();
1775
+ // 渠道开关(账号管理弹窗勾选):providers 空 = 不限;否则只推勾选的 CD provider
1776
+ const enabled = c.accountSync?.enabled !== false;
1777
+ if (!enabled) {
1778
+ return json(res, 200, { channel: 'disabled', results: [], summary: '账号同步已关闭(账号管理弹窗可重新开启)' });
1779
+ }
1780
+ const allow = Array.isArray(c.accountSync?.providers) ? c.accountSync.providers : [];
1781
+ const accounts = await loadAccounts();
1782
+ const filtered = allow.length ? accounts.filter((a) => allow.includes(a.provider)) : accounts;
1783
+ const r = await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: filtered });
1784
+ logger.info('DAEMON', '10Router 账号同步:{summary}', { summary: r.summary });
1785
+ return json(res, 200, r);
1786
+ } catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
1787
+ }
1788
+ // 额度卡推送(issue #24):手动触发一轮 external-quota 推送(面板「免费额度」页同步按钮附带)
1789
+ if (p === '/api/tenrouter/push-quota' && method === 'POST') {
1790
+ const body = await readBody(req).catch(() => ({}));
1791
+ try {
1792
+ // webSessionOnly:只推网页会话渠道(cookie-site 系)的额度卡——免费额度页
1793
+ // 「同步到 10Router」的口径,不被本机凭据线(如 minimax)搭车
1794
+ if (body?.webSessionOnly === true) {
1795
+ const accounts = await loadAccounts();
1796
+ const wsAccounts = accounts.filter((a) => a.meta?.cookieSiteId || a.provider === 'tokenharbor');
1797
+ const sources = new Set(['tokenharbor']); // tokenharbor 单一 source(聚合卡),无账号也可能要清旧卡
1798
+ for (const a of wsAccounts) {
1799
+ if (a.provider === 'xiaomi-mimo' || a.meta?.cookieSiteId === 'xiaomi-mimo') {
1800
+ const { buildXiaomiMimoQuotaPayload } = await import('./xiaomiMimoClient.js');
1801
+ sources.add(buildXiaomiMimoQuotaPayload(a).source);
1802
+ } else if (a.provider === 'river' || a.meta?.cookieSiteId === 'river') {
1803
+ const { buildRiverQuotaPayload } = await import('./riverClient.js');
1804
+ sources.add(buildRiverQuotaPayload(a).source);
1805
+ } else if (a.provider === 'modelscope' || a.meta?.cookieSiteId === 'modelscope') {
1806
+ const { buildModelScopeQuotaPayload } = await import('./modelscopeClient.js');
1807
+ sources.add(buildModelScopeQuotaPayload(a).source);
1808
+ } else if (a.provider === 'longcat' || a.meta?.cookieSiteId === 'longcat') {
1809
+ const { buildLongcatQuotaPayload } = await import('./longcatClient.js');
1810
+ sources.add(buildLongcatQuotaPayload(a).source);
1811
+ }
1812
+ }
1813
+ return json(res, 200, await tenrouter.pushQuotaCards({ onlySources: [...sources] }));
1814
+ }
1815
+ return json(res, 200, await tenrouter.pushQuotaCards());
1816
+ } catch (e) { return json(res, 500, { error: e.message, code: e.code }); }
1817
+ }
1818
+
1819
+ // Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
1820
+ if (p === '/api/qoder/umid' && method === 'GET') {
1821
+ return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
1822
+ }
1823
+ if (p === '/api/qoder/umid/install' && method === 'POST') {
1824
+ try {
1825
+ await installUmid();
1826
+ return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
1827
+ } catch (e) { return json(res, 500, { error: e.message }); }
1828
+ }
1829
+
1830
+ // 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
1831
+ if (p === '/api/logs' && method === 'GET') {
1832
+ const tag = url.searchParams.get('tag') || null;
1833
+ // ?history=1:读当日结构化归档(.jsonl),突破环形缓冲 300 条上限,按当前语言重渲染
1834
+ if (url.searchParams.get('history') === '1') {
1835
+ let lines = readArchiveJsonl({ limit: 2000 });
1836
+ lines = tag ? lines.filter((l) => l.tag === tag) : lines.filter((l) => !(l.tag || '').endsWith('-GW'));
1837
+ return json(res, 200, { logs: lines });
1838
+ }
1839
+ return json(res, 200, { logs: getLogs(200, tag) });
1840
+ }
1841
+
1842
+ // 状态
1843
+ if (p === '/api/status' && method === 'GET') {
1844
+ const accounts = await loadAccounts();
1845
+ const state = await loadState();
1846
+ // Qoder 当前登录(面板轮询每 30s 打一次,DPAPI 解密结果缓存 1 分钟)
1847
+ const qoder = await qoderCurrentInfo();
1848
+ // 旧账号没赶上「整份 auth 快照」的导入:本机 Qoder 有登录且账号库里有缺快照的同 uid 账号时,顺手回填(可逆、只在缺失时发生)
1849
+ const needBackfill = qoder.backfill.length
1850
+ && accounts.some((a) => (a.provider === 'qoder' || a.provider === 'qoder-cn')
1851
+ && a.uid && !a.meta?.qoderAuth
1852
+ && qoder.backfill.some((b) => b.provider === a.provider && String(b.uid) === String(a.uid)));
1853
+ if (needBackfill) {
1854
+ await withAccounts((list) => {
1855
+ for (const b of qoder.backfill) {
1856
+ const cur = list.find((a) => a.provider === b.provider && a.uid && b.uid && String(a.uid) === String(b.uid) && !a.meta?.qoderAuth);
1857
+ if (cur) cur.meta = { ...(cur.meta || {}), qoderAuth: b.authJson, qoderAuthFile: b.file };
1858
+ }
1859
+ }).catch((e) => logger.warn('DAEMON', '回填 Qoder 登录快照失败:{err}', { err: e.message }));
1860
+ }
1861
+ // 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
1862
+ const cpToken = currentCatpawToken();
1863
+ const mmToken = currentMiniMaxToken();
1864
+ const mmRecKey = currentMiniMaxRecordKey();
1865
+ // 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
1866
+ let mmLiveUid = null;
1867
+ if (mmToken) {
1868
+ mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
1869
+ }
1870
+ if (!mmLiveUid && mmRecKey) {
1871
+ // record-key 匹配可能命中多个(历史脏数据里本机导入与浏览器登录挂过同一个 key),
1872
+ // 此时用 uid 缓存的当前值收敛,避免随机取到「不是客户端当前登录」的那个账号。
1873
+ const byRec = accounts.filter((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey);
1874
+ if (byRec.length === 1) mmLiveUid = byRec[0].uid;
1875
+ else if (byRec.length > 1) {
1876
+ const mmCachedUid = currentMiniMaxUid();
1877
+ mmLiveUid = byRec.find((a) => a.uid === mmCachedUid)?.uid || null;
1878
+ }
1879
+ }
1880
+ if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
1881
+ const traeDet = detectTrae();
1882
+ return json(res, 200, {
1883
+ ok: true,
1884
+ app: 'CreditDaddy',
1885
+ version: APP_VERSION,
1886
+ build: loadBuildInfo() || undefined, // #21 构建身份(打包产物才有;无文件不返回该键)
1887
+ homepage: PROJECT_URL,
1888
+ accountsCount: accounts.length,
1889
+ dataDir: dataDir(),
1890
+ today: dayKey(),
1891
+ todayDone: state?.qoderDailyDone || {},
1892
+ todayByProduct: {
1893
+ qoder: dayKey(Date.now(), 'qoder'),
1894
+ workbuddy: dayKey(Date.now(), 'workbuddy'),
1895
+ trae: dayKey(Date.now(), 'trae'),
1896
+ minimax: dayKey(Date.now(), 'minimax'),
1897
+ },
1898
+ scheduler: await getSchedulerInfo(),
1899
+ riskIdentity: riskIdentityAvailable(),
1900
+ riskSource: riskIdentitySource(),
1901
+ umid: umidInfo(),
1902
+ platform: process.platform,
1903
+ workbuddyCurrentUid: currentWorkbuddyUid(),
1904
+ workbuddyClient: (() => { const d = detectWorkbuddy(); return { installed: d.installed, signedIn: d.signedIn, running: false }; })(),
1905
+ qoderCurrent: qoderCurrentIds(accounts, qoder),
1906
+ qoderCurrentToken: qoder.tokens,
1907
+ qoderCurrentUid: qoder.uids,
1908
+ qoderClient: { installed: qoder.apps.some((x) => x.installed), signedIn: qoder.apps.some((x) => x.signedIn), running: qoderRunning() },
1909
+ zcodeCurrentUid: currentZcodeUid(),
1910
+ zcodeCurrentIdentity: currentZcodeIdentity(),
1911
+ zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
1912
+ mirasimCurrentUid: currentMirasimUid(),
1913
+ mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1914
+ catpawCurrentUid: (cpToken && accounts.find((a) => a.provider === 'catpaw' && a.token === cpToken)?.uid) || null,
1915
+ catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1916
+ traeCurrentUid: traeDet.uid,
1917
+ traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
1918
+ minimaxCurrentUid: mmLiveUid,
1919
+ minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
1920
+ loomyCurrentUid: currentLoomyUid(),
1921
+ loomyClient: (() => { const d = detectLoomy(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
1922
+ keyRequired: panelKeyRequired(),
1923
+ });
1924
+ }
1925
+
1926
+ // Panel 设置:GET 读取(只回状态不回显密码),PUT 设置 / 修改 / 关闭访问密码
1927
+ if (p === '/api/settings' && method === 'GET') {
1928
+ return json(res, 200, { panelKeyEnabled: panelKeyRequired(), locale: getLocale() });
1929
+ }
1930
+ if (p === '/api/settings' && method === 'PUT') {
1931
+ const body = await readBody(req).catch(() => ({}));
1932
+ if (body?.locale !== undefined) {
1933
+ const locale = normalizeLocale(body.locale);
1934
+ setLocale(locale);
1935
+ await saveSettings({ locale });
1936
+ }
1937
+ // 全局自动签到 开关 / 频率(面板「设置 → 通用」)。不在这里提前 return:
1938
+ // 一个请求同时带 autoCheckin 和 panelKey 时,提前返回会让密码分支被静默跳过,
1939
+ // 面板以为改密成功、下次解锁却被旧密码挡住
1940
+ let resyncedCheckin = false;
1941
+ if (typeof body?.autoCheckin === 'boolean') { await saveSettings({ autoCheckin: body.autoCheckin }); resyncedCheckin = true; }
1942
+ if (body?.checkinIntervalMin !== undefined) {
1943
+ const min = Math.max(15, Math.min(1440, Number(body.checkinIntervalMin) || 120));
1944
+ await saveSettings({ checkinIntervalMin: min });
1945
+ resyncedCheckin = true;
1946
+ }
1947
+ if (resyncedCheckin) await resyncAutoCheckinScheduler();
1948
+ // 只有携带密码字段才走密码分支:纯语言/其它设置的请求此前会掉进
1949
+ // 「请提供新密码」400,面板 api() 抛错导致语言切换点了不刷新(i18n 切换失效根因)
1950
+ if (body?.panelKey !== undefined || body?.disable === true) return await handleSettingsPut(res, body);
1951
+ if (body?.locale !== undefined) return json(res, 200, { ok: true, panelKeyEnabled: panelKeyRequired(), locale: getLocale() });
1952
+ if (resyncedCheckin) return json(res, 200, { ok: true });
1953
+ return json(res, 400, { error: '请求未携带任何设置字段' });
1954
+ }
1955
+
1956
+ // 导出(加密口令必填——账号含 token,不允许再导出明文文件)
1957
+ if (p === '/api/export' && method === 'POST') {
1958
+ const body = await readBody(req).catch(() => ({}));
1959
+ const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
1960
+ const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
1961
+ const password = typeof body?.password === 'string' ? body.password : '';
1962
+ // 日志只记范围与条数——口令、token 一概不落
1963
+ const scope = provider ? (PROVIDER_LABEL[provider] || provider) : (product ? `${product}(全部版本)` : '全部账号');
1964
+ if (password.length < 4) {
1965
+ logger.warn('DAEMON', '导出被拒({scope}):未设置加密口令', { scope });
1966
+ return json(res, 400, { error: '导出必须设置加密口令(至少 4 位)', code: 'PASSWORD_REQUIRED' });
1967
+ }
1968
+ try {
1969
+ const accounts = await loadAccounts();
1970
+ const blob = exportAccounts(accounts, { password, provider, product });
1971
+ // 信封是密文、不带条数,用同参数重算一遍过滤结果记进日志
1972
+ logger.info('DAEMON', '导出账号({scope}):{n} 个(密文口令导出)', { scope, n: buildExportPayload(accounts, { provider, product }).accounts.length });
1973
+ return json(res, 200, blob);
1974
+ } catch (e) {
1975
+ if (e instanceof TransferError) {
1976
+ logger.warn('DAEMON', '导出失败({scope}):{code}', { scope, code: e.code });
1977
+ return json(res, 400, { error: e.message, code: e.code });
1978
+ }
1979
+ throw e;
1980
+ }
1981
+ }
1982
+
1983
+ // 导入
1984
+ if (p === '/api/import' && method === 'POST') {
1985
+ const body = await readBody(req);
1986
+ // 新格式 {data, password};兼容直接 POST 文件内容
1987
+ const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
1988
+ let parsed;
1989
+ try {
1990
+ parsed = parseImport(data, { password: body?.password });
1991
+ } catch (e) {
1992
+ if (e instanceof TransferError) {
1993
+ logger.warn('DAEMON', '导入失败:{code}({message})', { code: e.code, message: e.message });
1994
+ return json(res, 400, { error: e.message, code: e.code });
1995
+ }
1996
+ throw e;
1997
+ }
1998
+ const r = await importAccounts(parsed.accounts);
1999
+ const skipped = r.skipped + parsed.skipped;
2000
+ logger.info('DAEMON', '导入完成({source}):新增 {added},续期 {updated},跳过 {skipped}', { source: parsed.source, added: r.added, updated: r.updated, skipped });
2001
+ return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
2002
+ }
2003
+
2004
+ // 重启服务(桌面客户端或 CLI)
2005
+ if (p === '/api/system/restart' && method === 'POST') {
2006
+ json(res, 200, { ok: true, message: '正在重启服务…' });
2007
+ setTimeout(async () => {
2008
+ try {
2009
+ if (restartHandler) {
2010
+ await restartHandler();
2011
+ return;
2012
+ }
2013
+ // Node CLI 环境兜底重启:派生自身并退出当前进程
2014
+ const { spawn } = await import('node:child_process');
2015
+ spawn(process.execPath, process.argv.slice(1), {
2016
+ detached: true,
2017
+ stdio: 'inherit',
2018
+ env: process.env,
2019
+ }).unref();
2020
+ process.exit(0);
2021
+ } catch (err) {
2022
+ logger.error('DAEMON', '自动重启失败: {err}', { err: err?.message || err });
2023
+ }
2024
+ }, 400);
2025
+ return;
2026
+ }
2027
+
2028
+ return json(res, 404, { error: '未知接口' });
2029
+ }
2030
+
2031
+ let restartHandler = null;
2032
+ /** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
2033
+ export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
2034
+
2035
+ /** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
2036
+ export function getPanelKey() { return PANEL_KEY; }
2037
+ export function getSessionKey() { return PANEL_SESSION; }
2038
+ export function verifyPanelPassword(input) {
2039
+ if (PANEL_KEY_MODE === 'hash') return scryptMatches(String(input), PANEL_KEY_HASH);
2040
+ if (PANEL_KEY_MODE === 'plain') return keyMatches(String(input));
2041
+ return false;
2042
+ }
2043
+
2044
+ /** 出口代理地址(面板「网络出口(全局)」里存的,环境变量不算——登录窗显式设置用) */
2045
+ export function getLoginProxyUrl() { return proxyUrl() || null; }
2046
+
2047
+ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
2048
+ await initPanelKey();
2049
+ // 跨进程单实例守护(P0-3 层):daemon.json 记录 pid+port,端口探活信带——两个实例
2050
+ // 共用同一 dataDir 时 accounts/settings/state.json 的后写会覆盖前写(进程内串行队列
2051
+ // 不跨进程)。发现活实例直接拒启,不要悄悄抬到下一个端口共存。
2052
+ {
2053
+ const { statusRuntime } = await import('./bgdaemon.js');
2054
+ const cur = await statusRuntime().catch(() => null);
2055
+ if (cur && cur.running) {
2056
+ logger.warn('DAEMON', 'CreditDaddy 守护进程已在运行(pid {pid},{host}:{port})—— 拒绝再起实例共用同一数据目录', { pid: cur.pid, host: cur.host, port: cur.port });
2057
+ const err = new Error('CreditDaddy 守护进程已在运行(pid ' + cur.pid + ',' + (cur.host || '127.0.0.1') + ':' + cur.port + '),如需重启请先停止旧实例(CLI 可用 creditdaddy stop)');
2058
+ err.code = 'ALREADY_RUNNING';
2059
+ throw err;
2060
+ }
2061
+ }
2062
+ // 一次性清理:1.3.3 曾短暂支持 minimax-intl(MiniMax 国际版),实测其 API 侧对国内出口
2063
+ // 全链路 401(区域识别无法绕过),双版本策略已撤回——存量国际版账号移除,避免签到 /
2064
+ // 额度轮询对未知 provider 反复报错。仅在真的命中存量时落盘。
2065
+ {
2066
+ const list = await loadAccounts();
2067
+ const dead = list.filter((a) => a.provider === 'minimax-intl');
2068
+ if (dead.length) {
2069
+ await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'minimax-intl')); });
2070
+ logger.info('DAEMON', '已移除 {count} 个 MiniMax 国际版账号(该渠道已下线,额度接口对国内网络不可用)', { count: dead.length });
2071
+ }
2072
+ }
2073
+ // 一次性清理(issue #35):Inception Labs 线已整体删除(Cloudflare 对代理出口 /api/*
2074
+ // 持续 429),存量账号留着只会被签到/额度轮询反复打上游(401/429 报错刷屏)。命中才落盘。
2075
+ {
2076
+ const list = await loadAccounts();
2077
+ const dead = list.filter((a) => a.provider === 'inception');
2078
+ if (dead.length) {
2079
+ await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'inception')); });
2080
+ logger.info('DAEMON', '已移除 {count} 个 Inception Labs 会话(该渠道已删除,网页会话线不可用)', { count: dead.length });
2081
+ }
2082
+ }
2083
+ // 一次性清理(issue #40):码道 CodeArts 签到线已撤下(华为云控制台会话受闲置超时 +
2084
+ // WAF 指印绑定,本机无法续期,实测当日内即失效;详见 cookieSites.js 末尾说明)。
2085
+ // 存量账号必须清:provider 已从注册表移除,productOf 会回落到 qoder,
2086
+ // 于是它会在 Qoder 页显示成一条点不动、签不了、也删不掉的幽灵行。命中才落盘。
2087
+ {
2088
+ const list = await loadAccounts();
2089
+ const dead = list.filter((a) => a.provider === 'codearts');
2090
+ if (dead.length) {
2091
+ await withAccounts((l) => { l.splice(0, l.length, ...l.filter((a) => a.provider !== 'codearts')); });
2092
+ logger.info('DAEMON', '已移除 {count} 个码道 CodeArts 会话(该渠道已撤线,华为云会话无法保活)', { count: dead.length });
2093
+ }
2094
+ }
2095
+ let panelHtml = '';
2096
+ fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
2097
+
2098
+ const server = http.createServer(async (req, res) => {
2099
+ const url = new URL(req.url, 'http://localhost');
2100
+ try {
2101
+ const denied = rejectForeignRequest(req.headers, host, url.pathname);
2102
+ if (denied) {
2103
+ 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 || '' });
2104
+ return json(res, 403, { error: denied });
2105
+ }
2106
+ // ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
2107
+ // 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
2108
+ // gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
2109
+ // 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
2110
+ // 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
2111
+ if (
2112
+ url.pathname === '/gateway/zcode/v1/messages'
2113
+ || url.pathname === '/gateway/v1/messages'
2114
+ || url.pathname === '/v1/messages'
2115
+ ) {
2116
+ return await zcodeGateway.handleGateway(req, res);
2117
+ }
2118
+ // MiniMax Code 本地 Anthropic 兼容网关
2119
+ if (url.pathname === '/gateway/minimax/v1/messages') {
2120
+ return await minimaxGateway.handleGateway(req, res);
2121
+ }
2122
+ // Trae SOLO 本地 Anthropic 兼容网关
2123
+ if (url.pathname === '/gateway/trae/v1/messages') {
2124
+ return await traeGateway.handleGateway(req, res);
2125
+ }
2126
+ // 10Router 外配额卡「刷新配额」回调(自鉴权:HMAC 签名,不走面板密码门;浏览器直连需 CORS)
2127
+ if (url.pathname === '/api/quota-refresh') {
2128
+ res.setHeader('Access-Control-Allow-Origin', '*');
2129
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
2130
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
2131
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
2132
+ if (req.method !== 'POST') return json(res, 405, { error: '仅支持 POST' });
2133
+ const remote = remoteKeyAddr(req);
2134
+ const { verifyQuotaRefresh, logRefresh, refreshRateLimited } = await import('./quotaRefresh.js');
2135
+ if (refreshRateLimited(remote)) {
2136
+ logger.warn('10R', '外配额刷新回调限流 {remote}', { remote });
2137
+ return json(res, 429, { error: '刷新过于频繁,请稍后再试' });
2138
+ }
2139
+ const v = await verifyQuotaRefresh({
2140
+ source: url.searchParams.get('source'),
2141
+ exp: url.searchParams.get('exp'),
2142
+ sig: url.searchParams.get('sig'),
2143
+ });
2144
+ if (!v.ok) {
2145
+ logger.warn('10R', '外配额刷新回调签名无效({reason},source={source})', { reason: v.reason || '?', source: url.searchParams.get('source') || '—' });
2146
+ return json(res, 401, { error: '签名校验失败' });
2147
+ }
2148
+ const source = String(url.searchParams.get('source'));
2149
+ logRefresh(source);
2150
+ // 只推这一路 source:现拉余额 → 推卡(载荷重新签发新 refreshUrl,签名滚动)→ 返回结果
2151
+ const { runQuotaPush } = await import('./externalQuota.js');
2152
+ const cfg = tenrouter.loadConfig();
2153
+ if (!cfg.endpoint || !cfg.key) return json(res, 503, { error: '未配置 10Router 地址与虚拟 key' });
2154
+ // 已排除的渠道不回源(10R 端不该再留着这张卡——留给用户侧删卡;这里只拒绝刷新)
2155
+ const disabled = Array.isArray(cfg.quotaPushDisabledProviders) ? cfg.quotaPushDisabledProviders : [];
2156
+ const sourceFamily = /^(minimax-free|xiaomi-mimo|river|modelscope|longcat|tokenharbor)-/.exec(source);
2157
+ const familyProvider = sourceFamily ? sourceFamily[1] : (source === 'tokenharbor' ? 'tokenharbor' : null);
2158
+ if (familyProvider && disabled.includes(familyProvider)) {
2159
+ return json(res, 410, { error: '该渠道的额度卡推送已关闭(CreditDaddy 账号管理弹窗可重新开启)' });
2160
+ }
2161
+ const accounts = await loadAccounts();
2162
+ const r = await runQuotaPush({ endpoint: cfg.endpoint, key: cfg.key, accounts, fetchImpl: undefined, onlySource: source, noProbeMark: true });
2163
+ return json(res, 200, { ok: true, results: r.results });
2164
+ }
2165
+ if (url.pathname.startsWith('/api/')) {
2166
+ return await handleApi(req, res, url);
2167
+ }
2168
+ // Web 面板(未设密码时同 API 口径 fail-closed)
2169
+ if (url.pathname === '/' || url.pathname === '/index.html') {
2170
+ if (!panelKeyRequired()) {
2171
+ const remote = (req.socket && req.socket.remoteAddress) || '';
2172
+ if (!isLoopbackSocketAddr(remote)) {
2173
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
2174
+ return res.end('CreditDaddy:未设置面板访问密码时,面板仅允许本机访问——请在本机面板「设置 → 通用 → 面板访问密码」设置密码后再从局域网访问');
2175
+ }
2176
+ }
2177
+ if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
2178
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
2179
+ return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
2180
+ }
2181
+ // 静态资产(仅捐赠二维码三张 SVG;白名单精确匹配,面板密码门与面板一致)
2182
+ if (url.pathname.startsWith('/assets/donate-')) {
2183
+ const file = {
2184
+ '/assets/donate-alipay.svg': 'donate-alipay.svg', '/assets/donate-ifdian.svg': 'donate-ifdian.svg', '/assets/donate-wechat.svg': 'donate-wechat.svg',
2185
+ }[url.pathname];
2186
+ if (!file) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
2187
+ if (!panelKeyRequired()) {
2188
+ const remote = (req.socket && req.socket.remoteAddress) || '';
2189
+ if (!isLoopbackSocketAddr(remote)) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Forbidden'); }
2190
+ }
2191
+ try {
2192
+ const svg = await fs.readFile(path.join(__dirname, 'assets', file));
2193
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' });
2194
+ return res.end(svg);
2195
+ } catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
2196
+ }
2197
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
2198
+ res.end('Not Found');
2199
+ } catch (err) {
2200
+ const status = err instanceof HttpError ? err.status : 500;
2201
+ if (status >= 500) logger.error('DAEMON', '请求处理失败:{err}', { err: err?.message || err });
2202
+ if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
2203
+ }
2204
+ });
2205
+
2206
+ return new Promise((resolve, reject) => {
2207
+ const startPort = pickBindablePort(Number(port) || DEFAULT_PORT);
2208
+ let attempts = 0;
2209
+ let triedFallback = false;
2210
+ const bind = (p) => {
2211
+ // 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
2212
+ const onError = (err) => {
2213
+ server.off('listening', onListening);
2214
+ const code = err && err.code;
2215
+ // EACCES = 落进系统保留段(WSL2 镜像网络 / Hyper-V excludedportrange),EADDRINUSE = 被别的进程占
2216
+ const blocked = code === 'EADDRINUSE' || code === 'EACCES';
2217
+ if (blocked && attempts < 100 && !triedFallback) {
2218
+ attempts += 1;
2219
+ logger.warn('DAEMON', '端口 {port} 不可用({code}),改试 {next}', { port: p, code: code || '未知', next: p + 1 });
2220
+ server.close();
2221
+ setTimeout(() => bind(p + 1), 30);
2222
+ } else if (blocked && !triedFallback) {
2223
+ // 起始端口起整段都不可用(保留段异常大):回退 OS 随机空闲端口,端口记进 daemon.json
2224
+ triedFallback = true;
2225
+ logger.warn('DAEMON', '端口 {start}~{end} 均不可用,回退系统随机空闲端口', { start: startPort, end: p });
2226
+ server.close();
2227
+ setTimeout(() => bind(0), 30);
2228
+ } else {
2229
+ // 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
2230
+ // 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
2231
+ const msg = err?.message || String(err);
2232
+ logger.error('DAEMON', '监听失败:{msg}', { msg });
2233
+ reject(new Error(`无法监听 ${host}:${p}:${msg}`));
2234
+ }
2235
+ };
2236
+ const onListening = () => {
2237
+ server.off('error', onError);
2238
+ const bound = server.address().port;
2239
+ logger.info('DAEMON', 'CreditDaddy 守护进程已启动:http://{host}:{port}', { host, port: bound });
2240
+ if ((host === '0.0.0.0' || host === '::') && !panelKeyRequired()) {
2241
+ logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥', {});
2242
+ }
2243
+ resolve({ server, port: bound });
2244
+ };
2245
+ server.once('error', onError);
2246
+ server.once('listening', onListening);
2247
+ server.listen(p, host);
2248
+ };
2249
+ bind(startPort);
2250
+ });
2251
+ }