creditdaddy 0.9.5 → 1.2.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/tenrouter.js CHANGED
@@ -1,221 +1,263 @@
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
- }
1
+ /**
2
+ * 10Router 集成:用 10Router 仪表盘的虚拟 key(sk-…)
3
+ * 1. 读取 10Router 其他供应商的额度总览(GET /api/usage/quotas,10Router 1.2.1+)
4
+ * 2. 把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量与妙手(CatPaw)云端用量同步进 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: '', adminPassword: '', sync: { enabled: false, sources: [...SOURCES], sourcesVersion: 2 }, syncState: {}, lastSync: null };
24
+ // v1.0.0 及更早保存的来源全集;版本迁移只补「此后新增」的来源,不复活用户手动关掉的
25
+ const LEGACY_SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo', 'catpaw'];
26
+ const SOURCES_VERSION = 3; // 3: 新增 10Router 本机实例来源(10r)
27
+
28
+ export function loadConfig() {
29
+ try {
30
+ const c = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
31
+ const sync = { ...DEFAULT_CONFIG.sync, ...(c.sync || {}) };
32
+ // 旧配置里的 sources 是保存时的快照,会盖掉新增来源(妙手因此被静默关掉);
33
+ // 只把此后新增的来源默认并入,用户手动关掉的保持关闭。
34
+ const savedVersion = Number(c.sync?.sourcesVersion) || 1;
35
+ if (savedVersion < SOURCES_VERSION) {
36
+ sync.sources = (Array.isArray(sync.sources) ? [...sync.sources] : [...SOURCES]);
37
+ for (const s of SOURCES) if (!LEGACY_SOURCES.includes(s) && !sync.sources.includes(s)) sync.sources.push(s);
38
+ sync.sourcesVersion = SOURCES_VERSION;
39
+ }
40
+ return { ...DEFAULT_CONFIG, ...c, sync, syncState: c.syncState || {} };
41
+ } catch { return structuredClone(DEFAULT_CONFIG); }
42
+ }
43
+
44
+ function saveConfig(c) {
45
+ fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
46
+ const tmp = `${FILE()}.${process.pid}.${crypto.randomUUID()}.tmp`;
47
+ fs.writeFileSync(tmp, JSON.stringify(c, null, 2), { mode: 0o600 });
48
+ fs.renameSync(tmp, FILE());
49
+ }
50
+
51
+ // 读改写串行,避免面板保存与后台同步互相覆盖
52
+ let chain = Promise.resolve();
53
+ function withConfig(fn) {
54
+ const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); saveConfig(c); return r; });
55
+ chain = run.catch(() => {});
56
+ return run;
57
+ }
58
+
59
+ export function normalizeEndpoint(raw) {
60
+ let s = String(raw || '').trim();
61
+ if (!s) return '';
62
+ if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
63
+ const u = new URL(s); // 非法地址直接抛错
64
+ // LLM 客户端风格的 base URL 带 /v1 后缀——10Router 只认站点根地址,/v1 会让 /api/* 调用落空并误报版本过旧
65
+ return (u.origin + u.pathname).replace(/\/+$/, '').replace(/\/v1$/, '');
66
+ }
67
+
68
+ const maskKey = (k) => (k ? (k.length > 12 ? k.slice(0, 5) + '…' + k.slice(-4) : '…') : '');
69
+ export const isConfigured = (c = loadConfig()) => Boolean(c.endpoint && c.key);
70
+
71
+ /** ZCode 网关白名单预填建议:10Router 服务地址的主机部分(回环地址无加白意义,返回空)。 */
72
+ export function gatewayHostSuggestion() {
73
+ try {
74
+ const h = new URL(loadConfig().endpoint || '').hostname;
75
+ return h && !/^(127\.|localhost$|::1$)/i.test(h) ? h : '';
76
+ } catch { return ''; }
77
+ }
78
+
79
+ /** 面板用的配置视图(key 脱敏) */
80
+ export async function publicConfig() {
81
+ const c = loadConfig();
82
+ return {
83
+ configured: isConfigured(c),
84
+ endpoint: c.endpoint,
85
+ keyMasked: maskKey(c.key),
86
+ hasAdminPassword: Boolean(c.adminPassword),
87
+ sync: c.sync,
88
+ lastSync: c.lastSync,
89
+ syncing: Boolean(syncing),
90
+ sources: await detectSources(),
91
+ };
92
+ }
93
+
94
+ /** 更新配置:key 留空表示保持原值;endpoint 为空 = 清除整个配置 */
95
+ export function updateConfig({ endpoint, key, adminPassword, syncEnabled, sources } = {}) {
96
+ return withConfig((c) => {
97
+ if (endpoint !== undefined) {
98
+ c.endpoint = normalizeEndpoint(endpoint);
99
+ if (!c.endpoint) { Object.assign(c, structuredClone(DEFAULT_CONFIG)); return; }
100
+ }
101
+ if (typeof key === 'string' && key.trim()) {
102
+ const k = key.trim();
103
+ if (/\s/.test(k)) throw new Error('key 不能包含空白字符');
104
+ c.key = k;
105
+ }
106
+ if (typeof adminPassword === 'string' && adminPassword) c.adminPassword = adminPassword;
107
+ if (typeof syncEnabled === 'boolean') c.sync.enabled = syncEnabled;
108
+ if (Array.isArray(sources)) {
109
+ c.sync.sources = sources.filter((s) => SOURCES.includes(s));
110
+ c.sync.sourcesVersion = SOURCES_VERSION; // 显式保存过勾选结果,之后不再自动并入新来源
111
+ }
112
+ });
113
+ }
114
+
115
+ async function call(c, pathname, { method = 'GET', body, timeout = QUOTA_TIMEOUT_MS } = {}) {
116
+ if (!isConfigured(c)) throw Object.assign(new Error('尚未配置 10Router 地址与 key'), { code: 'NOT_CONFIGURED' });
117
+ // 兼容历史配置里已存的 /v1 后缀(新版 normalizeEndpoint 剥离,但旧配置不会自动重写)
118
+ const base = c.endpoint.replace(/\/+$/, '').replace(/\/v1$/, '');
119
+ let res;
120
+ try {
121
+ res = await fetch(base + pathname, {
122
+ method,
123
+ headers: { Authorization: `Bearer ${c.key}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
124
+ body: body ? JSON.stringify(body) : undefined,
125
+ signal: AbortSignal.timeout(timeout),
126
+ });
127
+ } catch (e) {
128
+ throw new Error(`无法连接 10Router(${c.endpoint}):${e.cause?.code || e.message}`);
129
+ }
130
+ const text = await res.text();
131
+ let data = null;
132
+ try { data = text ? JSON.parse(text) : null; } catch {}
133
+ if (res.status === 401 || res.status === 403) throw Object.assign(new Error('10Router 拒绝了这个 key(请确认是仪表盘里的虚拟 key sk-…,且未被停用)'), { code: 'BAD_KEY' });
134
+ if (res.status === 404 && pathname.startsWith('/api/usage/quotas')) {
135
+ throw Object.assign(new Error('这个 10Router 版本还没有额度总览接口,需要 10Router 1.2.1 及以上'), { code: 'TOO_OLD' });
136
+ }
137
+ if (!res.ok) throw new Error(`10Router 返回 HTTP ${res.status}${data?.error ? ':' + data.error : ''}`);
138
+ if (data === null) throw new Error('10Router 返回的不是 JSON(地址是否指向了 10Router?)');
139
+ return data;
140
+ }
141
+
142
+ /** 其他供应商的额度总览(10Router 端已按仪表盘同一口径归一) */
143
+ export async function fetchQuotas({ force = false } = {}) {
144
+ const data = await call(loadConfig(), '/api/usage/quotas' + (force ? '?force=1' : ''));
145
+ return {
146
+ generatedAt: data.generatedAt || new Date().toISOString(),
147
+ connections: Array.isArray(data.connections) ? data.connections : [],
148
+ };
149
+ }
150
+
151
+ /** 10Router 自身健康(/api/health,无需 key;未配置或不可达时返回 ok:false) */
152
+ export async function fetchHealth(override) {
153
+ const ov = { ...(override || {}) };
154
+ if (typeof ov.endpoint === 'string') ov.endpoint = normalizeEndpoint(ov.endpoint);
155
+ const c = { ...loadConfig(), ...ov };
156
+ if (!c.endpoint) return { ok: false, error: '未配置 10Router 地址' };
157
+ try {
158
+ const res = await fetch(c.endpoint.replace(/\/v1$/, '') + '/api/health', {
159
+ headers: { Accept: 'application/json', ...(c.key ? { Authorization: `Bearer ${c.key}` } : {}) },
160
+ signal: AbortSignal.timeout(8000),
161
+ });
162
+ const data = await res.json().catch(() => null);
163
+ if (!res.ok || !data) return { ok: false, error: `HTTP ${res.status}` };
164
+ return {
165
+ ok: data.ok === true,
166
+ driver: data.driver || null,
167
+ lastDriverError: typeof data.lastDriverError === 'string' ? data.lastDriverError : null,
168
+ };
169
+ } catch (e) {
170
+ return { ok: false, error: e.cause?.code || e.message };
171
+ }
172
+ }
173
+
174
+ /** 测试连接:用额度接口同时验证地址与 key;老版本 10Router 只能同步用量时也视为可用 */
175
+ export async function testConnection(override) {
176
+ const ov = { ...(override || {}) };
177
+ if (typeof ov.endpoint === 'string') ov.endpoint = normalizeEndpoint(ov.endpoint);
178
+ const c = { ...loadConfig(), ...ov };
179
+ try {
180
+ const q = await call(c, '/api/usage/quotas');
181
+ return { ok: true, quotas: true, connections: (q.connections || []).length };
182
+ } catch (e) {
183
+ if (e.code === 'TOO_OLD') return { ok: true, quotas: false, warning: e.message };
184
+ return { ok: false, error: e.message, code: e.code };
185
+ }
186
+ }
187
+
188
+ // ── 用量同步 ──
189
+
190
+ let syncing = null;
191
+ let timer = null;
192
+
193
+ /** 同步一轮;并发调用共用同一轮。dryRun 只统计不上传。 */
194
+ export function runUsageSync({ dryRun = false, trigger = 'manual' } = {}) {
195
+ if (!syncing) syncing = doSync({ dryRun, trigger }).finally(() => { syncing = null; });
196
+ return syncing;
197
+ }
198
+
199
+ async function doSync({ dryRun, trigger }) {
200
+ const c = loadConfig();
201
+ if (!isConfigured(c)) throw Object.assign(new Error('尚未配置 10Router 地址与 key'), { code: 'NOT_CONFIGURED' });
202
+ const results = [];
203
+ for (const id of c.sync.sources) {
204
+ const label = SOURCE_LABEL[id];
205
+ try {
206
+ const { entries, notes, files, nextSentTotals } = await collectSource(id, {
207
+ endpoint: c.endpoint, catpawModel: c.sync.catpawModel, sentTotals: c.syncState[id]?.sentTotals,
208
+ });
209
+ if (!files) { results.push({ source: id, label, status: 'absent' }); continue; }
210
+ // catpaw 的行是「某天的差量」,时间戳落在历史日期,不能按水位线筛(会漏掉老日期的新差量);
211
+ // 它的增量由 sentTotals 记账,entries 本身就是该发的差量。
212
+ const { selected, maxTs } = id === 'catpaw'
213
+ ? { selected: entries, maxTs: entries.length ? entries[entries.length - 1].timestamp : (c.syncState[id]?.lastTs ?? null) }
214
+ : selectSince(entries, c.syncState[id]?.lastTs);
215
+ if (dryRun || !selected.length) {
216
+ results.push({ source: id, label, status: dryRun ? 'dry-run' : 'up-to-date', total: entries.length, selected: selected.length, notes });
217
+ continue;
218
+ }
219
+ let imported = 0, skipped = 0;
220
+ for (let i = 0; i < selected.length; i += BATCH) {
221
+ const r = await call(c, '/api/settings/database/import-usage', {
222
+ method: 'POST', body: { usageHistory: selected.slice(i, i + BATCH) }, timeout: IMPORT_TIMEOUT_MS,
223
+ });
224
+ imported += r.imported || 0;
225
+ skipped += r.skipped || 0;
226
+ }
227
+ await withConfig((cc) => {
228
+ cc.syncState[id] = { lastTs: maxTs, at: new Date().toISOString() };
229
+ if (id === 'catpaw' && nextSentTotals) cc.syncState[id].sentTotals = nextSentTotals;
230
+ });
231
+ results.push({ source: id, label, status: 'ok', total: entries.length, selected: selected.length, imported, skipped, notes });
232
+ } catch (e) {
233
+ results.push({ source: id, label, status: 'failed', error: e.message });
234
+ if (e.code === 'BAD_KEY') break; // key 不对,后面的来源也不用试了
235
+ }
236
+ }
237
+ const imported = results.reduce((n, r) => n + (r.imported || 0), 0);
238
+ const skippedDup = results.reduce((n, r) => n + (r.skipped || 0), 0);
239
+ const failed = results.filter((r) => r.status === 'failed');
240
+ const summary = dryRun
241
+ ? '预览:' + results.map((r) => `${r.label} ${r.status === 'absent' ? '未检测到' : (r.selected ?? 0) + ' 行'}`).join(',')
242
+ : failed.length
243
+ ? `同步完成:新增 ${imported} 行,跳过 ${skippedDup} 行重复,${failed.length} 个来源失败(${failed.map((r) => r.label + ':' + r.error).join(';')})`
244
+ : `同步完成:新增 ${imported} 行,跳过 ${skippedDup} 行重复`;
245
+ if (!dryRun) {
246
+ await withConfig((cc) => { cc.lastSync = { at: new Date().toISOString(), trigger, summary, results }; });
247
+ (failed.length ? logger.warn : logger.info).call(logger, '10R', summary);
248
+ }
249
+ return { summary, results };
250
+ }
251
+
252
+ /** 后台每小时同步一次(仅在配置了且开启自动同步时) */
253
+ export function startUsageSyncScheduler() {
254
+ if (timer) return;
255
+ const tick = () => {
256
+ const c = loadConfig();
257
+ if (!isConfigured(c) || !c.sync.enabled) return;
258
+ runUsageSync({ trigger: 'auto' }).catch((e) => logger.warn('10R', '自动同步失败:' + e.message));
259
+ };
260
+ setTimeout(tick, 60_000).unref?.(); // 启动 1 分钟后先跑一轮
261
+ timer = setInterval(tick, SYNC_INTERVAL_MS);
262
+ timer.unref?.();
263
+ }