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/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, takeCandidate } from './localDetect.js';
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 头(或 ?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
- * - 监听回环地址时,Host 必须是 127.0.0.1 / localhost / ::1(挡住 DNS 重绑定读取 token)
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
- if (LOOPBACK_HOSTS.has(bindHost) && !LOOPBACK_HOSTS.has(hostnameOf(host))) {
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
- const key = req.headers['x-qd-key'] || url.searchParams.get('key') || '';
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 头或 ?key= 参数)', code: 'PANEL_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 (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode 账号支持切换' });
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) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
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, takeCandidate(c.id)?.token) });
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 = takeCandidate(String(body?.candidateId || ''));
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
  }
@@ -138,8 +138,8 @@ export function putCandidate(c) {
138
138
  return id;
139
139
  }
140
140
 
141
- /** 按 candidateId 取回完整候选(导入用),过期返回 null */
142
- export function takeCandidate(id) {
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
- return new Date().toISOString().replace('T', ' ').slice(0, 19);
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
+ }