creditdaddy 0.8.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Qoder 设备身份组件 —— 给没有 Qoder 客户端的 Linux(fnOS / NAS)提供国际版签到所需的风控身份。
3
+ *
4
+ * Qoder 国际版只向带 Cosy-MachineToken / Code / Type 的请求下发「每天领 100 Credits」,
5
+ * 这三个值由 Qoder 的 UMID 程序(runtime-info)生成。桌面端客户端自带它;官方 CLI
6
+ * @qoder-ai/qodercli 则把各平台的 UMID 二进制以 base64 内嵌在 bundle/qodercli.js 里,Linux x64 / arm64 都有。
7
+ *
8
+ * 这里在用户确认后从 npm 仓库下载官方 qodercli 包(校验 npm 的 sha512 integrity),
9
+ * 按本机 CPU 架构取出对应的 ELF,试跑一次确认能产出身份,再存到 ~/.creditdaddy/qoder-umid/。
10
+ * 不随 CreditDaddy 分发该二进制;调用方式与 qodercli 一致:runtime-info <env>,第一行输出 JSON。
11
+ * env:国际版 4(SINGAPORE,qodercli 的全球区取值),国内版 0(ONLINE)。
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+ import zlib from 'node:zlib';
18
+ import { execFile } from 'node:child_process';
19
+ import { dataDir } from './store.js';
20
+ import { logger } from './logger.js';
21
+
22
+ const PKG = '@qoder-ai/qodercli';
23
+ const REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org'];
24
+ const ELF_MACHINE = { x64: 62, arm64: 183 };
25
+ const BUNDLE_ENTRY = 'package/bundle/qodercli.js';
26
+ const FETCH_TIMEOUT_MS = 180_000;
27
+ export const CLI_RISK_ENV = { qoder: 4, 'qoder-cn': 0 };
28
+
29
+ export function umidSupported(platform = process.platform, arch = process.arch) {
30
+ return platform === 'linux' && Boolean(ELF_MACHINE[arch]);
31
+ }
32
+
33
+ const dir = () => path.join(dataDir(), 'qoder-umid');
34
+ const binPath = () => path.join(dir(), 'runtime-info');
35
+ const manifestPath = () => path.join(dir(), 'manifest.json');
36
+
37
+ /** 已安装的组件:{ path, version, arch, sha256, installedAt, registry };未安装返回 null */
38
+ export function installedUmid() {
39
+ if (!umidSupported()) return null;
40
+ try {
41
+ const m = JSON.parse(fs.readFileSync(manifestPath(), 'utf8'));
42
+ if (!fs.existsSync(binPath()) || m.arch !== process.arch) return null;
43
+ return { ...m, path: binPath() };
44
+ } catch { return null; }
45
+ }
46
+
47
+ // ── 纯函数(便于单测) ──
48
+
49
+ /** 最小 tar 解析:按名字取一个普通文件的内容 */
50
+ export function tarEntry(tar, wanted) {
51
+ let off = 0;
52
+ const cstr = (b, s, n) => { const x = b.subarray(s, s + n); const z = x.indexOf(0); return x.subarray(0, z < 0 ? n : z).toString('utf8'); };
53
+ let longName = null;
54
+ while (off + 512 <= tar.length) {
55
+ const h = tar.subarray(off, off + 512);
56
+ if (h.every((b) => b === 0)) break;
57
+ const size = parseInt(cstr(h, 124, 12).trim() || '0', 8);
58
+ const type = String.fromCharCode(h[156] || 48);
59
+ const prefix = cstr(h, 345, 155);
60
+ let name = longName || (prefix ? prefix + '/' : '') + cstr(h, 0, 100);
61
+ longName = null;
62
+ const body = tar.subarray(off + 512, off + 512 + size);
63
+ off += 512 + Math.ceil(size / 512) * 512;
64
+ if (type === 'L') { longName = body.toString('utf8').replace(/\0+$/, ''); continue; }
65
+ if (type === 'x') { // pax 扩展头里的 path 覆盖下一个条目的名字
66
+ const m = /\d+ path=([^\n]+)\n/.exec(body.toString('utf8'));
67
+ if (m) longName = m[1];
68
+ continue;
69
+ }
70
+ if ((type === '0' || type === '\0') && name === wanted) return body;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** 在 qodercli bundle 文本里找内嵌的 ELF(base64 字面量),按 e_machine 挑出本机架构 */
76
+ export function extractElf(jsText, machine) {
77
+ const MARK = '"f0VMRgIB'; // "\x7fELF" + 64 位 + 小端 的 base64 开头
78
+ let i = jsText.indexOf(MARK);
79
+ while (i >= 0) {
80
+ const start = i + 1;
81
+ const end = jsText.indexOf('"', start);
82
+ if (end < 0) break;
83
+ const head = Buffer.from(jsText.slice(start, start + 28), 'base64');
84
+ if (head.length >= 20 && head.readUInt16LE(18) === machine) {
85
+ return Buffer.from(jsText.slice(start, end), 'base64');
86
+ }
87
+ i = jsText.indexOf(MARK, end);
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** npm integrity(sha512-<base64>)校验 */
93
+ export function checkIntegrity(buf, integrity) {
94
+ const m = /^sha512-(.+)$/.exec(String(integrity || '').split(/\s+/)[0]);
95
+ if (!m) return false;
96
+ return crypto.createHash('sha512').update(buf).digest('base64') === m[1];
97
+ }
98
+
99
+ // ── 安装 ──
100
+
101
+ async function fetchBuf(url) {
102
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
103
+ if (!res.ok) throw new Error(`HTTP ${res.status}:${url}`);
104
+ return Buffer.from(await res.arrayBuffer());
105
+ }
106
+
107
+ function testRun(file) {
108
+ return new Promise((resolve, reject) => {
109
+ execFile(file, [String(CLI_RISK_ENV.qoder)], { timeout: 30_000, maxBuffer: 1 << 20 }, (err, stdout) => {
110
+ try {
111
+ const j = JSON.parse(String(stdout).split('\n')[0]);
112
+ if (typeof j.machineToken === 'string' && j.machineToken && j.machineCode && j.machineType) return resolve(j);
113
+ } catch {}
114
+ reject(new Error('组件试运行失败' + (err ? ':' + err.message : '(输出里没有设备身份)')));
115
+ });
116
+ });
117
+ }
118
+
119
+ let installing = null;
120
+
121
+ /** 下载官方 qodercli 包并提取本机架构的 UMID;并发调用共用同一次安装 */
122
+ export function installUmid() {
123
+ if (!installing) installing = doInstall().finally(() => { installing = null; });
124
+ return installing;
125
+ }
126
+ export const umidInstalling = () => Boolean(installing);
127
+
128
+ async function doInstall() {
129
+ if (!umidSupported()) throw new Error(`当前平台(${process.platform}/${process.arch})不需要或不支持该组件`);
130
+ const errors = [];
131
+ for (const registry of REGISTRIES) {
132
+ try {
133
+ const meta = JSON.parse((await fetchBuf(`${registry}/${PKG.replace('/', '%2f')}/latest`)).toString('utf8'));
134
+ const { tarball, integrity } = meta.dist || {};
135
+ if (!tarball || !integrity) throw new Error('包元数据缺少 tarball / integrity');
136
+ logger.info('UMID', `下载 ${PKG}@${meta.version}(${registry})`);
137
+ const tgz = await fetchBuf(tarball);
138
+ if (!checkIntegrity(tgz, integrity)) throw new Error('下载内容与 npm integrity 不一致');
139
+ const bundle = tarEntry(zlib.gunzipSync(tgz), BUNDLE_ENTRY);
140
+ if (!bundle) throw new Error(`包里没有 ${BUNDLE_ENTRY}(qodercli 结构可能变化)`);
141
+ const elf = extractElf(bundle.toString('latin1'), ELF_MACHINE[process.arch]);
142
+ if (!elf) throw new Error(`qodercli ${meta.version} 里没有 ${process.arch} 的 UMID 程序(结构可能变化)`);
143
+
144
+ fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
145
+ const tmp = binPath() + '.' + process.pid + '.tmp';
146
+ fs.writeFileSync(tmp, elf, { mode: 0o755 });
147
+ try { await testRun(tmp); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
148
+ fs.renameSync(tmp, binPath());
149
+ const manifest = {
150
+ version: meta.version, arch: process.arch, registry,
151
+ sha256: crypto.createHash('sha256').update(elf).digest('hex'),
152
+ installedAt: new Date().toISOString(),
153
+ };
154
+ fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
155
+ logger.info('UMID', `设备身份组件已安装:qodercli ${meta.version} / ${process.arch}`);
156
+ return { ...manifest, path: binPath() };
157
+ } catch (e) {
158
+ errors.push(`${registry}:${e.message}`);
159
+ logger.warn('UMID', `从 ${registry} 安装失败:${e.message}`);
160
+ }
161
+ }
162
+ throw new Error('设备身份组件安装失败 —— ' + errors.join(';'));
163
+ }
164
+
165
+ export function removeUmid() {
166
+ fs.rmSync(dir(), { recursive: true, force: true });
167
+ }
168
+
169
+ /** 面板 / 状态接口用的摘要 */
170
+ export function umidInfo() {
171
+ const m = installedUmid();
172
+ return {
173
+ supported: umidSupported(),
174
+ installing: umidInstalling(),
175
+ installed: m ? { version: m.version, arch: m.arch, installedAt: m.installedAt } : null,
176
+ };
177
+ }
package/src/store.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * 本地账号存储 — 数据全部留在本机(参考 WorkDaddy 的数据边界原则)。
3
+ *
4
+ * 目录:$CREDITDADDY_HOME(兼容旧的 $QODERDADDY_HOME;默认 ~/.creditdaddy)
5
+ * accounts.json 账号列表(含 token,0600 权限)
6
+ * state.json 运行状态(签到完成日历等)
7
+ * 项目曾名 QoderDaddy:默认目录不存在而 ~/.qoderdaddy 存在时,首次访问自动复制过来(旧目录保留)。
8
+ *
9
+ * 写入采用 原子写(临时文件 + rename),避免进程中断损坏数据。
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import fsSync from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import crypto from 'node:crypto';
17
+ import { PROVIDERS, productOf } from './constants.js';
18
+
19
+ let migrated = false;
20
+
21
+ /** 从旧版 ~/.qoderdaddy 复制数据到新目录(只在新目录尚不存在时执行一次) */
22
+ function migrateLegacyDir(target) {
23
+ migrated = true;
24
+ const legacy = path.join(os.homedir(), '.qoderdaddy');
25
+ try {
26
+ if (fsSync.existsSync(target) || !fsSync.existsSync(legacy)) return;
27
+ fsSync.cpSync(legacy, target, { recursive: true, errorOnExist: false });
28
+ fsSync.writeFileSync(path.join(target, 'MIGRATED_FROM_QODERDADDY'), new Date().toISOString());
29
+ } catch {}
30
+ }
31
+
32
+ export function dataDir() {
33
+ const explicit = process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME;
34
+ if (explicit) return explicit;
35
+ const target = path.join(os.homedir(), '.creditdaddy');
36
+ if (!migrated) migrateLegacyDir(target);
37
+ return target;
38
+ }
39
+
40
+ const ACCOUNTS_FILE = () => path.join(dataDir(), 'accounts.json');
41
+ const STATE_FILE = () => path.join(dataDir(), 'state.json');
42
+
43
+ async function ensureDir() {
44
+ await fs.mkdir(dataDir(), { recursive: true, mode: 0o700 });
45
+ }
46
+
47
+ /** 原子写:临时文件 + rename,失败不留半截文件 */
48
+ export async function atomicWrite(file, content) {
49
+ const tmp = path.join(
50
+ path.dirname(file),
51
+ `.tmp-${path.basename(file)}-${crypto.randomBytes(4).toString('hex')}`
52
+ );
53
+ await fs.writeFile(tmp, content, { mode: 0o600 });
54
+ await fs.rename(tmp, file);
55
+ }
56
+
57
+ async function readJson(file, fallback) {
58
+ try {
59
+ return JSON.parse(await fs.readFile(file, 'utf8'));
60
+ } catch (e) {
61
+ if (e.code === 'ENOENT') return fallback;
62
+ throw new Error(`读取 ${file} 失败: ${e.message}`);
63
+ }
64
+ }
65
+
66
+ // ─── 账号 ───
67
+
68
+ export async function loadAccounts() {
69
+ const list = await readJson(ACCOUNTS_FILE(), []);
70
+ return Array.isArray(list) ? list : [];
71
+ }
72
+
73
+ export async function saveAccounts(accounts) {
74
+ await ensureDir();
75
+ await atomicWrite(ACCOUNTS_FILE(), JSON.stringify(accounts, null, 2));
76
+ }
77
+
78
+ // 进程内串行队列:所有「读-改-写」都经由 withAccounts,避免并发请求互相覆盖
79
+ let accountsQueue = Promise.resolve();
80
+
81
+ /**
82
+ * 串行地读取 → 修改 → 保存账号列表。fn 可直接修改传入的数组(或返回新数组),
83
+ * 返回 fn 的结果。fn 内不要做网络请求,以免长时间占住队列。
84
+ */
85
+ export function withAccounts(fn) {
86
+ const run = accountsQueue.then(async () => {
87
+ const accounts = await loadAccounts();
88
+ const result = await fn(accounts);
89
+ await saveAccounts(accounts);
90
+ return result;
91
+ });
92
+ accountsQueue = run.catch(() => {});
93
+ return run;
94
+ }
95
+
96
+ export function newId() {
97
+ return crypto.randomUUID();
98
+ }
99
+
100
+ const optStr = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
101
+
102
+ /** 过期时间统一成 ISO 字符串(接受 ISO / 秒 / 毫秒) */
103
+ export function normalizeExpiry(v) {
104
+ if (v === null || v === undefined || v === '') return null;
105
+ const n = typeof v === 'number' ? v : /^\d+$/.test(String(v)) ? Number(v) : NaN;
106
+ const d = Number.isFinite(n) ? new Date(n < 1e12 ? n * 1000 : n) : new Date(String(v));
107
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
108
+ }
109
+
110
+ /**
111
+ * 校验并构造账号记录(不含重复检查,由调用方负责)。
112
+ * 可选元数据:uid / email / refreshToken / expiresAt / source / meta(产品相关的非通用字段,如 WorkBuddy 会话信息)
113
+ */
114
+ export function normalizeAccountInput({ name, provider, token, uid, email, refreshToken, expiresAt, source, meta }) {
115
+ if (!PROVIDERS.includes(provider)) {
116
+ throw new Error(`provider 必须是 ${PROVIDERS.join(' 或 ')}`);
117
+ }
118
+ const trimmed = String(token || '').trim();
119
+ if (!trimmed) throw new Error('token 不能为空');
120
+ return {
121
+ id: newId(),
122
+ name: String(name || '').trim() || null,
123
+ provider,
124
+ token: trimmed,
125
+ uid: optStr(uid),
126
+ email: optStr(email),
127
+ refreshToken: optStr(refreshToken),
128
+ expiresAt: normalizeExpiry(expiresAt),
129
+ source: optStr(source) || 'manual',
130
+ meta: meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : {},
131
+ createdAt: new Date().toISOString(),
132
+ lastCheckin: null,
133
+ lastResult: null,
134
+ };
135
+ }
136
+
137
+ /** 相同 provider 下 token 相同、或 uid 相同(同一用户换了新 token)视为同一账号 */
138
+ export function findDuplicate(accounts, provider, token, uid = null) {
139
+ const t = String(token || '').trim();
140
+ return accounts.find((a) => a.provider === provider && (a.token === t || (uid && a.uid && a.uid === uid)));
141
+ }
142
+
143
+ export function maskToken(token) {
144
+ if (typeof token !== 'string' || token.length < 12) return '***';
145
+ return `${token.slice(0, 6)}...${token.slice(-4)}`;
146
+ }
147
+
148
+ const maskPhone = (p) => (typeof p === 'string' && p.length >= 7 ? p.slice(0, 3) + '****' + p.slice(-4) : null);
149
+
150
+ /** 对外输出的脱敏账号视图 */
151
+ export function publicAccount(a) {
152
+ const meta = a.meta || {};
153
+ return {
154
+ id: a.id,
155
+ name: a.name,
156
+ provider: a.provider,
157
+ product: productOf(a.provider),
158
+ tokenMasked: maskToken(a.token),
159
+ isPat: typeof a.token === 'string' && a.token.startsWith('pt-'),
160
+ uid: a.uid || null,
161
+ email: a.email || null,
162
+ expiresAt: a.expiresAt || null,
163
+ hasRefreshToken: Boolean(a.refreshToken),
164
+ source: a.source || 'manual',
165
+ phone: maskPhone(meta.phone),
166
+ domain: meta.domain || null,
167
+ canSwitch: Boolean(meta.session?.account || meta.credentials),
168
+ verified: a.verified ?? null,
169
+ createdAt: a.createdAt,
170
+ lastCheckin: a.lastCheckin,
171
+ lastResult: a.lastResult || null,
172
+ };
173
+ }
174
+
175
+ // ─── 运行状态(签到日历) ───
176
+
177
+ export async function loadState() {
178
+ const s = await readJson(STATE_FILE(), {});
179
+ return s && typeof s === 'object' ? s : {};
180
+ }
181
+
182
+ export async function saveState(state) {
183
+ await ensureDir();
184
+ await atomicWrite(STATE_FILE(), JSON.stringify(state, null, 2));
185
+ }
186
+
187
+ // 导入 / 导出(含 10router 互通)见 transfer.js
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 10Router 集成:用 10Router 仪表盘的虚拟 key(sk-…)
3
+ * 1. 读取 10Router 其他供应商的额度总览(GET /api/usage/quotas,10Router 1.2.1+)
4
+ * 2. 把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量同步进 10Router 的用量统计
5
+ * (POST /api/settings/database/import-usage,与 10router-sync 插件同一接口,10Router 1.0.7+)
6
+ *
7
+ * 配置存 ~/.creditdaddy/tenrouter.json(0600,含 key,不随账号导出);
8
+ * key 只由守护进程使用,面板拿到的永远是脱敏值。
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { dataDir } from './store.js';
15
+ import { logger } from './logger.js';
16
+ import { SOURCES, SOURCE_LABEL, collectSource, selectSince, detectSources } from './usageSync.js';
17
+
18
+ const FILE = () => path.join(dataDir(), 'tenrouter.json');
19
+ const QUOTA_TIMEOUT_MS = 90_000; // 10Router 端并发查询全部供应商,冷缓存时可能较慢
20
+ const IMPORT_TIMEOUT_MS = 120_000;
21
+ const BATCH = 5000;
22
+ const SYNC_INTERVAL_MS = 60 * 60 * 1000;
23
+ const DEFAULT_CONFIG = { endpoint: '', key: '', sync: { enabled: false, sources: [...SOURCES] }, syncState: {}, lastSync: null };
24
+
25
+ export function loadConfig() {
26
+ try {
27
+ const c = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
28
+ return { ...DEFAULT_CONFIG, ...c, sync: { ...DEFAULT_CONFIG.sync, ...(c.sync || {}) }, syncState: c.syncState || {} };
29
+ } catch { return structuredClone(DEFAULT_CONFIG); }
30
+ }
31
+
32
+ function saveConfig(c) {
33
+ fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
34
+ const tmp = `${FILE()}.${process.pid}.${crypto.randomUUID()}.tmp`;
35
+ fs.writeFileSync(tmp, JSON.stringify(c, null, 2), { mode: 0o600 });
36
+ fs.renameSync(tmp, FILE());
37
+ }
38
+
39
+ // 读改写串行,避免面板保存与后台同步互相覆盖
40
+ let chain = Promise.resolve();
41
+ function withConfig(fn) {
42
+ const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); saveConfig(c); return r; });
43
+ chain = run.catch(() => {});
44
+ return run;
45
+ }
46
+
47
+ export function normalizeEndpoint(raw) {
48
+ let s = String(raw || '').trim();
49
+ if (!s) return '';
50
+ if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
51
+ const u = new URL(s); // 非法地址直接抛错
52
+ return (u.origin + u.pathname).replace(/\/+$/, '');
53
+ }
54
+
55
+ const maskKey = (k) => (k ? (k.length > 12 ? k.slice(0, 5) + '…' + k.slice(-4) : '…') : '');
56
+ export const isConfigured = (c = loadConfig()) => Boolean(c.endpoint && c.key);
57
+
58
+ /** 面板用的配置视图(key 脱敏) */
59
+ export async function publicConfig() {
60
+ const c = loadConfig();
61
+ return {
62
+ configured: isConfigured(c),
63
+ endpoint: c.endpoint,
64
+ keyMasked: maskKey(c.key),
65
+ sync: c.sync,
66
+ lastSync: c.lastSync,
67
+ syncing: Boolean(syncing),
68
+ sources: await detectSources(),
69
+ };
70
+ }
71
+
72
+ /** 更新配置:key 留空表示保持原值;endpoint 为空 = 清除整个配置 */
73
+ export function updateConfig({ endpoint, key, syncEnabled, sources } = {}) {
74
+ return withConfig((c) => {
75
+ if (endpoint !== undefined) {
76
+ c.endpoint = normalizeEndpoint(endpoint);
77
+ if (!c.endpoint) { Object.assign(c, structuredClone(DEFAULT_CONFIG)); return; }
78
+ }
79
+ if (typeof key === 'string' && key.trim()) {
80
+ const k = key.trim();
81
+ if (/\s/.test(k)) throw new Error('key 不能包含空白字符');
82
+ c.key = k;
83
+ }
84
+ if (typeof syncEnabled === 'boolean') c.sync.enabled = syncEnabled;
85
+ if (Array.isArray(sources)) c.sync.sources = sources.filter((s) => SOURCES.includes(s));
86
+ });
87
+ }
88
+
89
+ async function call(c, pathname, { method = 'GET', body, timeout = QUOTA_TIMEOUT_MS } = {}) {
90
+ if (!isConfigured(c)) throw Object.assign(new Error('尚未配置 10Router 地址与 key'), { code: 'NOT_CONFIGURED' });
91
+ let res;
92
+ try {
93
+ res = await fetch(c.endpoint + pathname, {
94
+ method,
95
+ headers: { Authorization: `Bearer ${c.key}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
96
+ body: body ? JSON.stringify(body) : undefined,
97
+ signal: AbortSignal.timeout(timeout),
98
+ });
99
+ } catch (e) {
100
+ throw new Error(`无法连接 10Router(${c.endpoint}):${e.cause?.code || e.message}`);
101
+ }
102
+ const text = await res.text();
103
+ let data = null;
104
+ try { data = text ? JSON.parse(text) : null; } catch {}
105
+ if (res.status === 401 || res.status === 403) throw Object.assign(new Error('10Router 拒绝了这个 key(请确认是仪表盘里的虚拟 key sk-…,且未被停用)'), { code: 'BAD_KEY' });
106
+ if (res.status === 404 && pathname.startsWith('/api/usage/quotas')) {
107
+ throw Object.assign(new Error('这个 10Router 版本还没有额度总览接口,需要 10Router 1.2.1 及以上'), { code: 'TOO_OLD' });
108
+ }
109
+ if (!res.ok) throw new Error(`10Router 返回 HTTP ${res.status}${data?.error ? ':' + data.error : ''}`);
110
+ if (data === null) throw new Error('10Router 返回的不是 JSON(地址是否指向了 10Router?)');
111
+ return data;
112
+ }
113
+
114
+ /** 其他供应商的额度总览(10Router 端已按仪表盘同一口径归一) */
115
+ export async function fetchQuotas({ force = false } = {}) {
116
+ const data = await call(loadConfig(), '/api/usage/quotas' + (force ? '?force=1' : ''));
117
+ return {
118
+ generatedAt: data.generatedAt || new Date().toISOString(),
119
+ connections: Array.isArray(data.connections) ? data.connections : [],
120
+ };
121
+ }
122
+
123
+ /** 10Router 自身健康(/api/health,无需 key;未配置或不可达时返回 ok:false) */
124
+ export async function fetchHealth(override) {
125
+ const c = { ...loadConfig(), ...(override || {}) };
126
+ if (!c.endpoint) return { ok: false, error: '未配置 10Router 地址' };
127
+ try {
128
+ const res = await fetch(c.endpoint + '/api/health', {
129
+ headers: { Accept: 'application/json', ...(c.key ? { Authorization: `Bearer ${c.key}` } : {}) },
130
+ signal: AbortSignal.timeout(8000),
131
+ });
132
+ const data = await res.json().catch(() => null);
133
+ if (!res.ok || !data) return { ok: false, error: `HTTP ${res.status}` };
134
+ return {
135
+ ok: data.ok === true,
136
+ driver: data.driver || null,
137
+ lastDriverError: typeof data.lastDriverError === 'string' ? data.lastDriverError : null,
138
+ };
139
+ } catch (e) {
140
+ return { ok: false, error: e.cause?.code || e.message };
141
+ }
142
+ }
143
+
144
+ /** 测试连接:用额度接口同时验证地址与 key;老版本 10Router 只能同步用量时也视为可用 */
145
+ export async function testConnection(override) {
146
+ const c = { ...loadConfig(), ...(override || {}) };
147
+ try {
148
+ const q = await call(c, '/api/usage/quotas');
149
+ return { ok: true, quotas: true, connections: (q.connections || []).length };
150
+ } catch (e) {
151
+ if (e.code === 'TOO_OLD') return { ok: true, quotas: false, warning: e.message };
152
+ return { ok: false, error: e.message, code: e.code };
153
+ }
154
+ }
155
+
156
+ // ── 用量同步 ──
157
+
158
+ let syncing = null;
159
+ let timer = null;
160
+
161
+ /** 同步一轮;并发调用共用同一轮。dryRun 只统计不上传。 */
162
+ export function runUsageSync({ dryRun = false, trigger = 'manual' } = {}) {
163
+ if (!syncing) syncing = doSync({ dryRun, trigger }).finally(() => { syncing = null; });
164
+ return syncing;
165
+ }
166
+
167
+ async function doSync({ dryRun, trigger }) {
168
+ const c = loadConfig();
169
+ if (!isConfigured(c)) throw Object.assign(new Error('尚未配置 10Router 地址与 key'), { code: 'NOT_CONFIGURED' });
170
+ const results = [];
171
+ for (const id of c.sync.sources) {
172
+ const label = SOURCE_LABEL[id];
173
+ try {
174
+ const { entries, notes, files } = await collectSource(id, { endpoint: c.endpoint });
175
+ if (!files) { results.push({ source: id, label, status: 'absent' }); continue; }
176
+ const { selected, maxTs } = selectSince(entries, c.syncState[id]?.lastTs);
177
+ if (dryRun || !selected.length) {
178
+ results.push({ source: id, label, status: dryRun ? 'dry-run' : 'up-to-date', total: entries.length, selected: selected.length, notes });
179
+ continue;
180
+ }
181
+ let imported = 0, skipped = 0;
182
+ for (let i = 0; i < selected.length; i += BATCH) {
183
+ const r = await call(c, '/api/settings/database/import-usage', {
184
+ method: 'POST', body: { usageHistory: selected.slice(i, i + BATCH) }, timeout: IMPORT_TIMEOUT_MS,
185
+ });
186
+ imported += r.imported || 0;
187
+ skipped += r.skipped || 0;
188
+ }
189
+ await withConfig((cc) => { cc.syncState[id] = { lastTs: maxTs, at: new Date().toISOString() }; });
190
+ results.push({ source: id, label, status: 'ok', total: entries.length, selected: selected.length, imported, skipped, notes });
191
+ } catch (e) {
192
+ results.push({ source: id, label, status: 'failed', error: e.message });
193
+ if (e.code === 'BAD_KEY') break; // key 不对,后面的来源也不用试了
194
+ }
195
+ }
196
+ const imported = results.reduce((n, r) => n + (r.imported || 0), 0);
197
+ const failed = results.filter((r) => r.status === 'failed');
198
+ const summary = dryRun
199
+ ? '预览:' + results.map((r) => `${r.label} ${r.status === 'absent' ? '未检测到' : (r.selected ?? 0) + ' 行'}`).join(',')
200
+ : failed.length
201
+ ? `同步完成:新增 ${imported} 行,${failed.length} 个来源失败(${failed.map((r) => r.label + ':' + r.error).join(';')})`
202
+ : `同步完成:新增 ${imported} 行`;
203
+ if (!dryRun) {
204
+ await withConfig((cc) => { cc.lastSync = { at: new Date().toISOString(), trigger, summary, results }; });
205
+ (failed.length ? logger.warn : logger.info).call(logger, '10R', summary);
206
+ }
207
+ return { summary, results };
208
+ }
209
+
210
+ /** 后台每小时同步一次(仅在配置了且开启自动同步时) */
211
+ export function startUsageSyncScheduler() {
212
+ if (timer) return;
213
+ const tick = () => {
214
+ const c = loadConfig();
215
+ if (!isConfigured(c) || !c.sync.enabled) return;
216
+ runUsageSync({ trigger: 'auto' }).catch((e) => logger.warn('10R', '自动同步失败:' + e.message));
217
+ };
218
+ setTimeout(tick, 60_000).unref?.(); // 启动 1 分钟后先跑一轮
219
+ timer = setInterval(tick, SYNC_INTERVAL_MS);
220
+ timer.unref?.();
221
+ }