creditdaddy 1.1.1 → 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/authDevice.js CHANGED
@@ -1,205 +1,205 @@
1
- /**
2
- * 浏览器登录(统一入口):面板发起 → 浏览器 / 隐私窗口里授权 → 面板轮询 → 自动入库。
3
- *
4
- * qoder / qoder-cn Qoder 设备码登录(PKCE + nonce,与官方 qodercli 同流程,移植自 10router)
5
- * workbuddy / workbuddy-intl WorkBuddy / CodeBuddy 登录(state 轮询,见 workbuddyAuth.js)
6
- * zcode-bigmodel / zcode-zai ZCode 登录(客户端 CLI 轮询流程,见 zcodeAuth.js)
7
- *
8
- * Qoder 流程:
9
- * 1. 本地生成 PKCE 对 (verifier/challenge-S256) + nonce + machine_id
10
- * 2. 打开 verificationUriComplete(qoder.com/device/selectAccounts?challenge=...)选择账号授权
11
- * 3. 带 nonce+verifier 轮询 openapi.qoder.sh/api/v1/deviceToken/poll,202/404=等待,200=拿到 dt- token
12
- * token 约 30 天有效;上游 refresh 对该流程 403,过期后重新走一遍登录即可。
13
- *
14
- * 会话只存在内存里(授权是短时动作,重启重来即可);面板每 2s 轮询一次,
15
- * 各流程按自己的上游间隔节流,未到时间直接回 pending。
16
- */
17
-
18
- import crypto from 'node:crypto';
19
- import {
20
- OPENAPI_BASE, CN_OPENAPI_BASE,
21
- LOGIN_URL, CN_LOGIN_URL,
22
- USERINFO_PATH, PROVIDER_LABEL,
23
- } from './constants.js';
24
- import { publicAccount } from './store.js';
25
- import { addAccount } from './accounts.js';
26
- import { logger } from './logger.js';
27
- import { startWorkbuddyLogin, pollWorkbuddyLogin } from './workbuddyAuth.js';
28
- import { startZcodeLogin, pollZcodeLogin } from './zcodeAuth.js';
29
-
30
- const FETCH_TIMEOUT_MS = 15000;
31
- const QODER_TTL_MS = 5 * 60 * 1000; // Qoder 授权链接 5 分钟有效
32
- const QODER_POLL_MS = 2000;
33
-
34
- const sessions = new Map();
35
-
36
- function base64Url(buf) {
37
- return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
38
- }
39
-
40
- async function fetchWithTimeout(url, init = {}) {
41
- const controller = new AbortController();
42
- const timer = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS);
43
- try {
44
- return await fetch(url, { ...init, signal: controller.signal });
45
- } finally {
46
- clearTimeout(timer);
47
- }
48
- }
49
-
50
- // ── Qoder ──
51
- export function endpointsFor(provider) {
52
- const cn = provider === 'qoder-cn';
53
- return {
54
- deviceTokenUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + '/api/v1/deviceToken/poll',
55
- loginUrl: cn ? CN_LOGIN_URL : LOGIN_URL,
56
- userInfoUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + USERINFO_PATH,
57
- };
58
- }
59
-
60
- function startQoderLogin(provider) {
61
- const ep = endpointsFor(provider);
62
- const verifier = base64Url(crypto.randomBytes(32));
63
- const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
64
- const nonce = crypto.randomUUID();
65
- const params = new URLSearchParams({ challenge, challenge_method: 'S256', machine_id: crypto.randomUUID(), nonce });
66
- return {
67
- url: ep.loginUrl + '?' + params.toString(),
68
- expiresInMs: QODER_TTL_MS,
69
- intervalMs: QODER_POLL_MS,
70
- data: { provider, nonce, verifier },
71
- };
72
- }
73
-
74
- /** 用户资料(best-effort,失败不阻断登录) */
75
- async function fetchQoderProfile(provider, accessToken) {
76
- try {
77
- const res = await fetchWithTimeout(endpointsFor(provider).userInfoUrl, {
78
- headers: { Authorization: 'Bearer ' + accessToken, Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' },
79
- });
80
- if (!res.ok) return { name: null, email: null };
81
- const b = await res.json();
82
- return { name: (b.name || b.username || '').trim() || null, email: (b.email || '').trim() || null };
83
- } catch { return { name: null, email: null }; }
84
- }
85
-
86
- async function pollQoderLogin(data) {
87
- const url = endpointsFor(data.provider).deviceTokenUrl
88
- + '?nonce=' + encodeURIComponent(data.nonce)
89
- + '&verifier=' + encodeURIComponent(data.verifier)
90
- + '&challenge_method=S256';
91
- const res = await fetchWithTimeout(url, { headers: { Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' } });
92
- if (res.status === 202 || res.status === 404) return { status: 'pending' };
93
-
94
- const text = await res.text();
95
- if (!res.ok) {
96
- let msg = '轮询失败 HTTP ' + res.status;
97
- try { const b = JSON.parse(text); if (b.message) msg += ':' + b.message; } catch {}
98
- throw new Error(msg);
99
- }
100
- let body;
101
- try { body = JSON.parse(text); } catch (e) { throw new Error('轮询返回非 JSON:' + e.message); }
102
- if (!body.token) throw new Error('轮询返回 200 但没有 token(上游结构可能变化)');
103
-
104
- const profile = await fetchQoderProfile(data.provider, body.token);
105
- return {
106
- status: 'ok',
107
- input: {
108
- provider: data.provider,
109
- token: body.token,
110
- name: profile.name || profile.email || null,
111
- email: profile.email,
112
- uid: body.user_id || null,
113
- refreshToken: body.refresh_token || body.refreshToken || null,
114
- expiresAt: body.expires_at || (body.expires_in ? Date.now() + Number(body.expires_in) * 1000 : null),
115
- source: 'device',
116
- },
117
- };
118
- }
119
-
120
- // ── 统一入口 ──
121
- const FLOWS = {
122
- qoder: { start: startQoderLogin, poll: pollQoderLogin },
123
- 'qoder-cn': { start: startQoderLogin, poll: pollQoderLogin },
124
- workbuddy: { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
125
- 'workbuddy-intl': { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
126
- 'zcode-bigmodel': { start: startZcodeLogin, poll: pollZcodeLogin },
127
- 'zcode-zai': { start: startZcodeLogin, poll: pollZcodeLogin },
128
- };
129
- export const LOGIN_KINDS = Object.keys(FLOWS);
130
- const KIND_LABEL = { 'zcode-bigmodel': 'ZCode(BigModel)', 'zcode-zai': 'ZCode(Z.ai)' };
131
- const labelOf = (kind) => KIND_LABEL[kind] || PROVIDER_LABEL[kind] || kind;
132
-
133
- function gcSessions() {
134
- const now = Date.now();
135
- for (const [k, s] of sessions) if (s.expiresAt < now || s.done) sessions.delete(k);
136
- }
137
-
138
- /** 创建一次登录会话,返回浏览器授权地址 */
139
- export async function startDeviceFlow(kind) {
140
- const flow = FLOWS[kind];
141
- if (!flow) throw new Error('不支持的登录类型:' + kind);
142
- gcSessions();
143
- const r = await flow.start(kind);
144
- const sessionId = crypto.randomUUID();
145
- sessions.set(sessionId, {
146
- kind, data: r.data,
147
- expiresAt: Date.now() + r.expiresInMs,
148
- intervalMs: r.intervalMs, nextPollAt: 0,
149
- done: false, busy: false,
150
- });
151
- logger.info('DEVICE', '发起浏览器登录(' + labelOf(kind) + ')');
152
- return {
153
- sessionId,
154
- url: r.url,
155
- expiresIn: Math.floor(r.expiresInMs / 1000),
156
- interval: Math.max(1, Math.floor(r.intervalMs / 1000)),
157
- };
158
- }
159
-
160
- /**
161
- * 面板侧轮询入口。返回:
162
- * { status: 'pending' } 继续等
163
- * { status: 'ok', account, already } 成功(already=账号已存在)
164
- * { status: 'expired' } 会话过期,重新发起
165
- * { status: 'failed', error } 终态失败
166
- */
167
- export async function pollDeviceFlow(sessionId) {
168
- gcSessions();
169
- const session = sessions.get(sessionId);
170
- if (!session) return { status: 'expired' };
171
- if (Date.now() > session.expiresAt) {
172
- sessions.delete(sessionId);
173
- return { status: 'expired' };
174
- }
175
- // 节流:未到上游轮询间隔、或上一轮还在进行,直接回 pending
176
- if (session.busy || Date.now() < session.nextPollAt) return { status: 'pending' };
177
- session.busy = true;
178
- session.nextPollAt = Date.now() + session.intervalMs;
179
-
180
- let result;
181
- try {
182
- result = await FLOWS[session.kind].poll(session.data);
183
- } catch (err) {
184
- sessions.delete(sessionId);
185
- logger.warn('DEVICE', labelOf(session.kind) + ' 登录失败:' + err.message);
186
- return { status: 'failed', error: err.message };
187
- } finally {
188
- session.busy = false;
189
- }
190
- if (result.status === 'pending') return { status: 'pending' };
191
-
192
- let added;
193
- try {
194
- added = await addAccount(result.input, { trusted: true });
195
- } catch (e) {
196
- sessions.delete(sessionId);
197
- return { status: 'failed', error: e.message };
198
- }
199
- session.done = true;
200
- const { account, duplicate } = added;
201
- logger.info('DEVICE', duplicate
202
- ? labelOf(session.kind) + ' 登录成功:账号已存在,已更新凭据(' + (account.name || account.id) + ')'
203
- : labelOf(session.kind) + ' 登录成功:' + (account.name || account.id));
204
- return { status: 'ok', account: publicAccount(account), already: duplicate };
205
- }
1
+ /**
2
+ * 浏览器登录(统一入口):面板发起 → 浏览器 / 隐私窗口里授权 → 面板轮询 → 自动入库。
3
+ *
4
+ * qoder / qoder-cn Qoder 设备码登录(PKCE + nonce,与官方 qodercli 同流程,移植自 10router)
5
+ * workbuddy / workbuddy-intl WorkBuddy / CodeBuddy 登录(state 轮询,见 workbuddyAuth.js)
6
+ * zcode-bigmodel / zcode-zai ZCode 登录(客户端 CLI 轮询流程,见 zcodeAuth.js)
7
+ *
8
+ * Qoder 流程:
9
+ * 1. 本地生成 PKCE 对 (verifier/challenge-S256) + nonce + machine_id
10
+ * 2. 打开 verificationUriComplete(qoder.com/device/selectAccounts?challenge=...)选择账号授权
11
+ * 3. 带 nonce+verifier 轮询 openapi.qoder.sh/api/v1/deviceToken/poll,202/404=等待,200=拿到 dt- token
12
+ * token 约 30 天有效;上游 refresh 对该流程 403,过期后重新走一遍登录即可。
13
+ *
14
+ * 会话只存在内存里(授权是短时动作,重启重来即可);面板每 2s 轮询一次,
15
+ * 各流程按自己的上游间隔节流,未到时间直接回 pending。
16
+ */
17
+
18
+ import crypto from 'node:crypto';
19
+ import {
20
+ OPENAPI_BASE, CN_OPENAPI_BASE,
21
+ LOGIN_URL, CN_LOGIN_URL,
22
+ USERINFO_PATH, PROVIDER_LABEL,
23
+ } from './constants.js';
24
+ import { publicAccount } from './store.js';
25
+ import { addAccount } from './accounts.js';
26
+ import { logger } from './logger.js';
27
+ import { startWorkbuddyLogin, pollWorkbuddyLogin } from './workbuddyAuth.js';
28
+ import { startZcodeLogin, pollZcodeLogin } from './zcodeAuth.js';
29
+
30
+ const FETCH_TIMEOUT_MS = 15000;
31
+ const QODER_TTL_MS = 5 * 60 * 1000; // Qoder 授权链接 5 分钟有效
32
+ const QODER_POLL_MS = 2000;
33
+
34
+ const sessions = new Map();
35
+
36
+ function base64Url(buf) {
37
+ return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
38
+ }
39
+
40
+ async function fetchWithTimeout(url, init = {}) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS);
43
+ try {
44
+ return await fetch(url, { ...init, signal: controller.signal });
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ // ── Qoder ──
51
+ export function endpointsFor(provider) {
52
+ const cn = provider === 'qoder-cn';
53
+ return {
54
+ deviceTokenUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + '/api/v1/deviceToken/poll',
55
+ loginUrl: cn ? CN_LOGIN_URL : LOGIN_URL,
56
+ userInfoUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + USERINFO_PATH,
57
+ };
58
+ }
59
+
60
+ function startQoderLogin(provider) {
61
+ const ep = endpointsFor(provider);
62
+ const verifier = base64Url(crypto.randomBytes(32));
63
+ const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
64
+ const nonce = crypto.randomUUID();
65
+ const params = new URLSearchParams({ challenge, challenge_method: 'S256', machine_id: crypto.randomUUID(), nonce });
66
+ return {
67
+ url: ep.loginUrl + '?' + params.toString(),
68
+ expiresInMs: QODER_TTL_MS,
69
+ intervalMs: QODER_POLL_MS,
70
+ data: { provider, nonce, verifier },
71
+ };
72
+ }
73
+
74
+ /** 用户资料(best-effort,失败不阻断登录) */
75
+ async function fetchQoderProfile(provider, accessToken) {
76
+ try {
77
+ const res = await fetchWithTimeout(endpointsFor(provider).userInfoUrl, {
78
+ headers: { Authorization: 'Bearer ' + accessToken, Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' },
79
+ });
80
+ if (!res.ok) return { name: null, email: null };
81
+ const b = await res.json();
82
+ return { name: (b.name || b.username || '').trim() || null, email: (b.email || '').trim() || null };
83
+ } catch { return { name: null, email: null }; }
84
+ }
85
+
86
+ async function pollQoderLogin(data) {
87
+ const url = endpointsFor(data.provider).deviceTokenUrl
88
+ + '?nonce=' + encodeURIComponent(data.nonce)
89
+ + '&verifier=' + encodeURIComponent(data.verifier)
90
+ + '&challenge_method=S256';
91
+ const res = await fetchWithTimeout(url, { headers: { Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' } });
92
+ if (res.status === 202 || res.status === 404) return { status: 'pending' };
93
+
94
+ const text = await res.text();
95
+ if (!res.ok) {
96
+ let msg = '轮询失败 HTTP ' + res.status;
97
+ try { const b = JSON.parse(text); if (b.message) msg += ':' + b.message; } catch {}
98
+ throw new Error(msg);
99
+ }
100
+ let body;
101
+ try { body = JSON.parse(text); } catch (e) { throw new Error('轮询返回非 JSON:' + e.message); }
102
+ if (!body.token) throw new Error('轮询返回 200 但没有 token(上游结构可能变化)');
103
+
104
+ const profile = await fetchQoderProfile(data.provider, body.token);
105
+ return {
106
+ status: 'ok',
107
+ input: {
108
+ provider: data.provider,
109
+ token: body.token,
110
+ name: profile.name || profile.email || null,
111
+ email: profile.email,
112
+ uid: body.user_id || null,
113
+ refreshToken: body.refresh_token || body.refreshToken || null,
114
+ expiresAt: body.expires_at || (body.expires_in ? Date.now() + Number(body.expires_in) * 1000 : null),
115
+ source: 'device',
116
+ },
117
+ };
118
+ }
119
+
120
+ // ── 统一入口 ──
121
+ const FLOWS = {
122
+ qoder: { start: startQoderLogin, poll: pollQoderLogin },
123
+ 'qoder-cn': { start: startQoderLogin, poll: pollQoderLogin },
124
+ workbuddy: { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
125
+ 'workbuddy-intl': { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
126
+ 'zcode-bigmodel': { start: startZcodeLogin, poll: pollZcodeLogin },
127
+ 'zcode-zai': { start: startZcodeLogin, poll: pollZcodeLogin },
128
+ };
129
+ export const LOGIN_KINDS = Object.keys(FLOWS);
130
+ const KIND_LABEL = { 'zcode-bigmodel': 'ZCode(BigModel)', 'zcode-zai': 'ZCode(Z.ai)' };
131
+ const labelOf = (kind) => KIND_LABEL[kind] || PROVIDER_LABEL[kind] || kind;
132
+
133
+ function gcSessions() {
134
+ const now = Date.now();
135
+ for (const [k, s] of sessions) if (s.expiresAt < now || s.done) sessions.delete(k);
136
+ }
137
+
138
+ /** 创建一次登录会话,返回浏览器授权地址 */
139
+ export async function startDeviceFlow(kind) {
140
+ const flow = FLOWS[kind];
141
+ if (!flow) throw new Error('不支持的登录类型:' + kind);
142
+ gcSessions();
143
+ const r = await flow.start(kind);
144
+ const sessionId = crypto.randomUUID();
145
+ sessions.set(sessionId, {
146
+ kind, data: r.data,
147
+ expiresAt: Date.now() + r.expiresInMs,
148
+ intervalMs: r.intervalMs, nextPollAt: 0,
149
+ done: false, busy: false,
150
+ });
151
+ logger.info('DEVICE', '发起浏览器登录(' + labelOf(kind) + ')');
152
+ return {
153
+ sessionId,
154
+ url: r.url,
155
+ expiresIn: Math.floor(r.expiresInMs / 1000),
156
+ interval: Math.max(1, Math.floor(r.intervalMs / 1000)),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * 面板侧轮询入口。返回:
162
+ * { status: 'pending' } 继续等
163
+ * { status: 'ok', account, already } 成功(already=账号已存在)
164
+ * { status: 'expired' } 会话过期,重新发起
165
+ * { status: 'failed', error } 终态失败
166
+ */
167
+ export async function pollDeviceFlow(sessionId) {
168
+ gcSessions();
169
+ const session = sessions.get(sessionId);
170
+ if (!session) return { status: 'expired' };
171
+ if (Date.now() > session.expiresAt) {
172
+ sessions.delete(sessionId);
173
+ return { status: 'expired' };
174
+ }
175
+ // 节流:未到上游轮询间隔、或上一轮还在进行,直接回 pending
176
+ if (session.busy || Date.now() < session.nextPollAt) return { status: 'pending' };
177
+ session.busy = true;
178
+ session.nextPollAt = Date.now() + session.intervalMs;
179
+
180
+ let result;
181
+ try {
182
+ result = await FLOWS[session.kind].poll(session.data);
183
+ } catch (err) {
184
+ sessions.delete(sessionId);
185
+ logger.warn('DEVICE', labelOf(session.kind) + ' 登录失败:' + err.message);
186
+ return { status: 'failed', error: err.message };
187
+ } finally {
188
+ session.busy = false;
189
+ }
190
+ if (result.status === 'pending') return { status: 'pending' };
191
+
192
+ let added;
193
+ try {
194
+ added = await addAccount(result.input, { trusted: true });
195
+ } catch (e) {
196
+ sessions.delete(sessionId);
197
+ return { status: 'failed', error: e.message };
198
+ }
199
+ session.done = true;
200
+ const { account, duplicate } = added;
201
+ logger.info('DEVICE', duplicate
202
+ ? labelOf(session.kind) + ' 登录成功:账号已存在,已更新凭据(' + (account.name || account.id) + ')'
203
+ : labelOf(session.kind) + ' 登录成功:' + (account.name || account.id));
204
+ return { status: 'ok', account: publicAccount(account), already: duplicate };
205
+ }
@@ -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
+ }