creditdaddy 0.9.3 → 0.9.5
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 +228 -204
- package/bin/creditdaddy.js +7 -3
- package/package.json +28 -28
- package/src/checkin.js +2 -1
- package/src/constants.js +4 -2
- package/src/daemon.js +74 -12
- package/src/localDetect.js +2 -2
- package/src/logger.js +4 -1
- package/src/mirasimClient.js +189 -0
- package/src/mirasimLocal.js +307 -0
- package/src/panel.html +566 -68
- package/src/providers.js +25 -1
- package/src/qoderApp.js +6 -4
- package/src/qoderUmid.js +25 -4
- package/src/store.js +10 -0
- package/src/zcodeClient.js +50 -36
- package/src/zcodeLocal.js +103 -1
package/src/checkin.js
CHANGED
|
@@ -15,7 +15,7 @@ import { productOf } from './constants.js';
|
|
|
15
15
|
import { startUsageSyncScheduler } from './tenrouter.js';
|
|
16
16
|
import { refreshContext } from './accounts.js';
|
|
17
17
|
import { zcodeAutoClaim } from './zcodeAutoClaim.js';
|
|
18
|
-
import { autoClaimEnabled } from './zcodeClient.js';
|
|
18
|
+
import { autoClaimEnabled, warmZcodeAppVersion } from './zcodeClient.js';
|
|
19
19
|
import { logger } from './logger.js';
|
|
20
20
|
|
|
21
21
|
const TICK_MS = 2 * 60 * 60 * 1000; // 2 小时
|
|
@@ -200,6 +200,7 @@ async function runTickInner(opts) {
|
|
|
200
200
|
export function startScheduler() {
|
|
201
201
|
if (timerHandle) return;
|
|
202
202
|
startUsageSyncScheduler(); // 10Router 用量同步(未配置 / 未开启时不做任何事)
|
|
203
|
+
warmZcodeAppVersion(); // 后台预热 ZCode 客户端版本探测(Windows 注册表,避免首个请求同步阻塞)
|
|
203
204
|
const scheduleNext = () => {
|
|
204
205
|
const delay = msUntilNextTick();
|
|
205
206
|
nextTickAt = new Date(Date.now() + delay).toISOString();
|
package/src/constants.js
CHANGED
|
@@ -28,19 +28,21 @@ export const QUOTA_USAGE_PATH = '/api/v2/quota/usage';
|
|
|
28
28
|
export const JOB_TOKEN_EXCHANGE_PATH = '/api/v1/jobToken/exchange';
|
|
29
29
|
|
|
30
30
|
// 账号的 provider = 产品 + 区域。新增产品时在此登记,并在 providers.js 挂上对应实现。
|
|
31
|
-
export const PROVIDERS = ['qoder', 'qoder-cn', 'workbuddy', 'workbuddy-intl', 'zcode'];
|
|
31
|
+
export const PROVIDERS = ['qoder', 'qoder-cn', 'workbuddy', 'workbuddy-intl', 'zcode', 'mirasim'];
|
|
32
32
|
export const PROVIDER_LABEL = {
|
|
33
33
|
qoder: 'Qoder 国际版',
|
|
34
34
|
'qoder-cn': 'Qoder 国内版',
|
|
35
35
|
workbuddy: 'WorkBuddy 国内版',
|
|
36
36
|
'workbuddy-intl': 'WorkBuddy 国际版',
|
|
37
37
|
zcode: 'ZCode',
|
|
38
|
+
mirasim: 'mirasim',
|
|
38
39
|
};
|
|
39
|
-
/** provider → 产品线(qoder / workbuddy / zcode) */
|
|
40
|
+
/** provider → 产品线(qoder / workbuddy / zcode / mirasim) */
|
|
40
41
|
export const productOf = (provider) => {
|
|
41
42
|
const p = String(provider);
|
|
42
43
|
if (p.startsWith('workbuddy')) return 'workbuddy';
|
|
43
44
|
if (p.startsWith('zcode')) return 'zcode';
|
|
45
|
+
if (p === 'mirasim') return 'mirasim';
|
|
44
46
|
return 'qoder';
|
|
45
47
|
};
|
|
46
48
|
/** provider 是否国内版 */
|
package/src/daemon.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import http from 'node:http';
|
|
41
41
|
import crypto from 'node:crypto';
|
|
42
|
+
import os from 'node:os';
|
|
42
43
|
import fs from 'node:fs/promises';
|
|
43
44
|
import path from 'node:path';
|
|
44
45
|
import { fileURLToPath } from 'node:url';
|
|
@@ -50,6 +51,7 @@ import { addAccount, importAccounts, refreshContext } from './accounts.js';
|
|
|
50
51
|
import { productImpl } from './providers.js';
|
|
51
52
|
import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
|
|
52
53
|
import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
|
|
54
|
+
import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
|
|
53
55
|
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, setAutoClaimEnabled } from './zcodeClient.js';
|
|
54
56
|
import { exportAccounts, parseImport, TransferError } from './transfer.js';
|
|
55
57
|
import { runCheckinTick, getSchedulerInfo, dayKey } from './checkin.js';
|
|
@@ -57,12 +59,12 @@ import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdent
|
|
|
57
59
|
import { umidInfo, installUmid } from './qoderUmid.js';
|
|
58
60
|
import * as tenrouter from './tenrouter.js';
|
|
59
61
|
import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
|
|
60
|
-
import { detectInstalls, scanLocalTokens, putCandidate,
|
|
62
|
+
import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
|
|
61
63
|
import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
|
|
62
64
|
|
|
63
|
-
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode'];
|
|
65
|
+
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim'];
|
|
64
66
|
|
|
65
|
-
/** 可选访问密钥:设置 CREDITDADDY_PASSWORD 后,所有 /api/* 需要 x-qd-key
|
|
67
|
+
/** 可选访问密钥:设置 CREDITDADDY_PASSWORD 后,所有 /api/* 需要 x-qd-key 头。
|
|
66
68
|
* fnOS/NAS 部署监听 0.0.0.0 时由 cmd/main 自动生成并注入。 */
|
|
67
69
|
const PANEL_KEY = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
|
|
68
70
|
|
|
@@ -105,15 +107,45 @@ function hostnameOf(hostHeader) {
|
|
|
105
107
|
try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
/** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
|
|
111
|
+
let localHostCache = { at: 0, set: null };
|
|
112
|
+
function localHosts() {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (!localHostCache.set || now - localHostCache.at > 60_000) {
|
|
115
|
+
const s = new Set(LOOPBACK_HOSTS);
|
|
116
|
+
try { s.add(os.hostname().toLowerCase()); } catch {}
|
|
117
|
+
try {
|
|
118
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
119
|
+
for (const it of list || []) {
|
|
120
|
+
if (!it?.address) continue;
|
|
121
|
+
const a = it.address.toLowerCase();
|
|
122
|
+
s.add(a);
|
|
123
|
+
if (a.includes(':')) s.add(`[${a}]`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
localHostCache = { at: now, set: s };
|
|
128
|
+
}
|
|
129
|
+
return localHostCache.set;
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
/**
|
|
109
133
|
* 浏览器侧防护,返回拒绝原因(null = 放行):
|
|
110
|
-
* -
|
|
134
|
+
* - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
|
|
135
|
+
* · 回环监听:只允许 127.0.0.1 / localhost / ::1
|
|
136
|
+
* · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
|
|
137
|
+
* 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
|
|
111
138
|
* - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
|
|
112
139
|
* 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
|
|
113
140
|
*/
|
|
114
141
|
export function rejectForeignRequest(headers, bindHost) {
|
|
115
142
|
const host = headers.host || '';
|
|
116
|
-
|
|
143
|
+
const hostName = hostnameOf(host).toLowerCase();
|
|
144
|
+
const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
|
|
145
|
+
if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
|
|
146
|
+
return 'Host 不被允许';
|
|
147
|
+
}
|
|
148
|
+
if (!loopbackBind && !PANEL_KEY && !localHosts().has(hostName)) {
|
|
117
149
|
return 'Host 不被允许';
|
|
118
150
|
}
|
|
119
151
|
const origin = headers.origin;
|
|
@@ -127,9 +159,10 @@ export function rejectForeignRequest(headers, bindHost) {
|
|
|
127
159
|
|
|
128
160
|
async function handleApi(req, res, url) {
|
|
129
161
|
if (PANEL_KEY) {
|
|
130
|
-
|
|
162
|
+
// 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
|
|
163
|
+
const key = req.headers['x-qd-key'] || '';
|
|
131
164
|
if (!keyMatches(key)) {
|
|
132
|
-
return json(res, 401, { error: '需要访问密钥(x-qd-key
|
|
165
|
+
return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
168
|
const p = url.pathname;
|
|
@@ -315,7 +348,29 @@ async function handleApi(req, res, url) {
|
|
|
315
348
|
return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
|
|
316
349
|
}
|
|
317
350
|
}
|
|
318
|
-
if (
|
|
351
|
+
if (target.provider === 'mirasim') {
|
|
352
|
+
// 防丢号:先把当前登录同步进账号库
|
|
353
|
+
try {
|
|
354
|
+
const live = await mirasimLiveAccount();
|
|
355
|
+
if (live && live.uid !== target.uid) {
|
|
356
|
+
await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:' + e.message));
|
|
357
|
+
}
|
|
358
|
+
} catch (e) {}
|
|
359
|
+
try {
|
|
360
|
+
let closedClient = false;
|
|
361
|
+
if (body?.force === true) {
|
|
362
|
+
const t = terminateMirasim();
|
|
363
|
+
closedClient = t.closed === true;
|
|
364
|
+
if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)');
|
|
365
|
+
}
|
|
366
|
+
const r = await mirasimSwitchTo(target, { force: body?.force === true });
|
|
367
|
+
logger.info('DAEMON', r.alreadyActive ? `mirasim 当前已是 ${target.name || target.id}` : `mirasim 已切换到 ${target.name || target.id}`);
|
|
368
|
+
return json(res, 200, { ok: true, closedClient, ...r });
|
|
369
|
+
} catch (e) {
|
|
370
|
+
return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim 账号支持切换' });
|
|
319
374
|
// 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
|
|
320
375
|
const cur = readWorkbuddySessions().accounts.find((a) => a.current);
|
|
321
376
|
if (cur && cur.uid !== target.uid) {
|
|
@@ -350,7 +405,7 @@ async function handleApi(req, res, url) {
|
|
|
350
405
|
|
|
351
406
|
// ── 本机检测 / 凭据扫描 ──
|
|
352
407
|
if (p === '/api/local/detect' && method === 'GET') {
|
|
353
|
-
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), legacy: detectInstalls() });
|
|
408
|
+
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), legacy: detectInstalls() });
|
|
354
409
|
}
|
|
355
410
|
if (p === '/api/local/scan' && method === 'POST') {
|
|
356
411
|
const existing = await loadAccounts();
|
|
@@ -390,19 +445,24 @@ async function handleApi(req, res, url) {
|
|
|
390
445
|
const z = zcodeLiveAccount();
|
|
391
446
|
if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
|
|
392
447
|
} catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
|
|
393
|
-
// 4)
|
|
448
|
+
// 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
|
|
449
|
+
try {
|
|
450
|
+
const m = await mirasimLiveAccount();
|
|
451
|
+
if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
|
|
452
|
+
} catch (e) { errors.push({ file: '~/.mirasim/setting.json', error: e.message }); }
|
|
453
|
+
// 5) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
|
|
394
454
|
const det = detectInstalls();
|
|
395
455
|
const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
|
|
396
456
|
if (det.cliDir.exists) dirs.push(det.cliDir.path);
|
|
397
457
|
const legacy = await scanLocalTokens(dirs);
|
|
398
458
|
for (const c of legacy.candidates) {
|
|
399
|
-
candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null,
|
|
459
|
+
candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
|
|
400
460
|
}
|
|
401
461
|
return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
|
|
402
462
|
}
|
|
403
463
|
if (p === '/api/local/import' && method === 'POST') {
|
|
404
464
|
const body = await readBody(req);
|
|
405
|
-
const cand =
|
|
465
|
+
const cand = peekCandidate(String(body?.candidateId || ''));
|
|
406
466
|
if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
|
|
407
467
|
const record = cand.record || {
|
|
408
468
|
provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
|
|
@@ -495,6 +555,8 @@ async function handleApi(req, res, url) {
|
|
|
495
555
|
zcodeCurrentUid: currentZcodeUid(),
|
|
496
556
|
zcodeCurrentIdentity: currentZcodeIdentity(),
|
|
497
557
|
zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
|
|
558
|
+
mirasimCurrentUid: currentMirasimUid(),
|
|
559
|
+
mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
|
|
498
560
|
keyRequired: Boolean(PANEL_KEY),
|
|
499
561
|
});
|
|
500
562
|
}
|
package/src/localDetect.js
CHANGED
|
@@ -138,8 +138,8 @@ export function putCandidate(c) {
|
|
|
138
138
|
return id;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/** 按 candidateId
|
|
142
|
-
export function
|
|
141
|
+
/** 按 candidateId 读取候选(不会消费掉,导入前可反复查询);过期返回 null */
|
|
142
|
+
export function peekCandidate(id) {
|
|
143
143
|
const c = candidates.get(id);
|
|
144
144
|
if (!c) return null;
|
|
145
145
|
if (c.at < Date.now() - CAND_TTL_MS) { candidates.delete(id); return null; }
|
package/src/logger.js
CHANGED
|
@@ -4,7 +4,10 @@ const RING_SIZE = 300;
|
|
|
4
4
|
const ring = [];
|
|
5
5
|
|
|
6
6
|
function ts() {
|
|
7
|
-
|
|
7
|
+
const d = new Date();
|
|
8
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
9
|
+
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
10
|
+
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export function log(level, tag, msg) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirasim API 客户端 — 用户资料、套餐、5h/7d 滚动窗口配额查询。
|
|
3
|
+
*
|
|
4
|
+
* GET https://auth.mirasim.ai/auth/me 用户资料(name, email, plan, plan_exp, id)
|
|
5
|
+
* GET https://relay.mirasim.ai/v1/limits 滚动窗口(5h, 7d, 7d_claude 等)
|
|
6
|
+
* 注:relay.mirasim.ai 对大陆直连限制区域(429),需走统一出口 fetchJsonRace 代理
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
10
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const AUTH_ME_URL = 'https://auth.mirasim.ai/auth/me';
|
|
13
|
+
const RELAY_LIMITS_URL = 'https://relay.mirasim.ai/v1/limits';
|
|
14
|
+
const AUTH_REFRESH_URL = 'https://auth.mirasim.ai/auth/refresh';
|
|
15
|
+
|
|
16
|
+
export async function fetchMirasimProfile(token) {
|
|
17
|
+
const res = await fetchJsonRace(AUTH_ME_URL, {
|
|
18
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
19
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
if (res.status === 401) { const e = new Error('mirasim 登录凭据已过期'); e.auth = true; throw e; }
|
|
23
|
+
throw new Error(`获取 mirasim 用户信息失败: HTTP ${res.status}`);
|
|
24
|
+
}
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 用 refreshToken 换新凭据(refreshToken 会轮换,两个都要保存)。
|
|
30
|
+
* 返回 { token, refreshToken, expiresAt };没有 refreshToken 或刷新失败抛错。
|
|
31
|
+
*/
|
|
32
|
+
export async function refreshMirasimToken(account) {
|
|
33
|
+
if (!account.refreshToken) throw new Error('没有 refreshToken,无法刷新,请在 mirasim 客户端重新登录后重新导入');
|
|
34
|
+
const res = await fetchJsonRace(AUTH_REFRESH_URL, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
37
|
+
body: { refresh_token: account.refreshToken },
|
|
38
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
if (res.status === 401 || res.status === 403) {
|
|
42
|
+
const e = new Error('refreshToken 已失效,请在 mirasim 客户端重新登录后到「添加账号 → 本机导入」同步');
|
|
43
|
+
e.authInvalid = true;
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`mirasim 凭据刷新失败: HTTP ${res.status}`);
|
|
47
|
+
}
|
|
48
|
+
const d = await res.json();
|
|
49
|
+
const token = d.access_token || d.accessToken;
|
|
50
|
+
if (!token) throw new Error('mirasim 刷新响应缺少 access_token(上游结构可能变化)');
|
|
51
|
+
let expiresAt = null;
|
|
52
|
+
try {
|
|
53
|
+
const p = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
54
|
+
if (Number.isFinite(p.exp)) expiresAt = new Date(p.exp * 1000).toISOString();
|
|
55
|
+
} catch {}
|
|
56
|
+
return { token, refreshToken: d.refresh_token || d.refreshToken || account.refreshToken, expiresAt };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 以有效 token 执行 fn(token);401 时刷新一次再重试。
|
|
61
|
+
* 刷新成功经 ctx.onRefresh(newCreds) 回写账号库与 setting.json(调用方负责持久化)。
|
|
62
|
+
*/
|
|
63
|
+
async function withToken(account, fn, ctx) {
|
|
64
|
+
let token = account.token;
|
|
65
|
+
let refreshed = false;
|
|
66
|
+
const doRefresh = async () => {
|
|
67
|
+
const creds = await refreshMirasimToken(account);
|
|
68
|
+
account.token = creds.token;
|
|
69
|
+
account.refreshToken = creds.refreshToken;
|
|
70
|
+
if (creds.expiresAt) account.expiresAt = creds.expiresAt;
|
|
71
|
+
token = creds.token;
|
|
72
|
+
refreshed = true;
|
|
73
|
+
await ctx?.onRefresh?.(creds);
|
|
74
|
+
};
|
|
75
|
+
let r = await fn(token);
|
|
76
|
+
if (r.status === 401 && !refreshed) {
|
|
77
|
+
await doRefresh();
|
|
78
|
+
r = await fn(token);
|
|
79
|
+
}
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 统一配额结构(对齐 Qoder/WorkBuddy/ZCode) */
|
|
84
|
+
export function normalizeMirasimLimits(limits, profile = null) {
|
|
85
|
+
const windows = Array.isArray(limits?.windows) ? limits.windows : [];
|
|
86
|
+
const epochToIso = (sec) => (Number.isFinite(sec) && sec > 0 ? new Date(sec * 1000).toISOString() : null);
|
|
87
|
+
|
|
88
|
+
const WINDOW_LABELS = {
|
|
89
|
+
'5h': '5小时窗口',
|
|
90
|
+
'7d': '7天全局窗口',
|
|
91
|
+
'7d_claude': '7天 Claude 上限',
|
|
92
|
+
'7d_sonnet': '7天 Sonnet 上限',
|
|
93
|
+
'7d_opus': '7天 Opus 上限',
|
|
94
|
+
'7d_fable': '7天 Fable 上限',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const parts = windows.map((w) => {
|
|
98
|
+
const budget = Number(w.budget) || 0;
|
|
99
|
+
const used = Number(w.used) || 0;
|
|
100
|
+
const remaining = Math.max(0, budget - used);
|
|
101
|
+
const usedPct = budget > 0 ? Math.min(100, Math.round((used / budget) * 1000) / 10) : 0;
|
|
102
|
+
const remPct = Math.max(0, Math.round((100 - usedPct) * 10) / 10);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: WINDOW_LABELS[w.name] || w.name,
|
|
106
|
+
total: budget,
|
|
107
|
+
used,
|
|
108
|
+
remaining,
|
|
109
|
+
percent: usedPct,
|
|
110
|
+
remainingPercentage: remPct,
|
|
111
|
+
unit: '',
|
|
112
|
+
resetAt: epochToIso(w.reset_at),
|
|
113
|
+
expiresAt: epochToIso(w.reset_at),
|
|
114
|
+
recurring: true,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 瓶颈短板原则:5h 窗口与 7d 全局窗口是“与”的关系(任何一个见底,请求就发不出去)
|
|
119
|
+
// 逻辑上:周额度没了,5 小时即使是 100% 也没有意义!所以主额度必须反映实际可用状态
|
|
120
|
+
const w5h = parts.find((p) => p.name.includes('5小时'));
|
|
121
|
+
const w7d = parts.find((p) => p.name.includes('7天全局'));
|
|
122
|
+
const bottleneck = w7d && w7d.remainingPercentage <= 0 ? w7d
|
|
123
|
+
: w5h && w5h.remainingPercentage <= 0 ? w5h
|
|
124
|
+
: w7d && w5h ? (w7d.remainingPercentage < w5h.remainingPercentage ? w7d : w5h)
|
|
125
|
+
: parts[0] || null;
|
|
126
|
+
|
|
127
|
+
const rem = bottleneck ? bottleneck.remainingPercentage : 0;
|
|
128
|
+
const isExhausted = bottleneck ? bottleneck.remainingPercentage <= 0 : false;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
total: 100,
|
|
132
|
+
used: 100 - rem,
|
|
133
|
+
remaining: rem,
|
|
134
|
+
unit: '%',
|
|
135
|
+
plan: profile?.plan ? String(profile.plan).toUpperCase() : (limits?.paid ? 'PRO' : 'FREE'),
|
|
136
|
+
planExpiresAt: epochToIso(profile?.plan_exp),
|
|
137
|
+
bottleneckLabel: isExhausted ? (bottleneck?.name || '额度') + '已用尽' : null,
|
|
138
|
+
parts,
|
|
139
|
+
empty: parts.length === 0,
|
|
140
|
+
exceeded: limits?.suspended === true || isExhausted,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 查询 mirasim 完整配额;401 时自动用 refreshToken 续期一次并重试(ctx.onRefresh 回写凭据) */
|
|
145
|
+
export async function fetchMirasimQuota(account, ctx = {}) {
|
|
146
|
+
if (!account.token) throw new Error('账号缺少 token');
|
|
147
|
+
|
|
148
|
+
const getProfile = (token) => fetchJsonRace(AUTH_ME_URL, {
|
|
149
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
150
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
151
|
+
});
|
|
152
|
+
const getLimits = (token) => fetchJsonRace(RELAY_LIMITS_URL, {
|
|
153
|
+
headers: { Authorization: `Bearer ${token}`, 'x-mirasim-probe': 'usage', Accept: 'application/json' },
|
|
154
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let profile = null;
|
|
158
|
+
try {
|
|
159
|
+
const pres = await withToken(account, getProfile, ctx);
|
|
160
|
+
if (pres.ok) profile = await pres.json();
|
|
161
|
+
} catch (e) {
|
|
162
|
+
// 刷新也失败 / 网络错误:没有 refreshToken 或 refreshToken 失效时如实抛出,其余继续试 limits
|
|
163
|
+
if (e.authInvalid) throw e;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const res = await withToken(account, getLimits, ctx).catch((e) => {
|
|
167
|
+
if (e.authInvalid) throw e;
|
|
168
|
+
throw new Error(`查询额度失败:${e.message}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
if (res.status === 401) { const e = new Error('mirasim 登录凭据已过期,请在 mirasim 客户端重新登录后重新导入'); e.auth = true; throw e; }
|
|
173
|
+
if (res.status === 429) {
|
|
174
|
+
// 区域受限时返回 profile 里的基础套餐信息,不完全阻断
|
|
175
|
+
return {
|
|
176
|
+
total: 100, used: 0, remaining: 100, unit: '%',
|
|
177
|
+
plan: profile?.plan || 'Plus',
|
|
178
|
+
planExpiresAt: profile?.plan_exp ? new Date(profile.plan_exp * 1000).toISOString() : null,
|
|
179
|
+
parts: [],
|
|
180
|
+
empty: false,
|
|
181
|
+
warning: '云端限流或直连网络受限(配置代理后可查询实时 5h/7d 窗口)',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`查询额度失败: HTTP ${res.status}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const limits = await res.json();
|
|
188
|
+
return normalizeMirasimLimits(limits, profile);
|
|
189
|
+
}
|