creditdaddy 1.2.0 → 1.3.2
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/LICENSE +21 -21
- package/README.md +40 -10
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/accounts.js +18 -7
- package/src/authDevice.js +3 -0
- package/src/bgdaemon.js +332 -0
- package/src/catpawClient.js +103 -103
- package/src/catpawLocal.js +229 -229
- package/src/checkin.js +10 -4
- package/src/constants.js +4 -2
- package/src/daemon.js +137 -13
- package/src/localDetect.js +147 -147
- package/src/logger.js +104 -6
- package/src/minimaxAuth.js +127 -0
- package/src/minimaxClient.js +339 -0
- package/src/minimaxGateway.js +410 -0
- package/src/minimaxLocal.js +259 -0
- package/src/mirasimClient.js +189 -189
- package/src/panel.html +459 -36
- package/src/providers.js +26 -0
- package/src/qoderApp.js +3 -9
- package/src/qoderClient.js +237 -237
- package/src/qoderUmid.js +198 -198
- package/src/store.js +65 -7
- package/src/tenrouter.js +8 -0
- package/src/tenrouterAccounts.js +38 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/transfer.js +24 -1
- package/src/usageSync.js +505 -505
- package/src/winProc.js +36 -0
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -97
- package/src/zcodeClient.js +93 -18
- package/src/zcodeGateway.js +104 -46
- package/src/zcodePlanShape.json +3 -3
- package/src/zcrypto.js +160 -160
package/src/bgdaemon.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CreditDaddy 后台守护 —— 让 `creditdaddy start` 把 daemon 丢到后台跑,终端可以随手关掉。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不直接把日志接到 daemon-YYYY-MM-DD.log:
|
|
5
|
+
* 同一个数据目录可能同时有多个实例(后台实例 + 用户手动跑的前台 daemon + 桌面版),
|
|
6
|
+
* 它们以 O_APPEND 共享当天文件。logger.js 关闭流时会 gzip 并清空原文件;子进程那侧
|
|
7
|
+
* 单独接 background.log,两个实例互不干扰,也避开 cleanupArchive 的文件名前缀约定。
|
|
8
|
+
*
|
|
9
|
+
* 状态落在 <数据目录>/daemon.json(不是 .pid),因为要记的不止 pid —— daemon 端口被占
|
|
10
|
+
* 时会自动 +1 重试最多 10 次(见 daemon.js 的 EADDRINUSE 分支),真正在听的端口只有
|
|
11
|
+
* startDaemon() resolve 后的 r.port 知道,所以由 daemon 进程自己写这个文件。
|
|
12
|
+
*
|
|
13
|
+
* 「在运行」= pid 存活 **且** 端口应答:pid 会被系统回收、文件会被手删,只查 pid 会误报;
|
|
14
|
+
* 只查端口又会把同端口的桌面版误认成 CLI 实例。状态查询走带鉴权的 /api/status(不新增
|
|
15
|
+
* 任何未鉴权端点),401 同样算「活着」—— 说明服务在,只是要面板密码。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
19
|
+
import fsSync from 'node:fs';
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import net from 'node:net';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dataDir } from './store.js';
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const CLI_ENTRY = path.join(__dirname, '..', 'bin', 'creditdaddy.js');
|
|
30
|
+
const DEFAULT_PORT = 47860;
|
|
31
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
32
|
+
|
|
33
|
+
// 后台启动最多等这么久:daemon 要读账号库、建 HTTP server、起签到定时器,冷启动慢一点正常
|
|
34
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
35
|
+
const READY_INTERVAL_MS = 250;
|
|
36
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
37
|
+
const STOP_TIMEOUT_MS = 8000;
|
|
38
|
+
|
|
39
|
+
/** 状态文件路径:跟随 store.js 的 dataDir(),尊重 CREDITDADDY_HOME */
|
|
40
|
+
export function runtimeFile() {
|
|
41
|
+
return path.join(dataDir(), 'daemon.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 后台实例的 stdout/stderr 落点(与按日归档的 daemon-*.log 分开,见文件头) */
|
|
45
|
+
export function backgroundLogFile() {
|
|
46
|
+
return path.join(dataDir(), 'logs', 'background.log');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 读状态文件。文件不存在 / 内容损坏都返回 null(调用方按「没有后台实例」处理) */
|
|
50
|
+
export function readRuntime() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = fsSync.readFileSync(runtimeFile(), 'utf8');
|
|
53
|
+
const r = JSON.parse(raw);
|
|
54
|
+
if (!r || typeof r !== 'object' || !Number.isInteger(r.pid) || r.pid <= 0) return null;
|
|
55
|
+
return r;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeRuntime(rec) {
|
|
62
|
+
const file = runtimeFile();
|
|
63
|
+
try {
|
|
64
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
65
|
+
await fs.writeFile(file, JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
66
|
+
return true;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
logger.warn('BG', `写状态文件失败:${e?.message || e}`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 删状态文件。仅当 pid 与自己一致时才删——避免把后一个实例的状态误删 */
|
|
74
|
+
export async function clearRuntime(expectedPid = null) {
|
|
75
|
+
try {
|
|
76
|
+
const cur = readRuntime();
|
|
77
|
+
if (expectedPid != null && cur && cur.pid !== expectedPid) return false;
|
|
78
|
+
await fs.unlink(runtimeFile());
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 进程是否还活着。
|
|
87
|
+
* ESRCH = 确实没有;EPERM = 在、只是当前用户无权发信号(不能当成已死);
|
|
88
|
+
* 抛到调用方的其他异常同样按「活着」处理——宁可多报一次运行中,也不要误杀。
|
|
89
|
+
*/
|
|
90
|
+
export function isAlive(pid) {
|
|
91
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return e?.code !== 'ESRCH';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 面板访问密码,取值顺序与 daemon.js 的 initPanelKey 保持一致:
|
|
102
|
+
* 面板设置里存的 panelKey 优先(用户可能已改过),环境变量兜底。
|
|
103
|
+
*/
|
|
104
|
+
export async function readPanelKey() {
|
|
105
|
+
try {
|
|
106
|
+
const { loadSettings } = await import('./store.js');
|
|
107
|
+
const s = await loadSettings();
|
|
108
|
+
if (typeof s.panelKey === 'string' && s.panelKey) return s.panelKey;
|
|
109
|
+
} catch { /* 读不到就退到环境变量 */ }
|
|
110
|
+
return process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 探一次 /api/status。200 与 401 都算 reachable(详见文件头)。
|
|
115
|
+
* 不带 Origin,能过 daemon 的 rejectForeignRequest(非浏览器客户端不受该校验约束)。
|
|
116
|
+
*/
|
|
117
|
+
export function probeStatus(rec, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
118
|
+
const port = Number(rec?.port);
|
|
119
|
+
const host = rec?.host || DEFAULT_HOST;
|
|
120
|
+
const probeHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
121
|
+
if (!Number.isInteger(port) || port <= 0) return Promise.resolve({ reachable: false });
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const req = http.request(
|
|
125
|
+
{ host: probeHost, port, path: '/api/status', method: 'GET', headers: { Host: `${probeHost}:${port}` } },
|
|
126
|
+
(res) => {
|
|
127
|
+
const chunks = [];
|
|
128
|
+
res.on('data', (c) => chunks.push(c));
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
const reachable = res.statusCode === 200 || res.statusCode === 401;
|
|
131
|
+
let body = null;
|
|
132
|
+
if (res.statusCode === 200) {
|
|
133
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; }
|
|
134
|
+
}
|
|
135
|
+
resolve({
|
|
136
|
+
reachable,
|
|
137
|
+
status: res.statusCode,
|
|
138
|
+
needsKey: res.statusCode === 401,
|
|
139
|
+
version: body?.version || null,
|
|
140
|
+
accountsCount: Number.isInteger(body?.accountsCount) ? body.accountsCount : null,
|
|
141
|
+
nextTickAt: body?.scheduler?.nextTickAt || null,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
// 401 也要把面板密码带上再试一次:daemon 可能在跑但设了访问密码,
|
|
147
|
+
// 拿不到 version 不影响「在运行」的判定,但要账号数就得认证。
|
|
148
|
+
req.on('error', () => resolve({ reachable: false }));
|
|
149
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ reachable: false }); });
|
|
150
|
+
readPanelKey().then((key) => {
|
|
151
|
+
if (key) req.setHeader('x-qd-key', key);
|
|
152
|
+
req.end();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 后台实例的完整状态:文件 + 进程 + 端口三处都查 */
|
|
158
|
+
export async function statusRuntime() {
|
|
159
|
+
const rec = readRuntime();
|
|
160
|
+
if (!rec) return { running: false, reason: 'no-state' };
|
|
161
|
+
const pidAlive = isAlive(rec.pid);
|
|
162
|
+
const probe = pidAlive ? await probeStatus(rec) : { reachable: false };
|
|
163
|
+
return { running: pidAlive && probe.reachable, pidAlive, probe, ...rec };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 端口已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
|
|
168
|
+
* 后台启动前先探,避免子进程悄悄漂到 47861 让用户摸不着面板在哪。
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* 端口是否已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
|
|
172
|
+
*
|
|
173
|
+
* 用「自己试着 bind 一下」判断,而不是发 HTTP 请求探活:非 HTTP 的程序(数据库、
|
|
174
|
+
* 别的服务)占着端口时不会回话,HTTP 探测会一直等到超时,然后误报「端口空闲」,
|
|
175
|
+
* 子进程就这么悄悄漂到了下一个端口,用户摸不着面板到底在哪。绑一下立刻 close,
|
|
176
|
+
* 唯一目的就是拿到 EADDRINUSE 这个答案。
|
|
177
|
+
*/
|
|
178
|
+
export function portTaken(port, host = DEFAULT_HOST) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const probe = net.createServer();
|
|
181
|
+
probe.once('error', (e) => resolve(e?.code === 'EADDRINUSE'));
|
|
182
|
+
probe.once('listening', () => probe.close(() => resolve(false)));
|
|
183
|
+
try {
|
|
184
|
+
probe.listen(port, host);
|
|
185
|
+
} catch {
|
|
186
|
+
resolve(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 等后台实例真正开始应答(子进程要先写状态文件,再能被探到) */
|
|
192
|
+
async function waitReady(timeoutMs = READY_TIMEOUT_MS) {
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const rec = readRuntime();
|
|
196
|
+
if (rec && isAlive(rec.pid)) {
|
|
197
|
+
const probe = await probeStatus(rec);
|
|
198
|
+
if (probe.reachable) return { ok: true, rec, probe };
|
|
199
|
+
}
|
|
200
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
201
|
+
}
|
|
202
|
+
return { ok: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 后台拉起 daemon。
|
|
207
|
+
* 已有一个活着的实例就直接返回(幂等)——脚本里 `start` 连跑两次不该起两个服务。
|
|
208
|
+
*/
|
|
209
|
+
export async function startBackground({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {}) {
|
|
210
|
+
const cur = await statusRuntime();
|
|
211
|
+
if (cur.running) return { already: true, rec: cur };
|
|
212
|
+
|
|
213
|
+
const stale = readRuntime();
|
|
214
|
+
if (stale) await clearRuntime(); // 残留状态(进程已死 / 端口失联),先清掉再起
|
|
215
|
+
|
|
216
|
+
if (await portTaken(port, host)) {
|
|
217
|
+
return { already: false, error: `端口 ${port} 已被其他程序占用` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const logFile = backgroundLogFile();
|
|
221
|
+
try {
|
|
222
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true });
|
|
223
|
+
} catch { /* 目录建不了就退回继承父进程 stdio,daemon 本身不受影响 */ }
|
|
224
|
+
|
|
225
|
+
let out = 'ignore';
|
|
226
|
+
let logFd = null;
|
|
227
|
+
try {
|
|
228
|
+
logFd = fsSync.openSync(logFile, 'a');
|
|
229
|
+
out = logFd;
|
|
230
|
+
} catch { /* 落不了盘就让 daemon 继承 stdio */ }
|
|
231
|
+
|
|
232
|
+
let child;
|
|
233
|
+
try {
|
|
234
|
+
child = spawn(process.execPath, [CLI_ENTRY, 'daemon', '--port', String(port), '--host', host], {
|
|
235
|
+
detached: true, // 独立进程组:关掉终端不影响它
|
|
236
|
+
windowsHide: true, // Windows 上不要弹控制台窗口
|
|
237
|
+
stdio: ['ignore', out, out],
|
|
238
|
+
env: { ...process.env, CREDITDADDY_BG: '1' }, // 让 daemon 知道自己跑在后台
|
|
239
|
+
});
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
|
|
242
|
+
return { already: false, error: `启动失败:${e?.message || e}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
|
|
246
|
+
child.unref(); // 父进程立刻可以退出,子进程继续活着
|
|
247
|
+
|
|
248
|
+
const ready = await waitReady();
|
|
249
|
+
if (!ready.ok) {
|
|
250
|
+
// 没等到应答:把刚起的进程收掉,否则会留一个查不到状态的孤儿
|
|
251
|
+
await killPid(child.pid);
|
|
252
|
+
return { already: false, error: `启动超时(${Math.round(READY_TIMEOUT_MS / 1000)}s),日志见 ${logFile}` };
|
|
253
|
+
}
|
|
254
|
+
return { already: false, rec: ready.rec };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 杀掉一个 pid。
|
|
259
|
+
* Windows 没有进程组概念,process.kill(-pid) 会直接抛 EINVAL,所以用 taskkill /T 杀进程树;
|
|
260
|
+
* taskkill 不可用(精简系统 / 容器)时退回单杀。
|
|
261
|
+
*/
|
|
262
|
+
export async function killPid(pid) {
|
|
263
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
264
|
+
if (process.platform === 'win32') {
|
|
265
|
+
try {
|
|
266
|
+
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
267
|
+
windowsHide: true, stdio: 'ignore', timeout: 10_000,
|
|
268
|
+
});
|
|
269
|
+
return true;
|
|
270
|
+
} catch { /* 进程可能已经没了,或 taskkill 不可用 */ }
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
// POSIX 下 detached 起的子进程自成进程组,-pid 才能连它自己拉起的子进程一起收掉
|
|
274
|
+
process.kill(-pid, 'SIGTERM');
|
|
275
|
+
return true;
|
|
276
|
+
} catch { /* 不是组长或已退出 */ }
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, 'SIGTERM');
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 端口是否已从监听状态退下 */
|
|
286
|
+
async function waitPortFree(port, host = DEFAULT_HOST, timeoutMs = STOP_TIMEOUT_MS) {
|
|
287
|
+
const deadline = Date.now() + timeoutMs;
|
|
288
|
+
while (Date.now() < deadline) {
|
|
289
|
+
if (!(await portTaken(port, host))) return true;
|
|
290
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 等进程真正从进程表里消失。
|
|
297
|
+
* SIGTERM 只是投递信号,handler 跑完 + 内核回收之间还有几十毫秒到几秒的窗口——
|
|
298
|
+
* 端口先释放、pid 后回收是常态。不等它就报 stopped:true,调用方紧接着的
|
|
299
|
+
* isAlive(pid) 仍会读到 true,误判「停不干净」。
|
|
300
|
+
*/
|
|
301
|
+
async function waitPidDead(pid, timeoutMs = STOP_TIMEOUT_MS) {
|
|
302
|
+
const deadline = Date.now() + timeoutMs;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
if (!isAlive(pid)) return true;
|
|
305
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 停掉后台实例。
|
|
312
|
+
* 没在跑也正常返回(幂等)——脚本里 `stop` 后紧接 `start` 不该因为「本来就没跑」而中断。
|
|
313
|
+
*/
|
|
314
|
+
export async function stopDaemon() {
|
|
315
|
+
const rec = readRuntime();
|
|
316
|
+
if (!rec) return { stopped: false, reason: 'no-state' };
|
|
317
|
+
|
|
318
|
+
if (!isAlive(rec.pid)) {
|
|
319
|
+
await clearRuntime();
|
|
320
|
+
return { stopped: false, reason: 'not-running', stale: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await killPid(rec.pid);
|
|
324
|
+
const free = await waitPortFree(rec.port, rec.host);
|
|
325
|
+
// 端口释放不等于进程退出:SIGTERM 只是投递,handler 收尾 + 内核回收还有窗口。
|
|
326
|
+
// 必须等到 pid 真的没了再报 stopped,否则调用方紧接着 isAlive(pid) 仍读到 true。
|
|
327
|
+
const dead = await waitPidDead(rec.pid);
|
|
328
|
+
await clearRuntime();
|
|
329
|
+
if (!free) logger.warn('BG', `端口 ${rec.port} 迟迟没有释放,进程可能仍在收尾`);
|
|
330
|
+
if (!dead) logger.warn('BG', `进程 ${rec.pid} 在 ${Math.round(STOP_TIMEOUT_MS / 1000)}s 内没有退出干净`);
|
|
331
|
+
return { stopped: true, rec, portFree: free, pidDead: dead };
|
|
332
|
+
}
|
package/src/catpawClient.js
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 妙手(美团 CatPaw)桌面网关 API 客户端 — 用户信息与额度/版本查询。
|
|
3
|
-
*
|
|
4
|
-
* GET https://catx.nocode.cn/api/gateway/auth/current-user 用户信息(userId, name, mobile, avatarUrl)
|
|
5
|
-
* GET https://catx.nocode.cn/api/gateway/credit/balance 可用额度 + 当前套餐(体验版/专业版…)
|
|
6
|
-
*
|
|
7
|
-
* 鉴权:本机妙手客户端解密出的 access_token,放在 X-Auth-Token 头
|
|
8
|
-
* (客户端内部 API 封装同款;网页端 credit.catpaw.meituan.com 的接口只认浏览器 Cookie,不适用)。
|
|
9
|
-
* 响应统一信封 { code: 0, message, data },code !== 0 视为业务错误。
|
|
10
|
-
*
|
|
11
|
-
* 凭据由妙手客户端登录管理(网页 OAuth 轮转),没有对外的 refresh 接口:
|
|
12
|
-
* token 失效(401)时提示在妙手客户端重新登录后重新本机导入。
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
-
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
17
|
-
|
|
18
|
-
export const CATPAW_GATEWAY = 'https://catx.nocode.cn';
|
|
19
|
-
|
|
20
|
-
const gw = (p) => CATPAW_GATEWAY + '/api/gateway' + p;
|
|
21
|
-
const authHeaders = (token) => ({ 'X-Auth-Token': token, Accept: 'application/json' });
|
|
22
|
-
|
|
23
|
-
/** GET 网关接口并拆信封;401 → e.auth = true(凭据失效) */
|
|
24
|
-
async function gwGet(path, token) {
|
|
25
|
-
const res = await fetchJsonRace(gw(path), { headers: authHeaders(token), timeoutMs: FETCH_TIMEOUT_MS });
|
|
26
|
-
if (res.status === 401) {
|
|
27
|
-
const e = new Error('妙手登录凭据已过期,请在妙手客户端重新登录后到「添加账号 → 本机导入」同步');
|
|
28
|
-
e.auth = true;
|
|
29
|
-
throw e;
|
|
30
|
-
}
|
|
31
|
-
if (!res.ok) throw new Error(`妙手接口 ${path} 失败: HTTP ${res.status}`);
|
|
32
|
-
const body = await res.json().catch(() => null);
|
|
33
|
-
if (!body || typeof body !== 'object') throw new Error(`妙手接口 ${path} 返回无法解析`);
|
|
34
|
-
if (body.code !== 0) {
|
|
35
|
-
const msg = String(body.message || body.errorCode || '未知错误');
|
|
36
|
-
if (body.code === 401 || /未登录|登录失效/.test(msg)) {
|
|
37
|
-
const e = new Error('妙手登录凭据已过期,请在妙手客户端重新登录后重新本机导入');
|
|
38
|
-
e.auth = true;
|
|
39
|
-
throw e;
|
|
40
|
-
}
|
|
41
|
-
throw new Error(`妙手接口 ${path} 返回错误:${msg}`);
|
|
42
|
-
}
|
|
43
|
-
return body.data;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 用户信息 → { userId, name, mobile, avatarUrl } */
|
|
47
|
-
export async function fetchCatpawProfile(token) {
|
|
48
|
-
if (!token) throw new Error('账号缺少 token');
|
|
49
|
-
const d = await gwGet('/auth/current-user', token);
|
|
50
|
-
if (!d || !d.userId) throw new Error('妙手用户信息响应缺少 userId(上游结构可能变化)');
|
|
51
|
-
return { userId: String(d.userId), name: d.name || null, mobile: d.mobile || null, avatarUrl: d.avatarUrl || null };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const iso = (ms) => (Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null);
|
|
55
|
-
|
|
56
|
-
/** credit/balance → 统一配额结构(可用妙手 Credits + 当前版本;无总量口径,不计入面板「剩余积分」合计) */
|
|
57
|
-
export function normalizeCatpawBalance(d) {
|
|
58
|
-
// 注意 Number(null) === 0:字段缺失 / 为 null 都要判为空额度,而不是 0
|
|
59
|
-
const raw = d?.availableCredits;
|
|
60
|
-
const credits = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
|
|
61
|
-
const plan = d?.userPlan || {};
|
|
62
|
-
const planName = plan.planName || (plan.pro ? '专业版' : '体验版');
|
|
63
|
-
const remaining = Number.isFinite(credits) ? credits : null;
|
|
64
|
-
return {
|
|
65
|
-
total: remaining ?? 0,
|
|
66
|
-
used: 0,
|
|
67
|
-
remaining: remaining ?? 0,
|
|
68
|
-
unit: 'Credits',
|
|
69
|
-
plan: planName,
|
|
70
|
-
planExpiresAt: iso(Number(plan.expireTime)),
|
|
71
|
-
nextRefreshAt: iso(Number(plan.nextRefreshTime)),
|
|
72
|
-
parts: [{
|
|
73
|
-
name: '可用额度',
|
|
74
|
-
total: remaining ?? 0,
|
|
75
|
-
used: 0,
|
|
76
|
-
remaining: remaining ?? 0,
|
|
77
|
-
unit: 'Credits',
|
|
78
|
-
expiresAt: iso(Number(plan.nextRefreshTime)),
|
|
79
|
-
recurring: true,
|
|
80
|
-
}],
|
|
81
|
-
empty: remaining === null,
|
|
82
|
-
exceeded: plan.status ? plan.status !== 'active' : false,
|
|
83
|
-
pro: plan.pro === true,
|
|
84
|
-
autoRenew: plan.autoRenew === true,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** 查询额度与版本(一次 balance 请求即含 userPlan;401 抛 auth 错误) */
|
|
89
|
-
export async function fetchCatpawQuota(account) {
|
|
90
|
-
if (!account.token) throw new Error('账号缺少 token');
|
|
91
|
-
return normalizeCatpawBalance(await gwGet('/credit/balance', account.token));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 云端用量(按天 token 总量,无输入/输出/缓存拆分、无模型维度):
|
|
96
|
-
* GET /api/gateway/v1/usage/token/daily?startTime=&endTime= → { daily: [{ date: 'YYYY-MM-DD', totalTokens }] }
|
|
97
|
-
* 当天的桶随用随涨,调用方应只取已结束的日期,避免同一天反复快照产生重复行。
|
|
98
|
-
*/
|
|
99
|
-
export async function fetchCatpawTokenDaily(token, { startTime, endTime } = {}) {
|
|
100
|
-
if (!token) throw new Error('账号缺少 token');
|
|
101
|
-
const d = await gwGet(`/v1/usage/token/daily?startTime=${startTime}&endTime=${endTime}`, token);
|
|
102
|
-
return Array.isArray(d && d.daily) ? d.daily : [];
|
|
103
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 妙手(美团 CatPaw)桌面网关 API 客户端 — 用户信息与额度/版本查询。
|
|
3
|
+
*
|
|
4
|
+
* GET https://catx.nocode.cn/api/gateway/auth/current-user 用户信息(userId, name, mobile, avatarUrl)
|
|
5
|
+
* GET https://catx.nocode.cn/api/gateway/credit/balance 可用额度 + 当前套餐(体验版/专业版…)
|
|
6
|
+
*
|
|
7
|
+
* 鉴权:本机妙手客户端解密出的 access_token,放在 X-Auth-Token 头
|
|
8
|
+
* (客户端内部 API 封装同款;网页端 credit.catpaw.meituan.com 的接口只认浏览器 Cookie,不适用)。
|
|
9
|
+
* 响应统一信封 { code: 0, message, data },code !== 0 视为业务错误。
|
|
10
|
+
*
|
|
11
|
+
* 凭据由妙手客户端登录管理(网页 OAuth 轮转),没有对外的 refresh 接口:
|
|
12
|
+
* token 失效(401)时提示在妙手客户端重新登录后重新本机导入。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
17
|
+
|
|
18
|
+
export const CATPAW_GATEWAY = 'https://catx.nocode.cn';
|
|
19
|
+
|
|
20
|
+
const gw = (p) => CATPAW_GATEWAY + '/api/gateway' + p;
|
|
21
|
+
const authHeaders = (token) => ({ 'X-Auth-Token': token, Accept: 'application/json' });
|
|
22
|
+
|
|
23
|
+
/** GET 网关接口并拆信封;401 → e.auth = true(凭据失效) */
|
|
24
|
+
async function gwGet(path, token) {
|
|
25
|
+
const res = await fetchJsonRace(gw(path), { headers: authHeaders(token), timeoutMs: FETCH_TIMEOUT_MS });
|
|
26
|
+
if (res.status === 401) {
|
|
27
|
+
const e = new Error('妙手登录凭据已过期,请在妙手客户端重新登录后到「添加账号 → 本机导入」同步');
|
|
28
|
+
e.auth = true;
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) throw new Error(`妙手接口 ${path} 失败: HTTP ${res.status}`);
|
|
32
|
+
const body = await res.json().catch(() => null);
|
|
33
|
+
if (!body || typeof body !== 'object') throw new Error(`妙手接口 ${path} 返回无法解析`);
|
|
34
|
+
if (body.code !== 0) {
|
|
35
|
+
const msg = String(body.message || body.errorCode || '未知错误');
|
|
36
|
+
if (body.code === 401 || /未登录|登录失效/.test(msg)) {
|
|
37
|
+
const e = new Error('妙手登录凭据已过期,请在妙手客户端重新登录后重新本机导入');
|
|
38
|
+
e.auth = true;
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`妙手接口 ${path} 返回错误:${msg}`);
|
|
42
|
+
}
|
|
43
|
+
return body.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 用户信息 → { userId, name, mobile, avatarUrl } */
|
|
47
|
+
export async function fetchCatpawProfile(token) {
|
|
48
|
+
if (!token) throw new Error('账号缺少 token');
|
|
49
|
+
const d = await gwGet('/auth/current-user', token);
|
|
50
|
+
if (!d || !d.userId) throw new Error('妙手用户信息响应缺少 userId(上游结构可能变化)');
|
|
51
|
+
return { userId: String(d.userId), name: d.name || null, mobile: d.mobile || null, avatarUrl: d.avatarUrl || null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const iso = (ms) => (Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null);
|
|
55
|
+
|
|
56
|
+
/** credit/balance → 统一配额结构(可用妙手 Credits + 当前版本;无总量口径,不计入面板「剩余积分」合计) */
|
|
57
|
+
export function normalizeCatpawBalance(d) {
|
|
58
|
+
// 注意 Number(null) === 0:字段缺失 / 为 null 都要判为空额度,而不是 0
|
|
59
|
+
const raw = d?.availableCredits;
|
|
60
|
+
const credits = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
|
|
61
|
+
const plan = d?.userPlan || {};
|
|
62
|
+
const planName = plan.planName || (plan.pro ? '专业版' : '体验版');
|
|
63
|
+
const remaining = Number.isFinite(credits) ? credits : null;
|
|
64
|
+
return {
|
|
65
|
+
total: remaining ?? 0,
|
|
66
|
+
used: 0,
|
|
67
|
+
remaining: remaining ?? 0,
|
|
68
|
+
unit: 'Credits',
|
|
69
|
+
plan: planName,
|
|
70
|
+
planExpiresAt: iso(Number(plan.expireTime)),
|
|
71
|
+
nextRefreshAt: iso(Number(plan.nextRefreshTime)),
|
|
72
|
+
parts: [{
|
|
73
|
+
name: '可用额度',
|
|
74
|
+
total: remaining ?? 0,
|
|
75
|
+
used: 0,
|
|
76
|
+
remaining: remaining ?? 0,
|
|
77
|
+
unit: 'Credits',
|
|
78
|
+
expiresAt: iso(Number(plan.nextRefreshTime)),
|
|
79
|
+
recurring: true,
|
|
80
|
+
}],
|
|
81
|
+
empty: remaining === null,
|
|
82
|
+
exceeded: plan.status ? plan.status !== 'active' : false,
|
|
83
|
+
pro: plan.pro === true,
|
|
84
|
+
autoRenew: plan.autoRenew === true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 查询额度与版本(一次 balance 请求即含 userPlan;401 抛 auth 错误) */
|
|
89
|
+
export async function fetchCatpawQuota(account) {
|
|
90
|
+
if (!account.token) throw new Error('账号缺少 token');
|
|
91
|
+
return normalizeCatpawBalance(await gwGet('/credit/balance', account.token));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 云端用量(按天 token 总量,无输入/输出/缓存拆分、无模型维度):
|
|
96
|
+
* GET /api/gateway/v1/usage/token/daily?startTime=&endTime= → { daily: [{ date: 'YYYY-MM-DD', totalTokens }] }
|
|
97
|
+
* 当天的桶随用随涨,调用方应只取已结束的日期,避免同一天反复快照产生重复行。
|
|
98
|
+
*/
|
|
99
|
+
export async function fetchCatpawTokenDaily(token, { startTime, endTime } = {}) {
|
|
100
|
+
if (!token) throw new Error('账号缺少 token');
|
|
101
|
+
const d = await gwGet(`/v1/usage/token/daily?startTime=${startTime}&endTime=${endTime}`, token);
|
|
102
|
+
return Array.isArray(d && d.daily) ? d.daily : [];
|
|
103
|
+
}
|