creditdaddy 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,14 +15,31 @@ import {
15
15
  buildQoderHeaders, buildExchangeHeaders, FETCH_TIMEOUT_MS, webBase,
16
16
  } from './constants.js';
17
17
  import { getRiskIdentity, clientVersion, machineOs, machineHostname, machineId } from './qoderApp.js';
18
+ import { fetchJsonRace } from './zcodeClient.js';
18
19
  import { logger } from './logger.js';
20
+ import { t } from './i18n.js';
19
21
 
20
22
  export function apiBase(provider) {
21
23
  return provider === 'qoder-cn' ? CN_OPENAPI_BASE : OPENAPI_BASE;
22
24
  }
23
25
 
26
+ // 所有 openapi / web 请求统一走 fetchJsonRace(连接段限时 + 配置了出口代理时直连/代理双路兜底)——
27
+ // 此前裸 fetch:面板「网络出口 = 代理优先」对 Qoder 所有调用完全无效(审计 M2 漏网)。
28
+ // options.preferProxy:国际版(按 IP 识别地区)由调用方给 true。
24
29
  async function req(url, options = {}) {
25
- return fetch(url, { ...options, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
30
+ const { preferProxy = false, ...rest } = options;
31
+ return fetchJsonRace(url, { ...rest, timeoutMs: FETCH_TIMEOUT_MS, preferProxy });
32
+ }
33
+
34
+ /**
35
+ * 网页会话链路(探测归属 / 逐资源包用量)专用:国际版接口按 IP 识别地区,国内直连
36
+ * qoder.com 会被拦——登录窗有系统代理能登录成功,daemon 拿到 Cookie 后直连探测却失败,
37
+ * Cookie 被当无效丢弃,徽章「缺网页会话」常驻。配置了出口代理时国际版优先走代理
38
+ * (直连兜底),国内版保持直连不动。
39
+ */
40
+ async function webReq(provider, url, options = {}) {
41
+ if (provider === 'qoder-cn') return req(url, options);
42
+ return fetchJsonRace(url, { ...options, timeoutMs: options.timeoutMs ?? FETCH_TIMEOUT_MS, preferProxy: true });
26
43
  }
27
44
 
28
45
  /** PAT (pt-...) 兑换短期 job token (jt-...)。普通 JSON POST,无需 COSY 签名。 */
@@ -32,6 +49,7 @@ export async function exchangePatToJobToken(pat, isCn = false) {
32
49
  method: 'POST',
33
50
  headers: buildExchangeHeaders(),
34
51
  body: JSON.stringify({ personal_token: pat }),
52
+ preferProxy: !isCn,
35
53
  });
36
54
  if (!res.ok) {
37
55
  const text = await res.text().catch(() => '');
@@ -63,6 +81,7 @@ export async function fetchUserinfo(account) {
63
81
  if (!token) throw new Error('token 不可用');
64
82
  const res = await req(`${apiBase(account.provider)}${USERINFO_PATH}`, {
65
83
  headers: buildQoderHeaders(token),
84
+ preferProxy: account.provider === 'qoder',
66
85
  });
67
86
  if (!res.ok) throw new Error(`userinfo HTTP ${res.status}`);
68
87
  return res.json();
@@ -74,6 +93,7 @@ export async function fetchQuotaUsage(account) {
74
93
  if (!token) throw new Error('token 不可用');
75
94
  const res = await req(`${apiBase(account.provider)}${QUOTA_USAGE_PATH}`, {
76
95
  headers: buildQoderHeaders(token),
96
+ preferProxy: account.provider === 'qoder',
77
97
  });
78
98
  if (res.status === 401 || res.status === 403) throw new Error(`鉴权失败 (HTTP ${res.status}),token 可能已过期`);
79
99
  if (!res.ok) throw new Error(`quota HTTP ${res.status}`);
@@ -91,7 +111,7 @@ function webHeaders(cookie, provider) {
91
111
 
92
112
  /** 用原始 Cookie 探测网页会话属于哪个 Qoder 用户(登录窗口抓来的 Cookie 按 uid 归到账号上) */
93
113
  export async function probeWebSession(provider, cookie) {
94
- const res = await req(`${webBase(provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, provider) });
114
+ const res = await webReq(provider, `${webBase(provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, provider) });
95
115
  if (!res.ok) return null;
96
116
  const data = await res.json().catch(() => null);
97
117
  return data?.user_id ? data : null;
@@ -108,7 +128,7 @@ export async function probeWebSession(provider, cookie) {
108
128
  export async function fetchUsageDetail(account) {
109
129
  const cookie = String(account.meta?.qoderWebSession?.cookie || '').trim();
110
130
  if (!cookie) return null;
111
- const res = await req(`${webBase(account.provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, account.provider) });
131
+ const res = await webReq(account.provider, `${webBase(account.provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, account.provider) });
112
132
  if (res.status === 401 || res.status === 403) {
113
133
  const err = new Error(`网页会话已失效 (HTTP ${res.status})`);
114
134
  err.auth = true;
@@ -191,7 +211,7 @@ export async function checkinOne(account) {
191
211
 
192
212
  // 2) 带客户端身份 + 设备风控身份重新拉取(国际版只有这样才会出现每日积分活动)
193
213
  const { headers, risk, riskError } = await buildCampaignHeaders(account, token, uid);
194
- if (riskError) logger.debug('CHECKIN', `${label}:${riskError}`);
214
+ if (riskError) logger.debug('CHECKIN', '{label}:{err}', { label, err: riskError });
195
215
  const { campaigns, uid: uid2 } = (risk || !first) ? await getCampaigns(apiBase(account.provider), headers) : first;
196
216
  uid = uid2 || uid;
197
217
 
package/src/qoderUmid.js CHANGED
@@ -18,6 +18,7 @@ import zlib from 'node:zlib';
18
18
  import { execFile } from 'node:child_process';
19
19
  import { dataDir } from './store.js';
20
20
  import { logger } from './logger.js';
21
+ import { t } from './i18n.js';
21
22
 
22
23
  const PKG = '@qoder-ai/qodercli';
23
24
  const REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org'];
@@ -154,7 +155,7 @@ async function doInstall() {
154
155
  throw new Error(`镜像版本(${meta.version})与 npmjs(${authority.version})不一致`);
155
156
  }
156
157
  const expected = authority && authority.version === meta.version ? authority.integrity : integrity;
157
- logger.info('UMID', `下载 ${PKG}@${meta.version}(${registry})`);
158
+ logger.info('UMID', '下载 {pkg}@{version}({registry})', { pkg: PKG, version: meta.version, registry });
158
159
  const tgz = await fetchBuf(tarball);
159
160
  if (!checkIntegrity(tgz, expected)) throw new Error('下载内容与 npm integrity 不一致');
160
161
  const bundle = tarEntry(zlib.gunzipSync(tgz), BUNDLE_ENTRY);
@@ -173,11 +174,11 @@ async function doInstall() {
173
174
  installedAt: new Date().toISOString(),
174
175
  };
175
176
  fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
176
- logger.info('UMID', `设备身份组件已安装:qodercli ${meta.version} / ${process.arch}`);
177
+ logger.info('UMID', '设备身份组件已安装:qodercli {version} / {arch}', { version: meta.version, arch: process.arch });
177
178
  return { ...manifest, path: binPath() };
178
179
  } catch (e) {
179
180
  errors.push(`${registry}:${e.message}`);
180
- logger.warn('UMID', `从 ${registry} 安装失败:${e.message}`);
181
+ logger.warn('UMID', '从 {registry} 安装失败:{err}', { registry, err: e.message });
181
182
  }
182
183
  }
183
184
  throw new Error('设备身份组件安装失败 —— ' + errors.join(';'));
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 凭据刷新公共守卫(所有 provider 的 401-刷新通路共用)。
3
+ *
4
+ * refreshToken 是一次性的、刷新即轮换:两路并发各自拿同一条链去刷新,必然一个成功一个
5
+ * invalid_grant——面板每 2 分钟一轮额度查询和每小时签到轮曾因此互相误伤。两个护栏:
6
+ *
7
+ * 1) in-flight 合并:同一账号的并发刷新只跑一次 refreshFn,后到者共享同一份结果;
8
+ * 2) 失效链熔断:某条 refreshToken 被服务端作废(401 / invalid_grant / 12153),
9
+ * 在它被换新(重新登录、本机导入、或刷新成功后链自然更新)之前不再自动重试,
10
+ * 也不会再打出几十上百次无效请求(2026-10-09 实记录 400+ 次)。
11
+ *
12
+ * 消 bug 的调用约定:
13
+ * - refreshFn 内部可以再写 account(把新 token/refreshToken 落回对象),调用方负责持久化;
14
+ * - 认证失效的判定:err.authInvalid === true 或 err.auth === true(cold 有 provider 用两种标记);
15
+ * - deadMessage / deadNotice 均为可被 i18n 直接命中的字符串模板。
16
+ */
17
+
18
+ const inFlight = new Map(); // accountKey → Promise(共享给并发的后来者)
19
+ const deadFp = new Map(); // accountKey → 已被服务端作废的 refreshToken
20
+
21
+ function keyOf(account) {
22
+ return String(account.id || account.refreshToken || account.token || 'anon');
23
+ }
24
+
25
+ /** 该账号当前持有的 refreshToken 是否已被记为失效链。凭据换新(链指纹变化)自动放行。 */
26
+ export function refreshFpDead(account) {
27
+ const fp = account.refreshToken;
28
+ return typeof fp === 'string' && fp.length > 0 && deadFp.get(keyOf(account)) === fp;
29
+ }
30
+
31
+ /** 测试专用:清空进程内守卫状态 */
32
+ export function __resetRefreshGuardForTests() {
33
+ inFlight.clear();
34
+ deadFp.clear();
35
+ }
36
+
37
+ /**
38
+ * @param {object} account 账号(至少含 id / refreshToken;refreshFn 要拿的都是它字段)
39
+ * @param {(account: object) => Promise<object>} refreshFn 真刷新:成功后返回新凭据
40
+ * @param {{ log?: (msg: string) => void | string, deadMessage?: string, deadNotice?: string }} [ctx]
41
+ */
42
+ export function withRefreshDedupe(account, refreshFn, ctx = {}) {
43
+ const key = keyOf(account);
44
+ const inflight = inFlight.get(key);
45
+ if (inflight) return inflight;
46
+
47
+ const p = (async () => {
48
+ if (refreshFpDead(account)) {
49
+ const err = new Error(ctx.deadMessage || '凭据链已被服务端作废,等待重新登录或重新导入该账号');
50
+ err.authInvalid = true;
51
+ err.dead = true;
52
+ throw err; // 静默快拒:不再打服务端,不重复刷日志(首次失效时已提示)
53
+ }
54
+ try {
55
+ return await refreshFn(account);
56
+ } catch (err) {
57
+ if ((err?.authInvalid || err?.auth) && typeof account.refreshToken === 'string' && account.refreshToken.length > 0) {
58
+ deadFp.set(key, account.refreshToken);
59
+ if (ctx.deadNotice !== undefined && ctx.log) {
60
+ try { ctx.log(ctx.deadNotice || '凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)'); } catch {}
61
+ }
62
+ }
63
+ throw err;
64
+ }
65
+ })().finally(() => inFlight.delete(key));
66
+
67
+ inFlight.set(key, p);
68
+ return p;
69
+ }
package/src/store.js CHANGED
@@ -85,6 +85,17 @@ async function readJson(file, fallback) {
85
85
  return JSON.parse(await fs.readFile(file, 'utf8'));
86
86
  } catch (e) {
87
87
  if (e.code === 'ENOENT') return fallback;
88
+ // 主文件损坏/截断(断电半写、杀软抖动)时,写侧维护了一份同目录 .bak——先试它,
89
+ // 不至于直接让守护起不来;仍失败才抛出原(保留报错方向,不静默重置)。
90
+ if (e instanceof SyntaxError || e.name === 'SyntaxError') {
91
+ const bak = file + '.bak';
92
+ try {
93
+ const bakText = await fs.readFile(bak, 'utf8');
94
+ const parsed = JSON.parse(bakText);
95
+ console.error('readJson: 主文件损坏,已用备份恢复 ' + bak);
96
+ return parsed;
97
+ } catch { /* 没有备份或备份同坏——按原错误上抛 */ }
98
+ }
88
99
  throw new Error(`读取 ${file} 失败: ${e.message}`);
89
100
  }
90
101
  }
package/src/tenrouter.js CHANGED
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import crypto from 'node:crypto';
14
14
  import { dataDir } from './store.js';
15
15
  import { logger } from './logger.js';
16
+ import { t } from './i18n.js';
16
17
  import { SOURCES, SOURCE_LABEL, collectSource, selectSince, detectSources } from './usageSync.js';
17
18
 
18
19
  const FILE = () => path.join(dataDir(), 'tenrouter.json');
@@ -41,17 +42,18 @@ export function loadConfig() {
41
42
  } catch { return structuredClone(DEFAULT_CONFIG); }
42
43
  }
43
44
 
44
- function saveConfig(c) {
45
+ // 配置落盘走 store.atomicWrite:Windows 上杀软/同步盘瞬时占文件会直接 EPERM,
46
+ // 裸 renameSync 会整路抛到 API 500;atomicWrite 内置占用重试与直写兜底
47
+ async function saveConfig(c) {
45
48
  fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
46
- const tmp = `${FILE()}.${process.pid}.${crypto.randomUUID()}.tmp`;
47
- fs.writeFileSync(tmp, JSON.stringify(c, null, 2), { mode: 0o600 });
48
- fs.renameSync(tmp, FILE());
49
+ const { atomicWrite } = await import('./store.js');
50
+ await atomicWrite(FILE(), JSON.stringify(c, null, 2));
49
51
  }
50
52
 
51
53
  // 读改写串行,避免面板保存与后台同步互相覆盖
52
54
  let chain = Promise.resolve();
53
55
  function withConfig(fn) {
54
- const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); saveConfig(c); return r; });
56
+ const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); await saveConfig(c); return r; });
55
57
  chain = run.catch(() => {});
