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/i18n.js ADDED
@@ -0,0 +1,47 @@
1
+ /** 轻量国际化:以中文原文为 key,缺翻译时回退中文。字典位于 src/i18n/<locale>.json。 */
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'];
8
+ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'i18n');
9
+ const cache = new Map();
10
+
11
+ export function normalizeLocale(v) {
12
+ const s = String(v || '').trim();
13
+ return LOCALES.includes(s) ? s : 'zh-CN';
14
+ }
15
+
16
+ function dictFor(locale) {
17
+ if (locale === 'zh-CN') return {};
18
+ if (!cache.has(locale)) {
19
+ try {
20
+ cache.set(locale, JSON.parse(fs.readFileSync(path.join(DIR, `${locale}.json`), 'utf8')));
21
+ } catch {
22
+ cache.set(locale, {});
23
+ }
24
+ }
25
+ return cache.get(locale);
26
+ }
27
+
28
+ export async function loadDict(locale) {
29
+ return dictFor(normalizeLocale(locale));
30
+ }
31
+
32
+ let current = 'zh-CN';
33
+
34
+ export function setLocale(v) {
35
+ current = normalizeLocale(v);
36
+ }
37
+
38
+ export function getLocale() {
39
+ return current;
40
+ }
41
+
42
+ /** t('已连接 {n} 个账号', { n: 2 }) → 按当前语言取译文,再替换 {占位符} */
43
+ export function t(text, vars) {
44
+ const s = dictFor(current)[text] ?? text;
45
+ if (!vars) return s;
46
+ return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
47
+ }
package/src/logger.js CHANGED
@@ -8,6 +8,8 @@ import fsSync from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import os from 'node:os';
10
10
  import zlib from 'node:zlib';
11
+ import { t } from './i18n.js';
12
+ import { PROVIDER_LABEL } from './constants.js';
11
13
 
12
14
  const ARCHIVE_RETENTION_DAYS = 7; // .log.gz 归档保留天数,更早的自动清理
13
15
 
@@ -21,6 +23,8 @@ try {
21
23
 
22
24
  let archiveDay = null;
23
25
  let archiveStream = null;
26
+ let jsonlStream = null;
27
+ let jsonlFile = null;
24
28
 
25
29
  function archiveLine(line) {
26
30
  if (!archiveDir) return;
@@ -36,10 +40,22 @@ function archiveLine(line) {
36
40
  prev.on('close', () => gzipAndCleanup(prevFile));
37
41
  prev.end();
38
42
  }
43
+ if (jsonlStream) {
44
+ const prevj = jsonlStream;
45
+ const prevjFile = jsonlFile;
46
+ jsonlStream = null;
47
+ prevj.on('error', () => {});
48
+ prevj.on('close', () => gzipAndCleanup(prevjFile));
49
+ prevj.end();
50
+ }
39
51
  archiveStream = fsSync.createWriteStream(path.join(archiveDir, `daemon-${day}.log`), { flags: 'a' });
52
+ jsonlFile = path.join(archiveDir, `daemon-${day}.jsonl`);
53
+ jsonlStream = fsSync.createWriteStream(jsonlFile, { flags: 'a' });
40
54
  archiveDay = day;
41
55
  }
42
56
  archiveStream.write('[' + line.at + '] ' + line.level.toUpperCase() + ' [' + line.tag + '] ' + line.msg + '\n');
57
+ // 结构化副行(JSONL):key+args 原样落盘,读取侧按当前语言重渲染(历史也能切语言)
58
+ jsonlStream.write(JSON.stringify({ at: line.at, level: line.level, tag: line.tag, msg: line.msg, key: line.key, args: line.args }) + '\n');
43
59
  } catch { // 落盘失败不拖累主流程
44
60
  }
45
61
  }
@@ -51,14 +67,21 @@ function ts() {
51
67
  + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
52
68
  }
53
69
 
