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/README.md +87 -13
- package/bin/creditdaddy.js +158 -8
- package/package.json +32 -29
- package/src/accounts.js +10 -0
- package/src/authDevice.js +5 -4
- package/src/bgdaemon.js +341 -0
- package/src/checkin.js +72 -41
- package/src/daemon.js +1266 -997
- package/src/i18n/en.json +1490 -0
- package/src/i18n/ja.json +1490 -0
- package/src/i18n/ko.json +1490 -0
- package/src/i18n/zh-TW.json +1490 -0
- package/src/i18n.js +47 -0
- package/src/logger.js +162 -20
- package/src/minimaxClient.js +28 -22
- package/src/minimaxGateway.js +74 -50
- package/src/minimaxLocal.js +61 -16
- package/src/mirasimClient.js +6 -1
- package/src/panel.html +1342 -591
- package/src/qoderClient.js +24 -4
- package/src/qoderUmid.js +4 -3
- package/src/refreshGuard.js +69 -0
- package/src/store.js +27 -1
- package/src/tenrouter.js +14 -7
- package/src/tenrouterAccounts.js +31 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +698 -0
- package/src/transfer.js +44 -6
- package/src/workbuddyClient.js +14 -7
- package/src/zcodeAutoClaim.js +47 -7
- package/src/zcodeClient.js +76 -13
- package/src/zcodeGateway.js +41 -22
- package/src/zcodePlanShape.json +3 -3
package/src/minimaxGateway.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (上游为标准 Anthropic Messages 协议:https://agent.minimax.cn/mavis/api/v1/llm/v1/messages)
|
|
6
6
|
*
|
|
7
7
|
* 架构特性:
|
|
8
|
-
* 1.
|
|
8
|
+
* 1. 多账号粘性主力(provider=minimax):按剩余积分选主力并保持,出错/限流/凭据失效自动切下一个
|
|
9
9
|
* 2. 自动刷新与重试:401 凭据失效时自动走 OAuth refresh 换取新 token 并重试,彻底失效才拉黑
|
|
10
10
|
* 3. 429 限流保护:429 限流自动冷却 5 分钟
|
|
11
11
|
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
@@ -17,11 +17,13 @@ import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
|
17
17
|
import { refreshMiniMaxToken, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
|
|
18
18
|
import { refreshContext } from './accounts.js';
|
|
19
19
|
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
20
|
-
import { logger } from './logger.js';
|
|
20
|
+
import { logger, summarizeAttempts } from './logger.js';
|
|
21
|
+
import { t } from './i18n.js';
|
|
21
22
|
|
|
22
23
|
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
23
24
|
const MAX_ATTEMPTS = 5;
|
|
24
25
|
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
26
|
+
const UPSTREAM_CONNECT_MS = 15_000;
|
|
25
27
|
|
|
26
28
|
// ── 开关(持久化在 settings.minimaxGateway) ──
|
|
27
29
|
|
|
@@ -44,7 +46,8 @@ export async function setGatewayEnabled(v) {
|
|
|
44
46
|
|
|
45
47
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
46
48
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/刷新,自动复活)
|
|
47
|
-
const weights = new Map(); // accountId →
|
|
49
|
+
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }(查询失败时保留旧快照)
|
|
50
|
+
const WEIGHT_TTL_MS = 10 * 60_000; // 权重超过该时长重新查询额度,让用量变化能反映到分流上
|
|
48
51
|
|
|
49
52
|
export const stats = {
|
|
50
53
|
lastCallAt: null,
|
|
@@ -79,43 +82,50 @@ function isDead(account) {
|
|
|
79
82
|
if (fp === credFingerprint(account)) return true;
|
|
80
83
|
dead.delete(account.id);
|
|
81
84
|
cooling.delete(account.id);
|
|
82
|
-
logger.info('MINIMAX-GW',
|
|
85
|
+
logger.info('MINIMAX-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
83
86
|
return false;
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
/** 测试专用:仅让权重快照过期(保留粘性/拉黑状态),用于模拟额度变化 */
|
|
90
|
+
export function __expireWeightsForTests() { weights.clear(); }
|
|
91
|
+
|
|
86
92
|
/** 测试专用:清空进程内状态 */
|
|
87
93
|
export function __resetForTests() {
|
|
88
94
|
cooling.clear();
|
|
89
95
|
dead.clear();
|
|
90
96
|
weights.clear();
|
|
91
|
-
|
|
97
|
+
stickyId = null;
|
|
92
98
|
stats.lastCallAt = null;
|
|
93
99
|
stats.calls = 0;
|
|
94
100
|
stats.lastAccount = null;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
/**
|
|
103
|
+
/** 账号权重:剩余积分(带 TTL 的快照,避免每个请求都打额度接口)。查询失败时保留上次快照权重。 */
|
|
98
104
|
async function accountWeight(a) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const hit = weights.get(a.id);
|
|
106
|
+
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
107
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
108
|
+
try {
|
|
109
|
+
const q = await fetchMiniMaxQuota(a, refreshContext(a, (m) => logger.info("MINIMAX-GW", t('{label} {msg}', { label: a.name || a.uid || a.id, msg: m }))));
|
|
110
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
111
|
+
} catch {
|
|
112
|
+
// 额度查询失败时保留上次快照权重:一次网络抖动不该把主力账号踢成保底权重、引发主力来回切换
|
|
107
113
|
}
|
|
108
|
-
|
|
114
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
115
|
+
return w;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
// ── 粘性主力选型 ──
|
|
119
|
+
// 上次的主力账号仍健康时继续用它:两个账号积分接近时若逐请求轮换,会在两账号间
|
|
120
|
+
// 交替刷屏(「一主一备」困扰的来源,用户期望「出错/积分拉开差距才换」);
|
|
121
|
+
// 仅当另一账号剩余积分明显更多,或主力被拉黑/冷却,才主动换主力。
|
|
122
|
+
let stickyId = null;
|
|
123
|
+
const STICKY_MARGIN_RATIO = 0.1; // 积分领先超过 10% 才换
|
|
124
|
+
const STICKY_MARGIN_ABS = 50; // 且绝对差超过 50 积分
|
|
114
125
|
|
|
115
126
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* 所以这里直接把权重最高的放最前,其余按有效权重降序兜底。
|
|
127
|
+
* 可用账号队列:队首 = 本次主力账号,其余按剩余积分降序作重试兜底。
|
|
128
|
+
* 拉黑/冷却在挂载时剔除;扭转主力粘性见上方说明。
|
|
119
129
|
*/
|
|
120
130
|
async function rotationQueue() {
|
|
121
131
|
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
@@ -125,24 +135,23 @@ async function rotationQueue() {
|
|
|
125
135
|
if (isDead(a)) continue;
|
|
126
136
|
if ((cooling.get(a.id) || 0) > now) continue;
|
|
127
137
|
if (!a.token) continue;
|
|
128
|
-
|
|
129
|
-
const w = await accountWeight(a);
|
|
130
|
-
swrr.set(a.id, (swrr.get(a.id) || 0) + w);
|
|
131
|
-
ready.push({ account: a, weight: w });
|
|
138
|
+
ready.push({ account: a, weight: await accountWeight(a) });
|
|
132
139
|
}
|
|
133
140
|
if (!ready.length) return [];
|
|
134
141
|
|
|
135
|
-
|
|
136
|
-
let
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.
|
|
144
|
-
.
|
|
145
|
-
|
|
142
|
+
ready.sort((x, y) => y.weight - x.weight);
|
|
143
|
+
let head = ready[0];
|
|
144
|
+
const sticky = stickyId ? ready.find((it) => it.account.id === stickyId) : null;
|
|
145
|
+
if (sticky && sticky.account.id !== head.account.id) {
|
|
146
|
+
const margin = Math.max(STICKY_MARGIN_ABS, head.weight * STICKY_MARGIN_RATIO);
|
|
147
|
+
if (head.weight - sticky.weight < margin) head = sticky;
|
|
148
|
+
}
|
|
149
|
+
if (stickyId && stickyId !== head.account.id) {
|
|
150
|
+
const from = all.find((a) => a.id === stickyId);
|
|
151
|
+
logger.info('MINIMAX-GW', '主力账号 {from} → {to}(剩余积分领先)', { from: from?.name || from?.uid || stickyId, to: head.account.name || head.account.uid || head.account.id });
|
|
152
|
+
}
|
|
153
|
+
stickyId = head.account.id;
|
|
154
|
+
return [head.account, ...ready.filter((it) => it.account.id !== head.account.id).map((it) => it.account)];
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
async function unavailableReason() {
|
|
@@ -177,7 +186,13 @@ function remoteAllowed(remote, allowList) {
|
|
|
177
186
|
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
178
187
|
const e = String(entry || '').trim();
|
|
179
188
|
if (!e) return false;
|
|
180
|
-
if (e.endsWith('*'))
|
|
189
|
+
if (e.endsWith('*')) {
|
|
190
|
+
// 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
|
|
191
|
+
// 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
|
|
192
|
+
const prefix = e.slice(0, -1);
|
|
193
|
+
if (!prefix.endsWith('.')) return false;
|
|
194
|
+
return host.toLowerCase().startsWith(prefix.toLowerCase());
|
|
195
|
+
}
|
|
181
196
|
return host.toLowerCase() === e.toLowerCase();
|
|
182
197
|
});
|
|
183
198
|
}
|
|
@@ -212,7 +227,8 @@ export async function handleGateway(req, res) {
|
|
|
212
227
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
213
228
|
return res.end(JSON.stringify({ error: 'POST /gateway/minimax/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
214
229
|
}
|
|
215
|
-
|
|
230
|
+
const settings = await loadSettings();
|
|
231
|
+
if (settings.minimaxGateway !== true) {
|
|
216
232
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
217
233
|
return res.end(JSON.stringify({ error: 'MiniMax 本地网关未开启(面板 → MiniMax → 接口设置)' }));
|
|
218
234
|
}
|
|
@@ -220,7 +236,6 @@ export async function handleGateway(req, res) {
|
|
|
220
236
|
// 局域网访问控制
|
|
221
237
|
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
222
238
|
if (!isLoopbackRemote(remote)) {
|
|
223
|
-
const settings = await loadSettings();
|
|
224
239
|
if (settings.minimaxGatewayLan !== true) {
|
|
225
240
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
226
241
|
return res.end(JSON.stringify({ error: 'MiniMax 网关未允许局域网访问(面板 → MiniMax → 接口设置)' }));
|
|
@@ -251,14 +266,15 @@ export async function handleGateway(req, res) {
|
|
|
251
266
|
stats.calls += 1;
|
|
252
267
|
|
|
253
268
|
const attempted = [];
|
|
269
|
+
const clientAbort = new AbortController();
|
|
270
|
+
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
271
|
+
|
|
254
272
|
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
255
273
|
const account = queue[i];
|
|
256
274
|
if (isDead(account) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
257
275
|
const label = account.name || account.uid || account.id;
|
|
258
276
|
|
|
259
277
|
let upstream;
|
|
260
|
-
const controller = new AbortController();
|
|
261
|
-
req.on('close', () => controller.abort());
|
|
262
278
|
|
|
263
279
|
let activeToken = account.token;
|
|
264
280
|
const sendUpstream = async (tok) => {
|
|
@@ -273,7 +289,8 @@ export async function handleGateway(req, res) {
|
|
|
273
289
|
headers,
|
|
274
290
|
body: rawBody,
|
|
275
291
|
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
276
|
-
|
|
292
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
293
|
+
signal: clientAbort.signal,
|
|
277
294
|
});
|
|
278
295
|
};
|
|
279
296
|
|
|
@@ -281,12 +298,12 @@ export async function handleGateway(req, res) {
|
|
|
281
298
|
upstream = await sendUpstream(activeToken);
|
|
282
299
|
// 如果 401 且账号有 refreshToken,尝试就地刷新一次并重试
|
|
283
300
|
if (upstream.status === 401 && account.refreshToken) {
|
|
284
|
-
logger.info('MINIMAX-GW',
|
|
301
|
+
logger.info('MINIMAX-GW', '{label} 收到 401,尝试刷新凭据...', { label });
|
|
285
302
|
try {
|
|
286
303
|
// 本机导入账号:刷新前对齐 ~/.minimax auth.json 里客户端最新的 refreshToken,避免 invalid_grant
|
|
287
304
|
try {
|
|
288
305
|
const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
|
|
289
|
-
if (alignMiniMaxFromLocal(account, (m) => logger.info('MINIMAX-GW',
|
|
306
|
+
if (alignMiniMaxFromLocal(account, (m) => logger.info('MINIMAX-GW', '{label} {msg}', { label, msg: m }))) {
|
|
290
307
|
activeToken = account.token;
|
|
291
308
|
}
|
|
292
309
|
} catch {}
|
|
@@ -310,18 +327,22 @@ export async function handleGateway(req, res) {
|
|
|
310
327
|
cooling.delete(account.id);
|
|
311
328
|
upstream = await sendUpstream(activeToken);
|
|
312
329
|
} catch (refErr) {
|
|
313
|
-
logger.warn('MINIMAX-GW',
|
|
330
|
+
logger.warn('MINIMAX-GW', '{label} 凭据刷新失败: {err}', { label, err: refErr.message });
|
|
314
331
|
}
|
|
315
332
|
}
|
|
316
333
|
} catch (e) {
|
|
317
|
-
if (
|
|
334
|
+
if (clientAbort.signal.aborted) return;
|
|
318
335
|
attempted.push({ account: label, ok: false, error: e.message });
|
|
319
336
|
continue;
|
|
320
337
|
}
|
|
321
338
|
|
|
322
339
|
if (upstream.ok) {
|
|
323
340
|
stats.lastAccount = label;
|
|
324
|
-
logger.info('MINIMAX-GW',
|
|
341
|
+
logger.info('MINIMAX-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
|
|
342
|
+
try {
|
|
343
|
+
const meta = JSON.parse(rawBody);
|
|
344
|
+
logger.debug('MINIMAX-GW', '请求详情:model={model} stream={stream} ua={ua}', { model: meta?.model || '-', stream: meta?.stream ? 'y' : 'n', ua: req.headers?.['user-agent'] || '-' });
|
|
345
|
+
} catch {}
|
|
325
346
|
|
|
326
347
|
// 透传头(SSE / JSON)
|
|
327
348
|
const forwardHeaders = {
|
|
@@ -343,7 +364,8 @@ export async function handleGateway(req, res) {
|
|
|
343
364
|
}
|
|
344
365
|
}
|
|
345
366
|
} catch (e) {
|
|
346
|
-
|
|
367
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
368
|
+
logger.debug('MINIMAX-GW', '流式转发中断:{err}', { err: e.message });
|
|
347
369
|
}
|
|
348
370
|
res.end();
|
|
349
371
|
} else {
|
|
@@ -358,13 +380,13 @@ export async function handleGateway(req, res) {
|
|
|
358
380
|
if (upstream.status === 401) {
|
|
359
381
|
markDead(account);
|
|
360
382
|
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
361
|
-
logger.info('MINIMAX-GW',
|
|
383
|
+
logger.info('MINIMAX-GW', '{label} 凭据失效,拉黑', { label });
|
|
362
384
|
continue;
|
|
363
385
|
}
|
|
364
386
|
if (upstream.status === 429) {
|
|
365
387
|
markCooling(account.id, ACCOUNT_COOLING_MS);
|
|
366
388
|
attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
|
|
367
|
-
logger.info('MINIMAX-GW',
|
|
389
|
+
logger.info('MINIMAX-GW', '{label} 429 限流,冷却 5 分钟', { label });
|
|
368
390
|
continue;
|
|
369
391
|
}
|
|
370
392
|
|
|
@@ -372,6 +394,8 @@ export async function handleGateway(req, res) {
|
|
|
372
394
|
}
|
|
373
395
|
|
|
374
396
|
// 全部重试失败
|
|
397
|
+
const failedDetail = summarizeAttempts(attempted);
|
|
398
|
+
logger.warn('MINIMAX-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
375
399
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
376
400
|
res.end(JSON.stringify({
|
|
377
401
|
error: '所有 MiniMax 账号均调用失败',
|
package/src/minimaxLocal.js
CHANGED
|
@@ -49,6 +49,44 @@ function writeJson(file, data) {
|
|
|
49
49
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* recordKey → uid 的小缓存。
|
|
54
|
+
*
|
|
55
|
+
* 为什么需要:auth.json 的 record 只有 accessToken / refreshToken / clientId 这些,
|
|
56
|
+
* 稳定 uid 只能靠 profile 接口拿;profile 偶发失败时(离线 / 接口抖动),
|
|
57
|
+
* 「客户端当前是哪个账号」就没法判断了,缓存一份兜底。
|
|
58
|
+
*
|
|
59
|
+
* 注意:缓存**只对 auth.json 里仍然存在的 record 生效**(见 currentMiniMaxUid)。
|
|
60
|
+
* 客户端登出后 records 为空,此时若还回落到缓存,面板会把某个账号一直标成
|
|
61
|
+
* 「客户端当前」——这正是「客户端明明没登录却显示已登录」的来源,所以读的时候必须
|
|
62
|
+
* 与当前 record 集合求交集,不能无条件回落。
|
|
63
|
+
*/
|
|
64
|
+
function readUidCache() {
|
|
65
|
+
return readJson(minimaxUidCachePath()) || {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 写入 uid 缓存,并顺手丢掉 auth.json 里已经不存在的 record(防止缓存无限膨胀 / 陈旧) */
|
|
69
|
+
function writeUidCache(recordKey, uid) {
|
|
70
|
+
if (!recordKey || !uid) return;
|
|
71
|
+
try {
|
|
72
|
+
const cache = readUidCache();
|
|
73
|
+
if (cache[recordKey] === String(uid)) return;
|
|
74
|
+
cache[recordKey] = String(uid);
|
|
75
|
+
const live = liveRecordKeys();
|
|
76
|
+
if (live) for (const k of Object.keys(cache)) if (!live.has(k)) delete cache[k];
|
|
77
|
+
writeJson(minimaxUidCachePath(), cache);
|
|
78
|
+
} catch {
|
|
79
|
+
// 缓存写失败不影响调用方
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** auth.json 里当前仍有 accessToken 的 recordKey 集合;读不到(文件缺失/损坏)返回 null */
|
|
84
|
+
function liveRecordKeys() {
|
|
85
|
+
const authData = readJson(minimaxAuthPath());
|
|
86
|
+
if (!authData?.records) return null;
|
|
87
|
+
return new Set(Object.keys(authData.records).filter((k) => authData.records[k]?.accessToken));
|
|
88
|
+
}
|
|
89
|
+
|
|
52
90
|
/**
|
|
53
91
|
* 寻找 MiniMax Code.exe 安装路径
|
|
54
92
|
*/
|
|
@@ -115,18 +153,10 @@ export async function liveToAccount() {
|
|
|
115
153
|
// profile 失败不影响返回基础账号信息
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
// auth.json
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
if (profile?.userId && cached[recordKey] !== profile.userId) {
|
|
123
|
-
cached[recordKey] = profile.userId;
|
|
124
|
-
try {
|
|
125
|
-
writeJson(minimaxUidCachePath(), cached);
|
|
126
|
-
} catch {
|
|
127
|
-
// 缓存写失败不影响本次返回
|
|
128
|
-
}
|
|
129
|
-
}
|
|
156
|
+
// auth.json 的记录没有稳定 uid 字段,token 又高频轮换,profile 偶发失败时无处可查 ——
|
|
157
|
+
// 用 recordKey 把 uid 记住,供 currentMiniMaxUid 离线比对(见 minimaxUidCachePath 注释)。
|
|
158
|
+
const uid = profile?.userId || record.subject || record.accountId || readUidCache()[recordKey] || null;
|
|
159
|
+
if (uid) writeUidCache(recordKey, uid);
|
|
130
160
|
const name = profile?.name || (uid ? `MiniMax_${String(uid).slice(-6)}` : 'MiniMax Code');
|
|
131
161
|
|
|
132
162
|
return {
|
|
@@ -139,7 +169,10 @@ export async function liveToAccount() {
|
|
|
139
169
|
email: profile?.email || null,
|
|
140
170
|
source: 'local-app',
|
|
141
171
|
meta: {
|
|
142
|
-
authRecordKey
|
|
172
|
+
// 不写 authRecordKey:它是「这条账号绑定本机客户端凭据链」的开关,一旦挂上,
|
|
173
|
+
// 该账号刷新前就会去 auth.json 重读 refreshToken(可能会被别的账号的链顶掉)、
|
|
174
|
+
// 刷新后还会回写 auth.json。本机导入/浏览器登录混用时曾因此让两个账号共用同一条链
|
|
175
|
+
// 互相作废,故这里只留来源标记,链的绑定交给 alignMiniMaxFromLocal 按 uid 判断。
|
|
143
176
|
clientId: record.clientId,
|
|
144
177
|
scopes: record.scopes,
|
|
145
178
|
capturedAt: new Date().toISOString(),
|
|
@@ -149,7 +182,11 @@ export async function liveToAccount() {
|
|
|
149
182
|
|
|
150
183
|
/**
|
|
151
184
|
* 获取当前登录的 UID。
|
|
152
|
-
* 优先用 record 自带字段;没有则按
|
|
185
|
+
* 优先用 record 自带字段;没有则按 recordKey 命中 uid 缓存(token 会轮换,uid 不会)。
|
|
186
|
+
*
|
|
187
|
+
* records 为空(客户端从未登录 / 已登出)时一律返回 null——绝不回落到缓存。
|
|
188
|
+
* 回落的代价是:客户端登出后 uid 缓存还留着,面板会继续把那个旧账号标成
|
|
189
|
+
* 「客户端当前」,看起来像登出没生效。
|
|
153
190
|
*/
|
|
154
191
|
export function currentMiniMaxUid() {
|
|
155
192
|
const authFile = minimaxAuthPath();
|
|
@@ -160,8 +197,7 @@ export function currentMiniMaxUid() {
|
|
|
160
197
|
const record = authData.records[recordKey];
|
|
161
198
|
const own = record?.subject || record?.accountId;
|
|
162
199
|
if (own) return String(own);
|
|
163
|
-
|
|
164
|
-
return cached?.[recordKey] || null;
|
|
200
|
+
return readUidCache()[recordKey] || null;
|
|
165
201
|
}
|
|
166
202
|
|
|
167
203
|
/**
|
|
@@ -209,8 +245,17 @@ export function alignMiniMaxFromLocal(account, log) {
|
|
|
209
245
|
const key = account?.meta?.authRecordKey;
|
|
210
246
|
if (!key) return false;
|
|
211
247
|
try {
|
|
248
|
+
// 归属校验:本机客户端同一时刻只登录一个账号,auth.json 里那条 record 属于**它**,
|
|
249
|
+
// 不一定属于调用方这个账号。若两个账号(如「本机导入」与「浏览器登录」)因为历史原因
|
|
250
|
+
// 挂了同一个 authRecordKey,不校验就会拿别人的链去刷新——两个账号共用一次性 refreshToken,
|
|
251
|
+
// 必然互相作废(invalid_grant)。uid 对不上就认定不是本账号的链,直接跳过对齐。
|
|
212
252
|
const rec = readMiniMaxRecord(key);
|
|
213
253
|
if (!rec?.refreshToken || rec.refreshToken === account.refreshToken) return false;
|
|
254
|
+
const recUid = rec.subject || rec.accountId || readUidCache()[key] || null;
|
|
255
|
+
if (recUid && account.uid && String(recUid) !== String(account.uid)) {
|
|
256
|
+
log?.('本机客户端当前登录的是另一个 MiniMax 账号,跳过凭据对齐(避免两条链互相作废)');
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
214
259
|
account.refreshToken = rec.refreshToken;
|
|
215
260
|
if (rec.accessToken) account.token = rec.accessToken;
|
|
216
261
|
if (rec.expiresAtMs) account.expiresAt = new Date(rec.expiresAtMs).toISOString();
|
package/src/mirasimClient.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { fetchJsonRace } from './zcodeClient.js';
|
|
10
10
|
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
11
|
+
import { withRefreshDedupe } from './refreshGuard.js';
|
|
11
12
|
|
|
12
13
|
const AUTH_ME_URL = 'https://auth.mirasim.ai/auth/me';
|
|
13
14
|
const RELAY_LIMITS_URL = 'https://relay.mirasim.ai/v1/limits';
|
|
@@ -64,7 +65,11 @@ async function withToken(account, fn, ctx) {
|
|
|
64
65
|
let token = account.token;
|
|
65
66
|
let refreshed = false;
|
|
66
67
|
const doRefresh = async () => {
|
|
67
|
-
|
|
68
|
+
// 并发互斥 + 失效链熔断:与 minimax / workbuddy 共用 refreshGuard
|
|
69
|
+
const creds = await withRefreshDedupe(account, refreshMirasimToken, {
|
|
70
|
+
deadMessage: 'mirasim 凭据链已被服务端作废,等待重新登录',
|
|
71
|
+
deadNotice: 'mirasim 凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)',
|
|
72
|
+
});
|
|
68
73
|
account.token = creds.token;
|
|
69
74
|
account.refreshToken = creds.refreshToken;
|
|
70
75
|
if (creds.expiresAt) account.expiresAt = creds.expiresAt;
|