56
58
  return run;
57
59
  }
@@ -252,7 +254,12 @@ async function doSync({ dryRun, trigger }) {
252
254
  : `同步完成:新增 ${imported} 行,跳过 ${skippedDup} 行重复`;
253
255
  if (!dryRun) {
254
256
  await withConfig((cc) => { cc.lastSync = { at: new Date().toISOString(), trigger, summary, results }; });
255
- (failed.length ? logger.warn : logger.info).call(logger, '10R', summary);
257
+ // 日志走 key+args(按界面语言实时重渲染);summary 原文继续供面板展示
258
+ const logTpl = failed.length
259
+ ? '同步完成:新增 {imported} 行,跳过 {skipped} 行重复,{failed} 个来源失败({detail})'
260
+ : '同步完成:新增 {imported} 行,跳过 {skipped} 行重复';
261
+ (failed.length ? logger.warn : logger.info).call(logger, '10R', logTpl,
262
+ { imported, skipped: skippedDup, failed: failed.length, detail: failed.map((r) => r.label + ':' + r.error).join(';') });
256
263
  }
257
264
  return { summary, results };
258
265
  }
@@ -263,7 +270,7 @@ export function startUsageSyncScheduler() {
263
270
  const tick = () => {
264
271
  const c = loadConfig();
265
272
  if (!isConfigured(c) || !c.sync.enabled) return;
266
- runUsageSync({ trigger: 'auto' }).catch((e) => logger.warn('10R', '自动同步失败:' + e.message));
273
+ runUsageSync({ trigger: 'auto' }).catch((e) => logger.warn('10R', '自动同步失败:{err}', { err: e.message }));
267
274
  };
268
275
  setTimeout(tick, 60_000).unref?.(); // 启动 1 分钟后先跑一轮
269
276
  timer = setInterval(tick, SYNC_INTERVAL_MS);
@@ -17,6 +17,7 @@ import { loadAccounts, loadSettings, saveSettings } from './store.js';
17
17
  import { fetchTraeQuota } from './traeClient.js';
18
18
  import { gatewayHostSuggestion } from './tenrouter.js';
19
19
  import { logger, summarizeAttempts } from './logger.js';
20
+ import { t } from './i18n.js';
20
21
 
21
22
  const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
22
23
  const ACCOUNT_COOLING_MS = 5 * 60_000;
@@ -44,7 +45,8 @@ export async function setGatewayEnabled(v) {
44
45
 
45
46
  const cooling = new Map(); // accountId → 冷却截止(ms)
46
47
  const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
47
- const weights = new Map(); // accountId → 上次查到的剩余积分
48
+ const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
49
+ const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
48
50
 
49
51
  export const stats = {
50
52
  lastCallAt: null,
@@ -73,7 +75,7 @@ function isDead(account) {
73
75
  if (fp === credFingerprint(account)) return true;
74
76
  dead.delete(account.id);
75
77
  cooling.delete(account.id);
76
- logger.info('TRAE-GW', `${account.name || account.uid || account.id} 凭据已更新,解除拉黑`);
78
+ logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
77
79
  return false;
78
80
  }
79
81
 
@@ -88,16 +90,17 @@ export function __resetForTests() {
88
90
  }
89
91
 
90
92
  async function accountWeight(a) {
91
- if (!weights.has(a.id)) {
92
- try {
93
- const q = await fetchTraeQuota(a);
94
- const remaining = Number(q?.remaining) || 0;
95
- weights.set(a.id, Math.max(MIN_WEIGHT, remaining));
96
- } catch {
97
- weights.set(a.id, MIN_WEIGHT);
98
- }
93
+ const hit = weights.get(a.id);
94
+ if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
95
+ let w = hit?.w ?? MIN_WEIGHT;
96
+ try {
97
+ const q = await fetchTraeQuota(a);
98
+ w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
99
+ } catch {
100
+ // 额度查询失败保留上次快照权重(网络抖动不该把主力踢成保底权重)
99
101
  }
100
- return weights.get(a.id);
102
+ weights.set(a.id, { w, at: Date.now() });
103
+ return w;
101
104
  }
102
105
 
103
106
  const swrr = new Map();
@@ -161,7 +164,13 @@ function remoteAllowed(remote, allowList) {
161
164
  return (Array.isArray(allowList) ? allowList : []).some((entry) => {
162
165
  const e = String(entry || '').trim();
163
166
  if (!e) return false;
164
- if (e.endsWith('*')) return host.toLowerCase().startsWith(e.slice(0, -1).toLowerCase());
167
+ if (e.endsWith('*')) {
168
+ // 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
169
+ // 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
170
+ const prefix = e.slice(0, -1);
171
+ if (!prefix.endsWith('.')) return false;
172
+ return host.toLowerCase().startsWith(prefix.toLowerCase());
173
+ }
165
174
  return host.toLowerCase() === e.toLowerCase();
166
175
  });
167
176
  }
@@ -362,7 +371,7 @@ export async function handleGateway(req, res) {
362
371
  const cur = list.find((a) => a.id === account.id);
363
372
  if (cur) cur.token = token;
364
373
  });
365
- logger.info('TRAE-GW', `${label} 凭据已对齐本地客户端,重试会话创建…`);
374
+ logger.info('TRAE-GW', '{label} 凭据已对齐本地客户端,重试会话创建...', { label });
366
375
  sessionRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions`, {
367
376
  method: 'POST',
368
377
  headers: makeHeaders(token),
@@ -383,14 +392,14 @@ export async function handleGateway(req, res) {
383
392
  if (sessionRes.status === 401) {
384
393
  markDead(account);
385
394
  attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
386
- logger.info('TRAE-GW', `${label} 凭据失效,拉黑`);
395
+ logger.info('TRAE-GW', '{label} 凭据失效,拉黑', { label });
387
396
  continue;
388
397
  }
389
398
 
390
399
  if (sessionRes.status === 429) {
391
400
  markCooling(account.id, ACCOUNT_COOLING_MS);
392
401
  attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
393
- logger.info('TRAE-GW', `${label} 429 限流,冷却 5 分钟`);
402
+ logger.info('TRAE-GW', '{label} 429 限流,冷却 5 分钟', { label });
394
403
  continue;
395
404
  }
396
405
 
@@ -415,7 +424,9 @@ export async function handleGateway(req, res) {
415
424
  // 获取 events 流
416
425
  let eventsRes;
417
426
  try {
418
- eventsRes = await fetch(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}/events?reply_to_message_id=${encodeURIComponent(messageId)}`, {
427
+ // 走 fetchJsonRace:连接段 15s 限时 + 统一代理 / 直连兜底与会话请求自洽(此前裸 fetch——
428
+ // 上游黑洞时一直挂到客户端断开,也整体不走面板配置的出口代理,不是统一的接口设计)
429
+ eventsRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}/events?reply_to_message_id=${encodeURIComponent(messageId)}`, {
419
430
  headers: {
420
431
  Authorization: token.startsWith('Cloud-IDE-JWT ') ? token : `Cloud-IDE-JWT ${token.trim()}`,
421
432
  Accept: 'text/event-stream',
@@ -423,6 +434,8 @@ export async function handleGateway(req, res) {
423
434
  Referer: 'https://solo.trae.cn/',
424
435
  },
425
436
  signal: clientAbort.signal,
437
+ timeoutMs: UPSTREAM_TIMEOUT_MS,
438
+ connectMs: 15_000,
426
439
  });
427
440
  } catch (e) {
428
441
  cleanupSession();
@@ -438,7 +451,7 @@ export async function handleGateway(req, res) {
438
451
  }
439
452
 
440
453
  stats.lastAccount = label;
441
- logger.debug('TRAE-GW', `${label} 连接成功,开始流式输出 (${requestedModel})`);
454
+ logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
442
455
 
443
456
  // 成功建立流,按 isStream 分发
444
457
  if (isStream) {
@@ -482,10 +495,11 @@ export async function handleGateway(req, res) {
482
495
  const decoder = new TextDecoder();
483
496
  let buffer = '';
484
497
 
498
+ let streamCompleted = false;
485
499
  try {
486
500
  while (true) {
487
501
  const { done, value } = await reader.read();
488
- if (done) break;
502
+ if (done) { streamCompleted = true; break; }
489
503
  buffer += decoder.decode(value, { stream: true });
490
504
  const lines = buffer.split('\n');
491
505
  buffer = lines.pop();
@@ -550,7 +564,7 @@ export async function handleGateway(req, res) {
550
564
  }
551
565
  } catch (err) {
552
566
  // 客户端主动断开(点停止/换一句)属正常收尾,不是故障
553
- logger.debug('TRAE-GW', `流式读取中断: ${err.message}`);
567
+ logger.debug('TRAE-GW', '流式读取中断:{err}', { err: err.message });
554
568
  } finally {
555
569
  if (thinkingOpen) {
556
570
  sendSse('content_block_stop', { type: 'content_block_stop', index: thinkingIndex });
@@ -569,9 +583,13 @@ export async function handleGateway(req, res) {
569
583
  if (inputTokens > 0) deltaUsage.input_tokens = inputTokens;
570
584
  if (cacheReadTokens > 0) deltaUsage.cache_read_input_tokens = cacheReadTokens;
571
585
 
586
+ // 上游没正常收尾(断流 / EOF / reader 抛错)时,stop_reason 别伪装成 end_turn——
587
+ // 客户端把截断的字当完整答案就再也看不见了(两个分支都报 interrupted)。
588
+ const stopReason = streamCompleted ? 'end_turn' : 'interrupted';
589
+
572
590
  sendSse('message_delta', {
573
591
  type: 'message_delta',
574
- delta: { stop_reason: 'end_turn', stop_sequence: null },
592
+ delta: { stop_reason: stopReason, stop_sequence: null },
575
593
  usage: deltaUsage,
576
594
  });
577
595
  sendSse('message_stop', { type: 'message_stop' });
@@ -587,11 +605,12 @@ export async function handleGateway(req, res) {
587
605
  let fullThinking = '';
588
606
  let fullText = '';
589
607
  let usageInfo = null;
608
+ let streamCompleted = false;
590
609
 
591
610
  try {
592
611
  while (true) {
593
612
  const { done, value } = await reader.read();
594
- if (done) break;
613
+ if (done) { streamCompleted = true; break; }
595
614
  buffer += decoder.decode(value, { stream: true });
596
615
  const lines = buffer.split('\n');
597
616
  buffer = lines.pop();
@@ -613,7 +632,7 @@ export async function handleGateway(req, res) {
613
632
  }
614
633
  } catch (err) {
615
634
  // 客户端主动断开(点停止/换一句)属正常收尾,不是故障
616
- logger.debug('TRAE-GW', `非流式读取中断: ${err.message}`);
635
+ logger.debug('TRAE-GW', '非流式读取中断:{err}', { err: err.message });
617
636
  } finally {
618
637
  cleanupSession();
619
638
  }
@@ -636,7 +655,7 @@ export async function handleGateway(req, res) {
636
655
  role: 'assistant',
637
656
  model: requestedModel,
638
657
  content,
639
- stop_reason: 'end_turn',
658
+ stop_reason: streamCompleted ? 'end_turn' : 'interrupted',
640
659
  stop_sequence: null,
641
660
  usage: usageObj,
642
661
  }));
@@ -646,7 +665,7 @@ export async function handleGateway(req, res) {
646
665
 
647
666
  // 全部重试失败
648
667
  const failedDetail = summarizeAttempts(attempted);
649
- logger.warn('TRAE-GW', `全部 ${attempted.length} 次尝试失败${failedDetail ? ':' + failedDetail : ''}(下一请求自动重试)`);
668
+ logger.warn('TRAE-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
650
669
  res.writeHead(502, { 'Content-Type': 'application/json' });
651
670
  res.end(JSON.stringify({
652
671
  error: '所有 Trae 账号均调用失败',
package/src/transfer.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import crypto from 'node:crypto';
16
16
  import { PROVIDERS, productOf } from './constants.js';
17
+ import { inspectToken } from './workbuddyClient.js';
17
18
 
18
19
  export const TRANSFER_FORMAT = '10router-oauth-secure-v1';
19
20
  export const EXPORT_FORMAT = 'creditdaddy-accounts';
@@ -155,9 +156,17 @@ export function parseImport(data, { password } = {}) {
155
156
  const accounts = [];
156
157
  let skipped = 0;
157
158
  for (const item of list) {
158
- // 显式标注了其他提供商(如 10router 的 cursor 账号)→ 跳过;未标注才用文件级 provider
159
- const provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
160
- const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey;
159
+ // 兼容 workbuddy-switch 导出的明文备份文件(没有 provider 字段,但有 domain / auth_raw / 顶层 access_token)
160
+ const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey || item?.auth_raw?.accessToken || item?.auth_raw?.auth?.accessToken;
161
+ let provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
162
+ let isWbSwitch = false;
163
+ if (!provider && typeof token === 'string' && token.trim()) {
164
+ const inspected = inspectToken(token);
165
+ if (inspected.provider) {
166
+ provider = inspected.provider;
167
+ isWbSwitch = true;
168
+ }
169
+ }
161
170
  if (!provider || typeof token !== 'string' || !token.trim()) { skipped++; continue; }
162
171
  const psd = item.providerSpecificData || {};
163
172
  const email = typeof item.email === 'string' && item.email.includes('@') && !/^qoder(-cn)?-user-/.test(item.email) ? item.email : null;
@@ -167,15 +176,44 @@ export function parseImport(data, { password } = {}) {
167
176
  ? { cookie: psd.creditDaddyWebSession.cookie, capturedAt: psd.creditDaddyWebSession.capturedAt || null } : null;
168
177
  const meta = { ...(item.meta && typeof item.meta === 'object' ? item.meta : {}) };
169
178
  if (webSession && !meta.qoderWebSession) meta.qoderWebSession = webSession;
179
+
180
+ // 对 workbuddy-switch 导出还原 session 结构,使之支持在 CreditDaddy 面板一键切换
181
+ if (isWbSwitch) {
182
+ if (source === 'creditdaddy') source = 'wb-switch';
183
+ if (item.domain && !meta.domain) meta.domain = item.domain;
184
+ if (item.enterpriseId && !meta.enterpriseId) meta.enterpriseId = item.enterpriseId;
185
+ if (item.refreshExpiresAt && !meta.refreshExpiresAt) {
186
+ meta.refreshExpiresAt = Number.isFinite(item.refreshExpiresAt) ? new Date(item.refreshExpiresAt).toISOString() : String(item.refreshExpiresAt);
187
+ }
188
+ if (!meta.session) {
189
+ const accountObj = item.auth_raw?.account || item.profile_raw || {
190
+ uid: item.uid || null,
191
+ nickname: item.nickname || item.name || null,
192
+ phoneNumber: item.phoneNumber || item.phone || null,
193
+ enterpriseId: item.enterpriseId || null,
194
+ type: 'personal',
195
+ };
196
+ const authObj = item.auth_raw?.auth || (item.auth_raw && item.auth_raw.accessToken ? item.auth_raw : { domain: item.domain });
197
+ meta.session = {
198
+ account: accountObj,
199
+ auth: authObj,
200
+ accounts: Array.isArray(item.auth_raw?.accounts) ? item.auth_raw.accounts : [],
201
+ ...(Array.isArray(item.auth_raw?.allAccounts) ? { allAccounts: item.auth_raw.allAccounts } : {}),
202
+ };
203
+ }
204
+ }
205
+
206
+ const expiresAt = item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null);
207
+
170
208
  accounts.push({
171
209
  provider,
172
210
  token,
173
211
  name: item.name || item.nickname || email || null,
174
212
  email,
175
213
  uid: item.uid || psd.userId || null,
176
- refreshToken: item.refreshToken || item.refresh_token || null,
177
- expiresAt: item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null),
178
- source: source === '10router' ? '10router' : 'import',
214
+ refreshToken: item.refreshToken || item.refresh_token || item.auth_raw?.refreshToken || item.auth_raw?.auth?.refreshToken || null,
215
+ expiresAt,
216
+ source: isWbSwitch ? 'wb-switch' : (source === '10router' ? '10router' : 'import'),
179
217
  ...(Object.keys(meta).length ? { meta } : {}),
180
218
  });
181
219
  }
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import { FETCH_TIMEOUT_MS } from './constants.js';
20
+ import { fetchJsonRace } from './zcodeClient.js';
21
+ import { withRefreshDedupe } from './refreshGuard.js';
20
22
 
21
23
  const USER_AGENT = 'CLI/2.108.1 CodeBuddy/2.108.1';
22
24
  const CN_DEFAULT_HOST = 'www.codebuddy.cn';
@@ -79,11 +81,12 @@ function headersFor(account, token) {
79
81
  }
80
82
 
81
83
  async function post(account, path, token) {
82
- const res = await fetch(`https://${apiHost(account)}${path}`, {
84
+ // fetchJsonRace 统一出口:连接段限时 + 配置了出口代理时直连/代理双路兜底
85
+ const res = await fetchJsonRace(`https://${apiHost(account)}${path}`, {
83
86
  method: 'POST',
84
87
  headers: headersFor(account, token),
85
88
  body: '{}',
86
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
89
+ timeoutMs: FETCH_TIMEOUT_MS,
87
90
  });
88
91
  const text = await res.text();
89
92
  let body = null;
@@ -96,7 +99,7 @@ async function post(account, path, token) {
96
99
  // 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
97
100
  const INTL_PROBE_MODEL = 'hy4-preview';
98
101
  async function postIntlProbe(account, token) {
99
- const res = await fetch(`https://${apiHost(account)}/v2/chat/completions`, {
102
+ const res = await fetchJsonRace(`https://${apiHost(account)}/v2/chat/completions`, {
100
103
  method: 'POST',
101
104
  headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
102
105
  body: JSON.stringify({
@@ -108,7 +111,7 @@ async function postIntlProbe(account, token) {
108
111
  { role: 'user', content: [{ type: 'text', text: 'hi' }] },
109
112
  ],
110
113
  }),
111
- signal: AbortSignal.timeout(Math.max(FETCH_TIMEOUT_MS, 25_000)),
114
+ timeoutMs: Math.max(FETCH_TIMEOUT_MS, 25_000),
112
115
  });
113
116
  // 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
114
117
  const text = await res.text().catch(() => '');
@@ -151,7 +154,7 @@ export async function refreshWorkbuddyToken(account) {
151
154
  let lastError = null;
152
155
  for (const host of hosts) {
153
156
  try {
154
- const res = await fetch(`https://${host}/v2/plugin/auth/token/refresh`, {
157
+ const res = await fetchJsonRace(`https://${host}/v2/plugin/auth/token/refresh`, {
155
158
  method: 'POST',
156
159
  headers: {
157
160
  'Content-Type': 'application/json',
@@ -165,7 +168,7 @@ export async function refreshWorkbuddyToken(account) {
165
168
  ...(account.meta?.enterpriseId ? { 'X-Enterprise-Id': account.meta.enterpriseId } : {}),
166
169
  },
167
170
  body: '{}',
168
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
171
+ timeoutMs: FETCH_TIMEOUT_MS,
169
172
  });
170
173
  const j = await res.json().catch(() => ({}));
171
174
  const d = j?.data;
@@ -202,7 +205,11 @@ async function withToken(account, fn, onRefresh) {
202
205
  let token = account.token;
203
206
  let refreshed = false;
204
207
  const doRefresh = async () => {
205
- const creds = await refreshWorkbuddyToken(account);
208
+ // 并发互斥 + 失效链熔断:与 minimax 共用 refreshGuard(面板额度查询与签到轮同链抢刷的根堵点)
209
+ const creds = await withRefreshDedupe(account, refreshWorkbuddyToken, {
210
+ deadMessage: 'WorkBuddy 凭据链已被服务端作废,等待重新登录',
211
+ deadNotice: 'WorkBuddy 凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)',
212
+ });
206
213
  Object.assign(account, { token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
207
214
  token = creds.token;
208
215
  refreshed = true;