54
- /** 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档 */
70
+ /**
71
+ * 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档。
72
+ *
73
+ * ⚠️ 压缩后**截断**原文件而不是删除它:同一天的文件可能被另一个实例(前台 daemon /
74
+ * 桌面版)以 O_APPEND 持有同一个 fd。删掉原文件后,POSIX 上它的后续写入会落进一个
75
+ * 已无链接的 inode —— 别的实例还在正常跑,日志却静默消失。截断成 0 字节后 O_APPEND
76
+ * 会从 0 重新追加,同一个 inode、同一批数据,谁都不会丢。
77
+ */
55
78
  function gzipAndCleanup(file) {
56
79
  try {
57
80
  if (fsSync.existsSync(file)) {
58
81
  const raw = fsSync.readFileSync(file);
59
82
  if (raw.length) {
60
83
  fsSync.writeFileSync(file + '.gz', zlib.gzipSync(raw));
61
- fsSync.unlinkSync(file);
84
+ fsSync.truncateSync(file, 0);
62
85
  }
63
86
  }
64
87
  } catch {}
@@ -74,7 +97,7 @@ function cleanupArchive() {
74
97
  cutoff.setDate(cutoff.getDate() - ARCHIVE_RETENTION_DAYS);
75
98
  cutoff.setHours(0, 0, 0, 0);
76
99
  for (const name of fsSync.readdirSync(archiveDir)) {
77
- const m = /^daemon-(\d{4}-\d{2}-\d{2})\.log(\.gz)?$/.exec(name);
100
+ const m = /^daemon-(\d{4}-\d{2}-\d{2})\.(?:log|jsonl)(\.gz)?$/.exec(name);
78
101
  if (!m) continue; // 只动本 daemon 的归档文件,不碰目录里其他文件
79
102
  const f = path.join(archiveDir, name);
80
103
  let fileDay;
@@ -86,8 +109,9 @@ function cleanupArchive() {
86
109
  } catch {}
87
110
  }
88
111
 
89
- export function log(level, tag, msg) {
90
- const line = { at: ts(), level, tag, msg: String(msg) };
112
+ export function log(level, tag, msg, args) {
113
+ const rendered = t(String(msg), args);
114
+ const line = { at: ts(), level, tag, msg: rendered, key: args ? String(msg) : null, args: args || null };
91
115
  ring.push(line);
92
116
  if (ring.length > RING_SIZE) ring.shift();
93
117
  archiveLine(line);
@@ -96,26 +120,144 @@ export function log(level, tag, msg) {
96
120
  }
97
121
 
98
122
  export const logger = {
99
- info: (tag, msg) => log('info', tag, msg),
100
- warn: (tag, msg) => log('warn', tag, msg),
101
- error: (tag, msg) => log('error', tag, msg),
102
- debug: (tag, msg) => log('debug', tag, msg),
123
+ info: (tag, msg, args) => log('info', tag, msg, args),
124
+ warn: (tag, msg, args) => log('warn', tag, msg, args),
125
+ error: (tag, msg, args) => log('error', tag, msg, args),
126
+ debug: (tag, msg, args) => log('debug', tag, msg, args),
103
127
  };
104
128
 
129
+ /**
130
+ * 把各账号的失败结论压缩成「失败类型×次数」摘要,写进“全部失败”日志——
131
+ * 网络抖动 / 验证码 / 额度 / 限流 / 拉黑 一眼可辨,不用再靠“有没有伴随 3012”去反推。
132
+ * 三处网关共用同一口径;未知错误归入「上游错误」。
133
+ */
134
+ export function summarizeAttempts(attempted) {
135
+ if (!Array.isArray(attempted) || !attempted.length) return '';
136
+ const buckets = new Map();
137
+ for (const a of attempted) {
138
+ const err = String(a && a.error ? a.error : '未知错误');
139
+ let key;
140
+ if (a && a.captcha) key = '验证码被拒';
141
+ else if (/验证码/.test(err)) key = '验证码被拒';
142
+ else if (/拉黑|凭据失效|JWT|Token 彻底失效|401/.test(err)) key = '账号拉黑';
143
+ else if (/额度耗尽|额度不足|已打标/.test(err)) key = '额度耗尽';
144
+ else if (/额度/.test(err)) key = '额度瞬时拒绝';
145
+ else if (/429|限流/.test(err)) key = '429 限流';
146
+ else if (/超时|timeout|timed out|连接|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|terminated|aborted|fetch failed|网络|建立事件流失败/.test(err)) key = '网络错误';
147
+ else key = '上游错误';
148
+ buckets.set(key, (buckets.get(key) || 0) + 1);
149
+ }
150
+ return [...buckets.entries()].map(([k, n]) => `${k}×${n}`).join('、');
151
+ }
152
+
153
+ /**
154
+ * 运行期拼出的复合参数(如「[WorkBuddy 国内版] 账号名」)不可能整串进字典(账号名千变万化),
155
+ * 其中已知的产品 / 区域标签按当前语言做子串替换,其余字段原样保留。
156
+ */
157
+ // 除产品线标签外,失败摘要的桶标签(summarizeAttempts 产出,记进 {detail} 参数,形如「账号拉黑×2」)
158
+ // 同样是运行期复合串,整串无法进词典,按子串替换。
159
+ const ATTEMPT_BUCKETS = ['验证码被拒', '账号拉黑', '额度耗尽', '额度瞬时拒绝', '429 限流', '网络错误', '上游错误'];
160
+
161
+ function localizeEmbedded(text) {
162
+ let out = text;
163
+ for (const zh of [...Object.values(PROVIDER_LABEL), ...ATTEMPT_BUCKETS]) {
164
+ if (!out.includes(zh)) continue;
165
+ const tv = t(zh);
166
+ if (tv !== zh) out = out.split(zh).join(tv);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** 带运行时数字的既有消息(连签 11 天 / 第 1 天 +400 积分):按模式还原成模板再按当前语言渲染 */
172
+ const ARG_PATTERNS = [
173
+ [/^今日已签(连签 (\d+) 天)$/, '今日已签(连签 {n} 天)', (m) => ({ n: m[1] })],
174
+ [/^今日第 (\d+) 天已签到(([\d.]+) \+ ([\d.]+) 积分)$/, '今日第 {d} 天已签到({a} + {b} 积分)', (m) => ({ d: m[1], a: m[2], b: m[3] })],
175
+ [/^签到成功第 (\d+) 天(\+(\d+) 积分)$/, '签到成功第 {d} 天(+{p} 积分)', (m) => ({ d: m[1], p: m[2] })],
176
+ [/^今日已领取(\+(\d+) 积分)$/, '今日已领取(+{p} 积分)', (m) => ({ p: m[1] })],
177
+ [/^本机今日国际版额度已由「(.+)」领取(Qoder 每台设备每天限领一次)$/, '本机今日国际版额度已由「{name}」领取(Qoder 每台设备每天限领一次)', (m) => ({ name: localizeEmbedded(m[1]) })],
178
+ ];
179
+
180
+ /** 字符串参数的三级翻译:整串精确 → 模式还原模板 → 子串标签替换 */
181
+ function localizeArgString(v, ctx) {
182
+ const tv = t(v, ctx);
183
+ if (tv !== v) return tv;
184
+ for (const [re, tpl, vars] of ARG_PATTERNS) {
185
+ const m = re.exec(v);
186
+ if (m) return t(tpl, vars(m));
187
+ }
188
+ return localizeEmbedded(v);
189
+ }
190
+
191
+ function localizeArgs(args) {
192
+ if (!args) return args;
193
+ const loc = {};
194
+ for (const [k, v] of Object.entries(args)) loc[k] = typeof v === 'string' ? localizeArgString(v, args) : v;
195
+ const out = {};
196
+ for (const [k, v] of Object.entries(args)) out[k] = typeof v === 'string' ? localizeArgString(v, loc) : v;
197
+ return out;
198
+ }
199
+
105
200
  export function getLogs(limit = 100, tag = null) {
106
- const lines = tag ? ring.filter((l) => l.tag === tag) : ring;
107
- return lines.slice(-limit);
201
+ // tag=null 是面板「运行日志」:网关调用日志(*-GW)刷屏快、口径不同,
202
+ // 只在自己的标签页里展示,不混进运行日志;显式传 tag 的查询不受影响。
203
+ const lines = tag ? ring.filter((l) => l.tag === tag) : ring.filter((l) => !(l.tag || '').endsWith('-GW'));
204
+ return lines.slice(-limit).map((l) => (l.key ? { ...l, msg: t(l.key, localizeArgs(l.args)) } : l));
108
205
  }
109
206
 
110
- /** 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件 */
111
- export function closeArchiveStream() {
112
- if (archiveStream) {
113
- const prev = archiveStream;
114
- const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
115
- archiveStream = null;
116
- prev.on('error', () => {});
117
- prev.on('close', () => gzipAndCleanup(prevFile));
118
- prev.end();
207
+
208
+ /**
209
+ * 读取结构化归档(.jsonl;缺省读当日、最多 limit 条)并按当前语言重渲染。
210
+ * 旧版本没有 .jsonl 时返回 [],调用方静默回退到环形缓冲。
211
+ * 同时兼容已 gzip 的当日归档(.jsonl.gz)。
212
+ */
213
+ export function readArchiveJsonl({ day = null, limit = 2000 } = {}) {
214
+ if (!archiveDir) return [];
215
+ const d = day || ts().slice(0, 10);
216
+ const file = path.join(archiveDir, `daemon-${d}.jsonl`);
217
+ const gz = file + '.gz';
218
+ let raw = '';
219
+ try {
220
+ raw = fsSync.existsSync(file) ? fsSync.readFileSync(file, 'utf8') : '';
221
+ if (!raw && fsSync.existsSync(gz)) raw = zlib.gunzipSync(fsSync.readFileSync(gz)).toString('utf8');
222
+ } catch { return []; }
223
+ if (!raw) return [];
224
+ const lines = raw.split('\n').filter(Boolean).slice(-Math.max(1, Number(limit) || 2000));
225
+ const out = [];
226
+ for (const ln of lines) {
227
+ try {
228
+ const e = JSON.parse(ln);
229
+ out.push({ at: e.at, level: e.level, tag: e.tag, msg: e.key ? t(e.key, localizeArgs(e.args)) : e.msg });
230
+ } catch {}
119
231
  }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件。
237
+ *
238
+ * 返回 Promise,等流真正 close(gzip 已完成)才 resolve —— 优雅退出路径紧接着就要
239
+ * process.exit(),不等的话回调根本没机会跑,今天的日志既不落盘也不归档。
240
+ * 调用方不 await(测试里就是)也完全兼容,只是没人等它。
241
+ */
242
+ export function closeArchiveStream() {
243
+ const prev = archiveStream;
244
+ const prevJ = jsonlStream;
245
+ const prevFile = archiveDay ? path.join(archiveDir, `daemon-${archiveDay}.log`) : null;
246
+ const prevJFile = jsonlFile;
247
+ archiveStream = null;
248
+ jsonlStream = null;
120
249
  archiveDay = null;
250
+ const closeOne = (stream, file) => new Promise((resolve) => {
251
+ if (!stream) return resolve();
252
+ let settled = false;
253
+ const finish = (gzip) => { if (!settled) { settled = true; if (gzip) gzipAndCleanup(file); resolve(); } };
254
+ stream.on('error', () => finish(false));
255
+ // 只等 close 事件收尾(gzip 也在这里做):end 的回调在 finish 阶段就触发,
256
+ // 那时 close 还没来、.gz 还没生成——await 完断言会看到对象不齐(测试 #3 逮过)
257
+ stream.on('close', () => finish(true));
258
+ const timer = setTimeout(() => finish(false), 2000); // 兜底:close 不来不挂死
259
+ timer.unref?.();
260
+ stream.end();
261
+ });
262
+ return Promise.all([closeOne(prev, prevFile), closeOne(prevJ, prevJFile)]);
121
263
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { fetchJsonRace } from './zcodeClient.js';
16
+ import { withRefreshDedupe, refreshFpDead } from './refreshGuard.js';
16
17
  import { FETCH_TIMEOUT_MS } from './constants.js';
17
18
 
18
19
  export const MINIMAX_AGENT_BASE = 'https://agent.minimax.cn';
@@ -25,6 +26,14 @@ export const SIGNIN_STATUS_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/sig
25
26
  export const SIGNIN_CLAIM_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop`;
26
27
  export const TOKEN_REFRESH_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
27
28
 
29
+ /** 时间戳宽容转换(口径同 traeClient.iso):≤1e11 视为秒,否则视为毫秒;0/无效 → null */
30
+ function isoMs(v) {
31
+ const n = Number(v);
32
+ if (!Number.isFinite(n) || n <= 0) return null;
33
+ const d = new Date(n < 1e11 ? n * 1000 : n);
34
+ return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
35
+ }
36
+
28
37
  function authHeaders(token) {
29
38
  const t = token?.startsWith('Bearer ') ? token : `Bearer ${String(token || '').trim()}`;
30
39
  return {
@@ -77,8 +86,10 @@ export async function refreshMiniMaxToken(refreshToken) {
77
86
  /**
78
87
  * 带自动刷新 401 重试的高阶调用。
79
88
  *
80
- * 刷新前若该账号来自本机导入(meta.authRecordKey),先从 ~/.minimax auth.json 重读该 record 的
81
- * 最新 refreshToken——本机客户端会独立轮换刷新,账号库快照可能已被服务端作废(invalid_grant)。
89
+ * 刷新前若该账号带有 meta.authRecordKey(绑定本机客户端凭据链,仅本机导入账号会有),
90
+ * 先从 ~/.minimax auth.json 重读该 record 的最新 refreshToken——本机客户端会独立轮换刷新,
91
+ * 账号库快照可能已被服务端作废(invalid_grant)。对齐时会校验 uid 归属,链不算本账号的则跳过
92
+ * (见 minimaxLocal.alignMiniMaxFromLocal)。
82
93
  * 刷新成功后经 ctx.onRefresh 回写账号库,并尝试安全回写 auth.json(见 minimaxLocal.writeMiniMaxAuth)。
83
94
  */
84
95
  export async function withMiniMaxAuth(account, fn, ctx = {}) {
@@ -94,44 +105,38 @@ export async function withMiniMaxAuth(account, fn, ctx = {}) {
94
105
  }
95
106
 
96
107
  /**
97
- * refreshToken 是一次性的、刷新即轮换:两个并发 401 拿同一个 refreshToken 去刷新,
98
- * 必然一个成功一个 invalid_grant。签到轮(checkin)与面板的额度查询互不知情,
99
- * 面板每 2 分钟刷一轮账号额度,很容易和 2 小时一次的签到轮撞上。
100
- * 用 in-flight 表把同一账号的并发刷新合并成一次,后到者直接用刷新出来的新 token 重试。
108
+ * 并发互斥 + 失效链熔断已提升到 src/refreshGuard.js —— workbuddy / mirasim 复用同一守卫。
109
+ * MiniMax 特有的点:刷新前先 alignMiniMaxFromLocal 对齐本机客户端最新链(客户端会轮换)。
101
110
  */
102
- const refreshInFlight = new Map();
103
-
104
111
  function refreshAccountToken(account, ctx = {}) {
105
- const key = account.id || account.token;
106
- const inflight = refreshInFlight.get(key);
107
- if (inflight) return inflight;
108
- const p = (async () => {
109
- // 本机导入账号:刷新前对齐客户端最新的 refreshToken,避免用陈旧快照刷新触发 invalid_grant
112
+ return withRefreshDedupe(account, async (a) => {
110
113
  try {
111
114
  const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
112
- alignMiniMaxFromLocal(account, ctx.log);
115
+ alignMiniMaxFromLocal(a, ctx.log);
113
116
  } catch {}
114
117
  ctx.log?.('MiniMax token 已过期,正在自动刷新…');
115
118
  try {
116
- const refreshed = await refreshMiniMaxToken(account.refreshToken);
119
+ const refreshed = await refreshMiniMaxToken(a.refreshToken);
117
120
  const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString();
118
121
  const creds = {
119
122
  token: refreshed.accessToken,
120
123
  refreshToken: refreshed.refreshToken,
121
124
  expiresAt,
122
125
  };
123
- account.token = creds.token;
124
- account.refreshToken = creds.refreshToken;
125
- account.expiresAt = expiresAt;
126
+ a.token = creds.token;
127
+ a.refreshToken = creds.refreshToken;
128
+ a.expiresAt = expiresAt;
126
129
  await ctx.onRefresh?.(creds);
127
130
  return creds;
128
131
  } catch (refErr) {
129
132
  ctx.log?.(`MiniMax token 自动刷新失败: ${refErr.message}`);
130
133
  throw refErr;
131
134
  }
132
- })().finally(() => refreshInFlight.delete(key));
133
- refreshInFlight.set(key, p);
134
- return p;
135
+ }, {
136
+ deadMessage: 'MiniMax 凭据链已被服务端作废,等待重新登录',
137
+ deadNotice: 'MiniMax 凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)',
138
+ log: ctx.log,
139
+ });
135
140
  }
136
141
 
137
142
  /**
@@ -250,7 +255,8 @@ export function normalizeMiniMaxQuota(data) {
250
255
  // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
251
256
  // MiniMax 与 Qoder / Trae 同为「积分」口径,必须计入剩余积分合计。
252
257
  plan: data.plan_name || (data.is_pro_builder ? 'Pro' : '免费版'),
253
- planExpiresAt: data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null,
258
+ // expires_at 是毫秒时间戳:再乘 1000 会显示成 58729 年(实测免费版返回 0,按无到期处理)
259
+ planExpiresAt: isoMs(data.expires_at),
254
260
  parts,
255
261
  empty: total <= 0,
256
262
  };