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.
@@ -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
  }
@@ -98,7 +109,16 @@ export async function loadAccounts() {
98
109
 
99
110
  export async function saveAccounts(accounts) {
100
111
  await ensureDir();
101
- await atomicWrite(ACCOUNTS_FILE(), JSON.stringify(accounts, null, 2));
112
+ const file = ACCOUNTS_FILE();
113
+ const bak = file + '.bak';
114
+ try {
115
+ // 只有当已有账号文件存在且非空时才建立/更新 .bak 备份
116
+ const existing = await fs.readFile(file, 'utf8').catch(() => null);
117
+ if (existing && existing.trim().length > 2) {
118
+ await atomicWrite(bak, existing);
119
+ }
120
+ } catch {}
121
+ await atomicWrite(file, JSON.stringify(accounts, null, 2));
102
122
  }
103
123
 
104
124
  // 进程内串行队列:所有「读-改-写」都经由 withAccounts,避免并发请求互相覆盖
@@ -200,6 +220,12 @@ export function publicAccount(a) {
200
220
  phone: maskPhone(meta.phone),
201
221
  domain: meta.domain || null,
202
222
  flavor: a.provider === 'zcode' ? zcodeFlavor(meta) : null,
223
+ // Qoder 网页会话新鲜度(10Router issue #44):只回时间戳、绝不回 Cookie。
224
+ // 10Router 的「套餐内 Credits」明细依赖导出的这份会话;面板据此在
225
+ // 导出/同步缺会话或明显过期的 Qoder 账号时给出提示。
226
+ qoderWebCapturedAt: String(a.provider).startsWith('qoder')
227
+ ? (meta.qoderWebSession?.capturedAt || null)
228
+ : null,
203
229
  canSwitch: Boolean(meta.session?.account || meta.credentials || meta.qoderAuth || a.provider === 'catpaw' || a.provider === 'trae'),
204
230
  verified: a.verified ?? null,
205
231
  createdAt: a.createdAt,
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);
@@ -92,6 +92,9 @@ async function syncViaTransfer({ endpoint, cookie, groups }) {
92
92
  results.push({
93
93
  provider: target, count: list.length, status: (d.failed || 0) ? 'partial' : 'ok',
94
94
  imported: d.imported || 0, updated: d.updated || 0, skipped: d.skipped || 0, failed: d.failed || 0,
95
+ // 10Router issue #44:导入端会探测刚同步过去的网页会话是否仍可用,
96
+ // 失败清单直接回显到本端摘要(见 syncAccountsTo10r 的 sessionWarns)。
97
+ webSessions: d.webSessions || null,
95
98
  });
96
99
  }
97
100
  return results;
@@ -103,9 +106,15 @@ async function syncViaProviders({ endpoint, key, groups }) {
103
106
  for (const [target, list] of groups) {
104
107
  const row = { provider: target, count: list.length, status: 'ok', imported: 0, skipped: 0, failed: 0 };
105
108
  for (const a of list) {
106
- const r = await trFetch(`${endpoint}/api/providers`, {
107
- key, method: 'POST', body: { provider: target, apiKey: a.token, name: connName(a) },
108
- });
109
+ // issue #44:qoder 的 apikey 连接同样按 providerSpecificData 读网页会话
110
+ // (usage 侧 getQoderUsage 不区分 authType),所以新建连接时把会话一并
111
+ // 挂上;已存在的同名连接此接口不更新(409),缺失由同步摘要点名提醒。
112
+ const web = a.meta?.qoderWebSession?.cookie && String(a.provider).startsWith('qoder')
113
+ ? { creditDaddyWebSession: { cookie: a.meta.qoderWebSession.cookie, capturedAt: a.meta.qoderWebSession.capturedAt || null, userId: a.uid || null } }
114
+ : null;
115
+ const body = { provider: target, apiKey: a.token, name: connName(a) };
116
+ if (web) body.providerSpecificData = { userId: a.uid || null, ...web };
117
+ const r = await trFetch(`${endpoint}/api/providers`, { key, method: 'POST', body });
109
118
  if (r.status === 201 || r.status === 200) row.imported++;
110
119
  else if (r.status === 409) row.skipped++;
111
120
  else { row.failed++; row.error = r.data?.error || `HTTP ${r.status}`; }
@@ -163,6 +172,25 @@ export async function syncAccountsTo10r({ endpoint, key, adminPassword, accounts
163
172
  if (sum('skipped')) parts.push(`已存在 ${sum('skipped')}`);
164
173
  if (sum('failed')) parts.push(`失败 ${sum('failed')}`);
165
174
  if (unmapped.size) parts.push('跳过 ' + [...unmapped].map(([p, n]) => `${p} ×${n}`).join('、'));
175
+ // issue #44:Qoder 网页会话决定 10Router 端「套餐内 Credits」能不能显示。
176
+ // OAuth 通道:10Router 导入时已逐账号探测,失效清单原样带回(deadNames);
177
+ // 两条通道都要点名本地就没有会话的账号(apikey 通道 webSessions 恒为
178
+ // null,条件自动适用;OAuth 通道里已被探测过的组不重复报)。
179
+ const deadNames = [];
180
+ for (const r of results) {
181
+ for (const f of r.webSessions?.failures || []) deadNames.push(f.name);
182
+ }
183
+ const sessionless = [];
184
+ for (const [target, list] of groups) {
185
+ if (!String(target).startsWith('qoder')) continue;
186
+ for (const a of list) {
187
+ if (results.find((r) => r.provider === target)?.webSessions == null && !a.meta?.qoderWebSession?.cookie) {
188
+ sessionless.push(connName(a));
189
+ }
190
+ }
191
+ }
192
+ if (sessionless.length) parts.push(`未带网页会话 ${sessionless.length}(${sessionless.join('、')},在 CreditDaddy 重新浏览器登录 Qoder 后再同步可补齐逐资源包明细)`);
193
+ if (deadNames.length) parts.push(`网页会话已失效 ${deadNames.length}(${deadNames.join('、')},重新登录 Qoder 网页后再次同步)`);
166
194
  const summary = (channel === 'oauth' ? '账号同步完成(OAuth 通道):' : '账号同步完成(apikey 通道,token 过期后需重新同步):')
167
195
  + (parts.join(',') || '无变化')
168
196
  + (failed.length ? `;${failed.map((r) => r.provider + ':' + (r.error || r.status)).join(';')}` : '');
package/src/traeClient.js CHANGED
@@ -223,6 +223,11 @@ export async function checkinTrae(account, ctx = {}) {
223
223
  return { ...base, status: 'already', message: '今天已签到', uid };
224
224
  }
225
225
 
226
+ // 签到应得额度:status 接口里通常预告 credits / extra_credits(如 100 积分),
227
+ // claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
228
+ const TRAE_DEFAULT_DAILY_CREDITS = 100;
229
+ const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
230
+
226
231
  const c = await post(CLAIM_URL, account);
227
232
  if (c.code !== 0) {
228
233
  ctx.log?.(`Trae 领取失败:${c.message || `code ${c.code}`}`);
@@ -233,7 +238,7 @@ export async function checkinTrae(account, ctx = {}) {
233
238
  uid,
234
239
  };
235
240
  }
236
- const claimed = Number(findField(c.body, 'credits')) || 0;
241
+ const claimed = Number(findField(c.body, 'credits')) || expectedCredits;
237
242
  return { ...base, status: 'checked-in', claimedAmount: claimed, message: claimed ? `签到成功,+${claimed} 积分` : '签到成功', uid };
238
243
  }
239
244