creditdaddy 1.3.0 → 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.
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;
@@ -12,7 +12,20 @@
12
12
  */
13
13
 
14
14
  import { logger } from './logger.js';
15
+ import { t } from './i18n.js';
15
16
  import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
17
+ import { withAccounts } from './store.js';
18
+
19
+ // 额满(1005)时消费服务端 nextAt(plan.ends_at):
20
+ // 下轮自动轮询(claimIntervalMin 分钟一触)在 nextAt 之前跳过该号,不做无谓抢。
21
+ // 进程内缓存 + 落盘 account.meta.zcodeClaimNextAt 双轨(重启保护)。
22
+ const snoozeUntil = new Map(); // accountId → ms 时间戳
23
+
24
+ function snoozeUntilMs(account) {
25
+ const meta = Number(new Date(account?.meta?.zcodeClaimNextAt || 0)) || 0;
26
+ const proc = snoozeUntil.get(account.id) || 0;
27
+ return Math.max(meta, proc);
28
+ }
16
29
 
17
30
  // 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
18
31
  let captchaProvider = null;
@@ -28,11 +41,18 @@ const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.jo
28
41
  */
29
42
  export async function zcodeAutoClaim(account) {
30
43
  const label = account.name || account.uid || account.id;
44
+ {
45
+ const until = snoozeUntilMs(account);
46
+ if (until && until > Date.now()) {
47
+ logger.debug('ZCODE-CLAIM', '{label} 名额已领完,跳过直到 {until}(服务端计划结束点)', { label, until: new Date(until).toISOString() });
48
+ return null;
49
+ }
50
+ }
31
51
  let plans;
32
52
  try {
33
53
  plans = (await fetchClaimPlans(account)).plans;
34
54
  } catch (e) {
35
- logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
55
+ logger.debug('ZCODE-CLAIM', '{label} 活动查询失败:{err}', { label, err: e.message });
36
56
  return null;
37
57
  }
38
58
  if (!plans.length) return null;
@@ -41,6 +61,7 @@ export async function zcodeAutoClaim(account) {
41
61
  let anySuccess = false;
42
62
  let needManual = false;
43
63
 
64
+ let snoozeAt = 0;
44
65
  for (const plan of plans) {
45
66
  const planName = plan.name || plan.planId;
46
67
  // 第 2 步:先试不带验证码
@@ -48,17 +69,20 @@ export async function zcodeAutoClaim(account) {
48
69
  const r = await claimPlan(account, plan.planId, {});
49
70
  claims.push({ plan: planName, via: 'direct', ok: true });
50
71
  anySuccess = true;
51
- logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
72
+ logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(免验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
52
73
  continue;
53
74
  } catch (e) {
54
75
  if (e.code === 1003) {
55
76
  claims.push({ plan: planName, ok: true, already: true });
56
- logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
77
+ logger.info('ZCODE-CLAIM', '{label}「{plan}」已领取过', { label, plan: planName });
57
78
  continue;
58
79
  }
80
+ if (e.code === 1005 && e.nextAt) {
81
+ snoozeAt = Math.max(snoozeAt, Number(new Date(e.nextAt)) || 0);
82
+ }
59
83
  if (e.code !== 3007 && e.code !== 3001) {
60
84
  claims.push({ plan: planName, ok: false, message: e.message });
61
- logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
85
+ logger.warn('ZCODE-CLAIM', '{label} 领取「{plan}」失败:{err}', { label, plan: planName, err: e.message });
62
86
  continue;
63
87
  }
64
88
  }
@@ -66,7 +90,7 @@ export async function zcodeAutoClaim(account) {
66
90
  if (!captchaProvider) {
67
91
  needManual = true;
68
92
  claims.push({ plan: planName, ok: false, needManual: true });
69
- logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
93
+ logger.info('ZCODE-CLAIM', '{label}「{plan}」需要验证码,本环境无法自动通过,请在面板手动领取', { label, plan: planName });
70
94
  continue;
71
95
  }
72
96
  try {
@@ -76,13 +100,29 @@ export async function zcodeAutoClaim(account) {
76
100
  await claimPlan(account, plan.planId, { captchaParam, region });
77
101
  claims.push({ plan: planName, via: 'captcha', ok: true });
78
102
  anySuccess = true;
79
- logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
103
+ logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(静默验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
80
104
  } catch (e2) {
105
+ if (e2.code === 1005 && e2.nextAt) {
106
+ snoozeAt = Math.max(snoozeAt, Number(new Date(e2.nextAt)) || 0);
107
+ }
81
108
  needManual = true;
82
109
  claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
83
- logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
110
+ logger.warn('ZCODE-CLAIM', '{label}「{plan}」验证码未完成:{err}(可在面板手动领取)', { label, plan: planName, err: e2.message });
84
111
  }
85
112
  }
113
+ // 1005 名额已满 → 按服务端 ends_at 将本账号挂进跳过窗口,下一轮自动轮询在该时间点前不再空打
114
+ if (snoozeAt > 0) {
115
+ snoozeUntil.set(account.id, snoozeAt);
116
+ try {
117
+ await withAccounts((list) => {
118
+ const cur = list.find((a) => a.id === account.id);
119
+ if (cur) {
120
+ cur.meta = cur.meta && typeof cur.meta === 'object' && !Array.isArray(cur.meta) ? cur.meta : {};
121
+ cur.meta.zcodeClaimNextAt = new Date(snoozeAt).toISOString();
122
+ }
123
+ });
124
+ } catch {}
125
+ }
86
126
 
87
127
  const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
88
128
  const status = names.length ? 'checked-in'
@@ -259,22 +259,68 @@ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, t
259
259
  let viaProxyImpl = null;
260
260
  export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
261
261
 
262
- export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
262
+ /**
263
+ * 组合多个 AbortSignal 成一个:任一源触发即 abort。
264
+ * 与 AbortSignal.any 的区别——这里返回 { signal, releaseConnect },releaseConnect()
265
+ * 可摘掉 connectSignal 的转发监听:两段式超时里,响应头到达后连接段限时就该失效,
266
+ * 但整墙钟(wallSignal)和外部取消(external)要继续管住流式 body。
267
+ */
268
+ function combineSignals({ wallSignal, connectSignal, external }) {
269
+ const ctrl = new AbortController();
270
+ const onWall = () => ctrl.abort(wallSignal.reason);
271
+ const onConnect = () => ctrl.abort(connectSignal.reason);
272
+ const onExternal = () => ctrl.abort(external.reason);
273
+ if (wallSignal.aborted) ctrl.abort(wallSignal.reason);
274
+ else wallSignal.addEventListener('abort', onWall, { once: true });
275
+ if (connectSignal && !ctrl.signal.aborted) {
276
+ if (connectSignal.aborted) ctrl.abort(connectSignal.reason);
277
+ else connectSignal.addEventListener('abort', onConnect, { once: true });
278
+ }
279
+ if (external && !ctrl.signal.aborted) {
280
+ if (external.aborted) ctrl.abort(external.reason);
281
+ else external.addEventListener('abort', onExternal, { once: true });
282
+ }
283
+ // 连接段成功后调用:只摘掉 connectSignal 的转发,墙钟与外部取消继续管住 body 流
284
+ const releaseConnect = () => { connectSignal?.removeEventListener('abort', onConnect); };
285
+ return { signal: ctrl.signal, releaseConnect };
286
+ }
287
+
288
+ /**
289
+ * 直连 / 代理兜底的 fetch。可选 connectMs 启用两段式超时:
290
+ * - 拿到响应头前(DNS/TCP/TLS/首字节)限时 connectMs——上游黑洞挂死时几秒内
291
+ * 就失败换下一条路,而不是吊满整个 timeoutMs(网关补全场景尤其重要);
292
+ * - 拿到响应头后解除连接段限时,只保留 timeoutMs 整墙钟——流式补全可能合法地
293
+ * 跑几分钟,不能因为「连接快、生成慢」被误杀。
294
+ * 不传 connectMs 时退回单段 timeoutMs(旧行为,所有既有调用方不受影响)。
295
+ */
296
+ export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, connectMs = null, signal = null, preferProxy = false, redirect = 'manual' } = {}) {
263
297
  const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
298
+ const twoPhase = Number.isFinite(connectMs) && connectMs > 0 && connectMs < timeoutMs;
264
299
  // 超时信号必须每次尝试各建一个:共用一个的话,代理优先模式下第一条路吃完 timeoutMs,
265
300
  // 兜底那条就对着已触发的 signal 发请求,必定瞬间失败——「代理优先」就此形同虚设
266
301
  const makeAttempt = () => {
267
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
268
- const abortSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
269
- const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
302
+ const wallSignal = AbortSignal.timeout(timeoutMs);
303
+ const connectSignal = twoPhase ? AbortSignal.timeout(connectMs) : null;
304
+ const { signal: abortSignal, releaseConnect } = combineSignals({ wallSignal, connectSignal, external: signal });
305
+ // redirect 默认 manual:follow 的 301/302 会把 POST 转 GET 丢 body、307 会把大 body 重放。
306
+ const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal, redirect };
270
307
  return {
271
- direct: () => fetch(url, base),
272
- viaProxy: () => (viaProxyImpl
273
- ? viaProxyImpl(url, { method, headers, body, timeoutMs, signal: abortSignal })
274
- : fetchViaProxy(url, { method, headers, body, timeoutMs, signal: abortSignal })),
308
+ // 响应头到达 = 连接段成功:摘掉 connectSignal 转发,此后只有墙钟/外部取消能中断流
309
+ direct: async () => { const r = await fetch(url, base); releaseConnect(); return r; },
310
+ viaProxy: async () => {
311
+ const r = viaProxyImpl
312
+ ? await viaProxyImpl(url, { method, headers, body, timeoutMs, signal: abortSignal })
313
+ : await fetchViaProxy(url, { method, headers, body, timeoutMs, signal: abortSignal });
314
+ releaseConnect();
315
+ return r;
316
+ },
275
317
  };
276
318
  };
277
- const order = useProxy ? (proxyFirst() ? ['viaProxy', 'direct'] : ['direct', 'viaProxy']) : ['direct'];
319
+ // preferProxy:该域名按 IP 识别地区(如 qoder.com 国际版),配置了代理就该优先走代理、
320
+ // 直连只作兜底——直连「成功返回 403 风控页」也算成功响应,走默认顺序永远不会轮到代理
321
+ const order = useProxy
322
+ ? (preferProxy || proxyFirst() ? ['viaProxy', 'direct'] : ['direct', 'viaProxy'])
323
+ : ['direct'];
278
324
  let lastErr = null;
279
325
  for (const which of order) {
280
326
  const attempts = makeAttempt();
@@ -322,7 +368,11 @@ async function bigmodelHeaders(token) {
322
368
  async function getJson(url, headers) {
323
369
  const res = await fetchJsonRace(url, { headers, timeoutMs: FETCH_TIMEOUT_MS });
324
370
  const text = await res.text();
325
- try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
371
+ try { return JSON.parse(text); } catch {
372
+ // 解析失败(含 HTTP 200 + 空 body,服务端偶发对无效 token 就是这样)绝不能伪装成 code:200,
373
+ // 否则 businessOk 会放行并让额度链提前返回“无套餐”,短路后面的 balance 尝试
374
+ return { code: res.ok ? -1 : res.status, msg: (text || `empty response (HTTP ${res.status})`).slice(0, 120) };
375
+ }
326
376
  }
327
377
 
328
378
  const businessOk = (v) => {
@@ -497,6 +547,12 @@ export function normalizePlans(previewResp) {
497
547
  return out;
498
548
  }
499
549
 
550
+ /** “当前用户不存在coding plan / 没有资格”属于无套餐的业务口径,不是查询失败(同 zcode-switch is_no_plan_message) */
551
+ function isNoPlanMessage(msg) {
552
+ const m = String(msg || '');
553
+ return m.includes('不存在coding plan') || m.includes('没有资格') || /no.*coding.*plan/i.test(m);
554
+ }
555
+
500
556
  /**
501
557
  * 额度查询:先试 BigModel Coding Plan(quota/limit),再试 Z.ai / Start Plan(billing/balance)。
502
558
  * 都没有有效套餐时返回 { empty: true }(不是错误:很多账号只用免费额度)。
@@ -505,28 +561,35 @@ export async function fetchZcodeQuota(account) {
505
561
  const tokens = candidateTokens(account);
506
562
  if (!tokens.length) throw new Error('账号快照里没有可用 token,请在 ZCode 登录后重新导入');
507
563
  let lastErr = null;
564
+ let sawNoPlan = false;
508
565
  let authFails = 0;
509
566
  for (const t of tokens) {
510
567
  try {
511
568
  const limit = await getJson(QUOTA_LIMIT_URL, await bigmodelHeaders(t));
512
569
  if (businessOk(limit)) {
513
570
  const sub = await getJson(SUBSCRIPTION_URL, await bigmodelHeaders(t)).catch(() => null);
514
- return { ...normalizeQuotaLimit(limit, sub), source: 'bigmodel' };
571
+ const ov = normalizeQuotaLimit(limit, sub);
572
+ // BigModel 侧“有效响应但零额度项”不代表账号没额度(Start Plan 账号走 balance 有额度),
573
+ // 不能提前返回 empty,必须继续把 balance 链路试完——与 zcode-switch 每 token 双端点连查一致
574
+ if (!ov.empty) return { ...ov, source: 'bigmodel' };
515
575
  }
516
576
  if (limit?.code === 401) authFails++;
577
+ else if (isNoPlanMessage(limit?.msg)) sawNoPlan = true;
517
578
  else lastErr = limit?.msg || lastErr;
518
579
  } catch (e) { lastErr = e.message; }
519
580
  }
520
581
  for (const t of billingTokens(account)) {
521
582
  try {
522
583
  const bal = await getJson(`${BILLING_BALANCE_URL}?app_version=${await zcodeAppVersion()}`, await zaiHeaders(t, ensureVirtualDeviceMid(account)));
584
+ // balance 是账号级总余额端点,有效答复(含“确无套餐”)即可采信;
585
+ // 与 bigmodel 侧不同——那里“有效但零额度项”不代表账号没额度,必须继续往下试
523
586
  if (businessOk(bal)) return { ...normalizeBalance(bal), source: 'zcode.z.ai' };
524
587
  if (bal?.code === 401) authFails++;
525
588
  } catch (e) { lastErr = e.message; }
526
589
  }
527
590
  if (authFails && authFails >= tokens.length) throw new Error('鉴权失败,登录可能已过期,请在 ZCode 重新登录后重新导入');
528
- // “当前用户不存在coding plan”之类属于无套餐,按空额度返回
529
- if (!lastErr || /不存在|no.*plan/i.test(lastErr)) {
591
+ // 无套餐口径:显式的“不存在coding plan”或整条链没有任何错误
592
+ if (!lastErr || sawNoPlan) {
530
593
  return { total: 0, used: 0, remaining: 0, unit: '', plan: null, parts: [], empty: true, exceeded: false };
531
594
  }
532
595
  throw new Error(`额度查询失败:${lastErr}`);
@@ -22,7 +22,8 @@ import path from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
 
24
24
  // ZCode 客户端 plan 请求形状(从本机客户端真实流量捕获的载荷模板:系统提示词数组 +
25
- // currentDate 提醒块 + metadata 会话形状)。网关请求镜像此形状——这就是 405 风控的通过票。
25
+ // currentDate 提醒块 + metadata 会话形状)。网关请求镜像此形状——这就是 405/3012 风控的通过票。
26
+ // 提示词已脱敏(去掉抓包来源的用户路径/会话 id),避免把第三方隐私随请求外发(见 issue #16)。
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  let PLAN_SHAPE = null;
28
29
  try { PLAN_SHAPE = JSON.parse(readFileSync(path.join(__dirname, 'zcodePlanShape.json'), 'utf8')); } catch {}
@@ -38,13 +39,18 @@ export function getZcodeCompletionProvider() { return completionProvider; }
38
39
  import { loadAccounts, loadSettings, saveSettings, loadState, withState } from './store.js';
39
40
  import crypto from 'node:crypto';
40
41
  import os from 'node:os';
41
- import { logger } from './logger.js';
42
+ import { logger, summarizeAttempts } from './logger.js';
43
+ import { t } from './i18n.js';
42
44
 
43
45
  const PLAN_MESSAGES_URL = 'https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages';
44
46
  const CAPTCHA_CACHE_TTL_MS = 30_000;
45
47
  const ACCOUNT_COOLING_MS = 5 * 60_000;
46
48
  const MAX_ATTEMPTS = 6;
49
+ // 上游补全墙钟上限(响应头到齐后 body 仍可流式跑这么久)
47
50
  const UPSTREAM_TIMEOUT_MS = 600_000;
51
+ // 连接段(DNS/TCP/TLS/首字节)限时:上游黑洞挂死时几秒内换下一条路 / 代理兜底,
52
+ // 而不是吊满 10 分钟——这是「网关很慢 / 卡住」体感的主要来源
53
+ const UPSTREAM_CONNECT_MS = 15_000;
48
54
 
49
55
  // ── 开关(持久化在 settings.zcodeGateway) ──
50
56
 
@@ -103,7 +109,7 @@ function isDead(account) {
103
109
  if (fp === credFingerprint(account)) return true;
104
110
  dead.delete(account.id);
105
111
  cooling.delete(account.id);
106
- logger.info('ZCODE-GW', `${account.name || account.uid || account.id} 凭据已更新,解除拉黑`);
112
+ logger.info('ZCODE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
107
113
  return false;
108
114
  }
109
115
 
@@ -363,7 +369,13 @@ export function remoteAllowed(remote, allowList) {
363
369
  return (Array.isArray(allowList) ? allowList : []).some((entry) => {
364
370
  const e = String(entry || '').trim();
365
371
  if (!e) return false;
366
- if (e.endsWith('*')) return host.toLowerCase().startsWith(e.slice(0, -1).toLowerCase());
372
+ if (e.endsWith('*')) {
373
+ // 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
374
+ // 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
375
+ const prefix = e.slice(0, -1);
376
+ if (!prefix.endsWith('.')) return false;
377
+ return host.toLowerCase().startsWith(prefix.toLowerCase());
378
+ }
367
379
  return host.toLowerCase() === e.toLowerCase();
368
380
  });
369
381
  }
@@ -379,7 +391,9 @@ export async function handleGateway(req, res) {
379
391
  res.writeHead(405, { 'Content-Type': 'application/json' });
380
392
  return res.end(JSON.stringify({ error: 'POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态)' }));
381
393
  }
382
- if (!(await gatewayEnabled())) {
394
+ // settings 一次读齐:开关 + 局域网鉴权共用,省掉一次 settings.json 磁盘读(热路径每请求都走)
395
+ const settings = await loadSettings();
396
+ if (settings.zcodeGateway !== true) {
383
397
  res.writeHead(503, { 'Content-Type': 'application/json' });
384
398
  return res.end(JSON.stringify({ error: 'ZCode 免费额度网关未开启(面板 → ZCode → 网关开关)' }));
385
399
  }
@@ -389,7 +403,6 @@ export async function handleGateway(req, res) {
389
403
  // 鉴权:本机回环免密;开局域网后仅白名单内 IP 免密直连(如 10Router 所在机器——zcode-free 本就是免授权供应商)
390
404
  const remote = (req.socket && req.socket.remoteAddress) || '';
391
405
  if (!isLoopbackRemote(remote)) {
392
- const settings = await loadSettings();
393
406
  if (settings.zcodeGatewayLan !== true) {
394
407
  res.writeHead(403, { 'Content-Type': 'application/json' });
395
408
  return res.end(JSON.stringify({ error: '网关未允许局域网访问(面板 → ZCode → 接口设置)' }));
@@ -418,6 +431,11 @@ export async function handleGateway(req, res) {
418
431
  stats.calls += 1;
419
432
 
420
433
  const attempted = [];
434
+ // 客户端断开 → 取消当前上游请求。整个重试循环共用一个 controller/listener:
435
+ // 放在循环里每轮 new + req.on('close') 会在同一 req 上累积最多 MAX_ATTEMPTS 个监听器
436
+ // (MaxListenersExceededWarning + 泄漏)。
437
+ const clientAbort = new AbortController();
438
+ req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
421
439
  // 首发不带验证码(客户端真实流量多数直过);上游 3007/403 时求解并在此后的
422
440
  // 尝试上挂新 token(zcode-api 同策略)。provider 为 null(NAS/CLI)时首过路径依然可用。
423
441
  let captcha = null;
@@ -434,8 +452,6 @@ export async function handleGateway(req, res) {
434
452
  }
435
453
 
436
454
  let upstream;
437
- const controller = new AbortController();
438
- req.on('close', () => controller.abort());
439
455
  try {
440
456
  // 请求面完全镜像 ZCode 客户端真实流量(R1 捕获):全套 identity 头 + 客户端
441
457
  // 系统提示词/提醒块/metadata 形状。验证码不预挂——服务端未风控时直过;
@@ -449,10 +465,11 @@ export async function handleGateway(req, res) {
449
465
  headers,
450
466
  body: built.body,
451
467
  timeoutMs: UPSTREAM_TIMEOUT_MS,
452
- signal: controller.signal,
468
+ connectMs: UPSTREAM_CONNECT_MS,
469
+ signal: clientAbort.signal,
453
470
  });
454
471
  } catch (e) {
455
- if (controller.signal.aborted) return; // 客户端先断了
472
+ if (clientAbort.signal.aborted) return; // 客户端先断了
456
473
  attempted.push({ account: label, ok: false, error: e.message });
457
474
  continue;
458
475
  }
@@ -477,7 +494,7 @@ export async function handleGateway(req, res) {
477
494
  if (isAuthError(200, text)) {
478
495
  markDead(account);
479
496
  attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑' });
480
- logger.info('ZCODE-GW', `${label} JWT 失效(200 业务码)`);
497
+ logger.warn('ZCODE-GW', '{label} JWT 失效(200 业务码)', { label });
481
498
  continue;
482
499
  }
483
500
  if (isExhausted(200, text)) {
@@ -499,20 +516,20 @@ export async function handleGateway(req, res) {
499
516
  }
500
517
  continue;
501
518
  }
502
- logger.warn('ZCODE-GW', `${label} 上游 200 业务错误:${formatUpstreamError(200, text)} (${text.slice(0, 80)})`);
519
+ logger.warn('ZCODE-GW', '{label} 上游 200 业务错误:{err} ({snippet})', { label, err: formatUpstreamError(200, text), snippet: text.slice(0, 80) });
503
520
  res.writeHead(502, { 'Content-Type': 'application/json' });
504
521
  res.end(text);
505
522
  return true;
506
523
  }
507
524
  // 真 Message(非流式)——原样转发
508
- logger.debug('ZCODE-GW', `${label} 补全成功 (${upstream.status})`);
525
+ logger.debug('ZCODE-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
509
526
  stats.lastAccount = label;
510
527
  cooling.delete(account.id);
511
528
  res.writeHead(upstream.status, { 'Content-Type': ct || 'application/json', 'Cache-Control': 'no-cache' });
512
529
  res.end(text);
513
530
  return true;
514
531
  }
515
- logger.debug('ZCODE-GW', `${label} 补全成功 (${upstream.status})`);
532
+ logger.debug('ZCODE-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
516
533
  stats.lastAccount = label;
517
534
  cooling.delete(account.id);
518
535
  // 注意:undici 已自动解压上游 gzip——body 是明文,绝不能再带 content-encoding 头
@@ -532,7 +549,8 @@ export async function handleGateway(req, res) {
532
549
  }
533
550
  }
534
551
  } catch (e) {
535
- logger.warn('ZCODE-GW', `流式转发中断:${e.message}`);
552
+ // 客户端主动断开(点停止/换一句)就会在这里结束——属正常收尾,不是故障,降为 debug
553
+ logger.debug('ZCODE-GW', '流式转发中断:{err}', { err: e.message });
536
554
  }
537
555
  res.end();
538
556
  return true;
@@ -543,9 +561,9 @@ export async function handleGateway(req, res) {
543
561
  if (isCaptchaError(upstream.status, text)) {
544
562
  invalidateCaptcha();
545
563
  attempted.push({ account: label, ok: false, error: `验证码被拒(${upstream.status})`, captcha: true });
546
- logger.info('ZCODE-GW', `${label} 验证码被拒 (${formatUpstreamError(upstream.status, text)})`);
564
+ logger.warn('ZCODE-GW', '{label} 验证码被拒 ({err})', { label, err: formatUpstreamError(upstream.status, text) });
547
565
  try { captcha = await ensureCaptcha(true); } catch (e) {
548
- logger.info('ZCODE-GW', `验证码重解失败:${e.message}`);
566
+ logger.warn('ZCODE-GW', '验证码重解失败:{err}', { err: e.message });
549
567
  captcha = null;
550
568
  }
551
569
  continue;
@@ -553,30 +571,31 @@ export async function handleGateway(req, res) {
553
571
  if (isAuthError(upstream.status, text)) {
554
572
  markDead(account);
555
573
  attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑(重新登录后再导入)' });
556
- logger.debug('ZCODE-GW', `${label} JWT 失效,拉黑`);
574
+ logger.debug('ZCODE-GW', '{label} JWT 失效,拉黑', { label });
557
575
  continue;
558
576
  }
559
577
  if (isExhausted(upstream.status, text)) {
560
578
  markCooling(account.id, 30 * 60_000);
561
579
  attempted.push({ account: label, ok: false, error: '额度不足/无资源包' });
562
- logger.debug('ZCODE-GW', `${label} 额度不足 (${upstream.status})`);
580
+ logger.debug('ZCODE-GW', '{label} 额度不足 ({status})', { label, status: upstream.status });
563
581
  continue;
564
582
  }
565
583
  if (upstream.status === 429) {
566
584
  markCooling(account.id);
567
585
  attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
568
- logger.debug('ZCODE-GW', `${label} 429 限流,冷却 5 分钟`);
586
+ logger.debug('ZCODE-GW', '{label} 429 限流,冷却 5 分钟', { label });
569
587
  continue;
570
588
  }
571
589
  // 其他错误:友好化展示 + 原样透传给客户端
572
590
  const errorSummary = formatUpstreamError(upstream.status, text);
573
- logger.warn('ZCODE-GW', `${label} 上游错误 ${upstream.status} — ${errorSummary}`);
591
+ logger.warn('ZCODE-GW', '{label} 上游错误 {status} — {summary}', { label, status: upstream.status, summary: errorSummary });
574
592
  res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
575
593
  res.end(text);
576
594
  return true;
577
595
  }
578
596
 
579
- logger.warn('ZCODE-GW', `全部 ${attempted.length} 次尝试失败`);
597
+ const failedDetail = summarizeAttempts(attempted);
598
+ logger.warn('ZCODE-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
580
599
  res.writeHead(502, { 'Content-Type': 'application/json' });
581
600
  res.end(JSON.stringify({ error: 'ZCode 网关:所有账号尝试均失败', attempts: attempted }));
582
601
  return true;
@@ -16,14 +16,14 @@
16
16
  },
17
17
  {
18
18
  "type": "text",
19
- "text": "\n\n# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn — answers, summaries, findings, conclusions, deliverables — must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" — the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user — a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show — never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\n# Environment\nYou have been invoked in the following environment:\n- Primary working directory: C:\\Users\\YangYu\\zproxy-test\n- Is a git repository: no\n- Platform: win32\n- Shell: bash.exe\n- OS Version: win32 10.0.19045 x64\n- You are powered by the model named bigmodel-api/glm-5.3-flash.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nWhen you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state — restarts, deletes, config edits — check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.",
19
+ "text": "\n\n# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn — answers, summaries, findings, conclusions, deliverables — must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" — the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user — a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show — never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\n# Environment\nYou have been invoked in the following environment:\n- Primary working directory: C:\\Users\\<user>\\<workspace>\n- Is a git repository: no\n- Platform: win32\n- Shell: bash.exe\n- OS Version: win32 10.0.19045 x64\n- You are powered by the model named bigmodel-api/glm-5.3-flash.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nWhen you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state — restarts, deletes, config edits — check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.",
20
20
  "cache_control": {
21
21
  "type": "ephemeral"
22
22
  }
23
23
  }
24
24
  ],
25
- "reminderText": "<system-reminder>As you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is 2026-09-29.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.</system-reminder>",
25
+ "reminderText": "<system-reminder>As you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is YYYY-MM-DD.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.</system-reminder>",
26
26
  "metadata": {
27
- "user_id": "{\"account_uuid\":\"\",\"session_id\":\"ses_f0a670f7b1f1\"}"
27
+ "user_id": "{\"account_uuid\":\"\",\"session_id\":\"ses_\"}"
28
28
  }
29
29
  }