creditdaddy 1.5.0 → 1.5.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/src/traeClient.js CHANGED
@@ -1,266 +1,294 @@
1
- /**
2
- * Trae CN 服务端 API — 额度查询与每日签到领积分。
3
- *
4
- * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/status {} 是否已签 / 可领额度
5
- * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim {} 领取(约 150 通用积分/天)
6
- * POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage {"require_usage":true,"req_source":2}
7
- *
8
- * 鉴权:authorization: Cloud-IDE-JWT <jwt>(本机客户端 storage.json 解出的那个)。
9
- * 这些接口是 IDE 自己在调的,因此必须带全套 IDE 请求头;风控要求 x-device-id 为纯数字设备号,
10
- * 传 GUID 会返回 code 9074。多账号签到时每号必须用独立设备码(同一设备号服务端限每日一领),
11
- * 故 x-device-id 按 uid 确定性派生。响应统一信封 { code, message, ... },code !== 0 即业务失败。
12
- *
13
- * 请求头集与错误码口径来自社区逆向(TraeWorkAssistant trae_checkin.rs / star620 TraeApiClient.cs 实测)。
14
- */
15
-
16
- import crypto from 'node:crypto';
17
- import { fetchJsonRace } from './zcodeClient.js';
18
- import { FETCH_TIMEOUT_MS } from './constants.js';
19
- import { uidFromJwt } from './traeLocal.js';
20
-
21
- export const TRAE_API = 'https://api.trae.cn';
22
-
23
- const STATUS_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/status`;
24
- const CLAIM_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/claim`;
25
- const USAGE_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_ent_usage`;
26
- const PAY_STATUS_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_pay_status`;
27
-
28
- /** 时间戳宽容转换:Trae 部分字段是秒、部分是毫秒 */
29
- const iso = (v) => {
30
- const n = Number(v);
31
- if (!Number.isFinite(n) || n <= 0) return null;
32
- const ms = n < 1e11 ? n * 1000 : n;
33
- const d = new Date(ms);
34
- return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
35
- };
36
-
37
- const hex = (bytes) => crypto.randomBytes(bytes).toString('hex');
38
-
39
- /**
40
- * x-market-user-id / vscode-sessionid 不在客户端 storage.json 里,按 uid 确定性派生:
41
- * 同一账号每次请求指纹恒定(随机值会让服务端看到「每台请求换一台设备」)。
42
- */
43
- function seeded(uid, salt, n) {
44
- const prefix = `${salt}:${uid}`;
45
- const out = [];
46
- for (let counter = 0; out.length < n; counter++) {
47
- out.push(...crypto.createHash('sha256').update(`${prefix}:${counter}`).digest());
48
- }
49
- return out.slice(0, n);
50
- }
51
-
52
- export function traeDevice(uid, storedDeviceId) {
53
- const d = seeded(uid, 'devid', 15).map((b) => String(48 + (b % 10)));
54
- const m = seeded(uid, 'market', 16);
55
- m[6] = (m[6] & 0x0f) | 0x40;
56
- m[8] = (m[8] & 0x3f) | 0x80;
57
- const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
58
- return {
59
- // x-device-id 按 uid 派生(每号独立且稳定):本机导入的 N 个账号若共用客户端自报的
60
- // 同一设备号,服务端按设备限每日一领——第二个账号起签到必吃 9074(issue #28,
61
- // TraeTools 的「设备码切换」本质就是给每个账号换独立设备码)。这里不落 storedDeviceId。
62
- deviceId: d.join(''),
63
- marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
64
- sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
65
- };
66
- }
67
-
68
- function traeHeaders(jwt, dev) {
69
- const token = jwt.startsWith('Cloud-IDE-JWT ') ? jwt : `Cloud-IDE-JWT ${jwt.trim()}`;
70
- return {
71
- accept: '*/*',
72
- 'accept-language': 'zh-CN',
73
- authorization: token,
74
- 'content-type': 'application/json',
75
- 'user-agent': 'VSCode 1.107.1 (TRAE SOLO CN)',
76
- 'x-market-client-id': 'VSCode 1.107.1',
77
- 'x-market-user-id': dev.marketUserId,
78
- 'x-user-region': 'CN',
79
- 'x-device-id': dev.deviceId,
80
- 'x-lgw-req-sdk-type': '3',
81
- 'package-type': 'stable_cn',
82
- 'x-request-id': hex(16),
83
- 'x-lscbd-aid': '787976',
84
- 'x-lscbd-platform': process.platform === 'win32' ? 'windows' : process.platform,
85
- 'app-version': '0.1.45',
86
- 'x-tt-trace-id': `00-${hex(8)}-01`,
87
- 'vscode-sessionid': dev.sessionId,
88
- };
89
- }
90
-
91
- /** checked_in / credits 可能被包在 data / result 信封里,只读顶层会把已签账号判成未签而重复领取 */
92
- function findField(obj, key, depth = 0) {
93
- if (!obj || typeof obj !== 'object' || depth > 2) return undefined;
94
- if (key in obj) return obj[key];
95
- for (const child of [obj.data, obj.result, obj.Result]) {
96
- const v = findField(child, key, depth + 1);
97
- if (v !== undefined) return v;
98
- }
99
- return undefined;
100
- }
101
-
102
- /** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效) */
103
- async function post(url, account, body = {}) {
104
- const jwt = account.token;
105
- if (!jwt) throw new Error('账号缺少 Trae 登录 token(请到 Trae 客户端登录后用「本机导入」同步)');
106
- const uid = account.uid || uidFromJwt(jwt);
107
- const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId);
108
- const res = await fetchJsonRace(url, {
109
- method: 'POST',
110
- headers: traeHeaders(jwt, dev),
111
- body: JSON.stringify(body),
112
- timeoutMs: FETCH_TIMEOUT_MS,
113
- });
114
- if (res.status === 401) {
115
- const e = new Error('Trae 登录凭据已失效,请在 Trae 客户端重新登录后重新本机导入');
116
- e.auth = true;
117
- throw e;
118
- }
119
- const parsed = await res.json().catch(() => null);
120
- if (!parsed || typeof parsed !== 'object') {
121
- throw new Error(`Trae 接口返回无法解析(HTTP ${res.status})`);
122
- }
123
- const code = Number(parsed.code);
124
- if (code === 1001) {
125
- const e = new Error(String(parsed.message || 'Trae JWT 已被服务端吊销'));
126
- e.auth = true;
127
- throw e;
128
- }
129
- return { status: res.status, body: parsed, uid, code: Number.isFinite(code) ? code : null, message: String(parsed.message || '') };
130
- }
131
-
132
- /** claim/status 的业务失败(code !== 0)不带 HTTP 错误状态,按 code 与 message 判定 */
133
- const authDead = (e) => Boolean(e && e.auth);
134
-
135
- /**
136
- * 额度:ide_user_ent_usage 的权益包列表 → CreditDaddy 统一结构。
137
- * 剩余 = entitlement_base_info.quota.credits_limit − pack 顶层 usage.credits_amount(逐包求和)。
138
- *
139
- * ⚠️ 这个接口返回裸对象,没有 { code, message } 信封(与签到接口不同),
140
- * 只有 code 存在且非 0 才算业务失败。总额度优先取服务端自报的 usage_summary,
141
- * 逐包明细仍按 pack 展开(面板要显示每包到期时间)。
142
- */
143
- export async function fetchTraeQuota(account) {
144
- const r = await post(USAGE_URL, account, { require_usage: true, req_source: 2 });
145
- if (r.code !== null && r.code !== 0) throw new Error(`Trae 额度接口返回错误:${r.message || `code ${r.code}`}`);
146
- const packs = (findField(r.body, 'user_entitlement_pack_list') || []).filter((p) => p && typeof p === 'object');
147
- // 每天签到会新发一个 150 分 / 7 天有效期的包,账户上同名包能堆到 30 个;
148
- // 逐包直出会淹掉面板,故按名称归并(到期时间取最早的那个)。
149
- const byName = new Map();
150
- let total = 0;
151
- let used = 0;
152
- for (const pack of packs) {
153
- const base = pack.entitlement_base_info || {};
154
- const limit = Number(base.quota?.credits_limit);
155
- if (!Number.isFinite(limit) || limit <= 0) continue;
156
- const spent = Number(pack.usage?.credits_amount) || 0;
157
- // product_id 209 = Work 积分,其余是通用积分
158
- const isWork = Number(base.product_id) === 209;
159
- const name = pack.group_name || pack.display_desc || (isWork ? 'Work 积分' : '通用积分');
160
- const expiresAt = iso(pack.expire_time ?? base.end_time);
161
- const prev = byName.get(name);
162
- if (prev) {
163
- prev.total += limit;
164
- prev.used += spent;
165
- if (expiresAt && (!prev.expiresAt || expiresAt < prev.expiresAt)) prev.expiresAt = expiresAt;
166
- } else {
167
- byName.set(name, { name, total: limit, used: spent, expiresAt, recurring: !isWork, unit: '积分' });
168
- }
169
- total += limit;
170
- used += spent;
171
- }
172
- const parts = [...byName.values()]
173
- .map((p) => ({ ...p, total: round2(p.total), used: round2(p.used), remaining: round2(Math.max(p.total - p.used, 0)) }))
174
- .sort((a, b) => b.remaining - a.remaining);
175
- const summary = r.body.usage_summary && typeof r.body.usage_summary === 'object' ? r.body.usage_summary : null;
176
- if (summary && Number(summary.total_amount) > 0) {
177
- total = Number(summary.total_amount);
178
- used = Number(summary.consumed_amount) || 0;
179
- }
180
- const plan = await fetchTraePlan(account).catch(() => null);
181
- return {
182
- total: round2(total),
183
- used: round2(used),
184
- remaining: round2(Math.max(total - used, 0)),
185
- exceeded: total > 0 && total - used <= 0,
186
- // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
187
- // Trae 与 Qoder 同为「积分」口径,必须计入剩余积分合计。
188
- plan: plan?.plan || null,
189
- planExpiresAt: plan?.expiresAt || null,
190
- parts,
191
- empty: parts.length === 0 && !summary,
192
- };
193
- }
194
-
195
- const round2 = (n) => Math.round(n * 100) / 100;
196
-
197
- /** ide_user_pay_status → 套餐名(Free / Lite / Pro)与到期时间;查不到不影响额度 */
198
- async function fetchTraePlan(account) {
199
- const r = await post(PAY_STATUS_URL, account, { req_source: 2 });
200
- if (r.code !== null && r.code !== 0) return null;
201
- const name = findField(r.body, 'user_pay_identity_str');
202
- const expire = findField(r.body, 'end_time') ?? findField(r.body, 'next_billing_time');
203
- return { plan: typeof name === 'string' && name ? name : null, expiresAt: iso(expire) };
204
- }
205
-
206
- /**
207
- * 每日签到:先查 status,已签则报 already,未签才 claim。
208
- * 返回 CreditDaddy 的签到契约 { status, claimedAmount?, message?, uid? }。
209
- */
210
- export async function checkinTrae(account, ctx = {}) {
211
- const uid = account.uid || uidFromJwt(account.token || '');
212
- const label = account.name || account.id;
213
- const base = { accountId: account.id, account: label, provider: 'trae' };
214
- let r;
215
- try {
216
- r = await post(STATUS_URL, account);
217
- } catch (e) {
218
- if (authDead(e)) throw e;
219
- return { ...base, status: 'failed', message: e.message, uid };
220
- }
221
- if (r.code !== 0) {
222
- return { ...base, status: r.code === 9074 ? 'limited' : 'failed', message: r.message || `code ${r.code}`, uid };
223
- }
224
- const already = findField(r.body, 'checked_in');
225
- if (already === true || already === 1) {
226
- return { ...base, status: 'already', message: '今天已签到', uid };
227
- }
228
-
229
- // 签到应得额度:status 接口里通常预告 credits / extra_credits(如 100 积分),
230
- // claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
231
- const TRAE_DEFAULT_DAILY_CREDITS = 100;
232
- const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
233
-
234
- const c = await post(CLAIM_URL, account);
235
- if (c.code !== 0) {
236
- ctx.log?.(`Trae 领取失败:${c.message || `code ${c.code}`}`);
237
- return {
238
- ...base,
239
- status: c.code === 9074 ? 'limited' : 'failed',
240
- message: c.message || `code ${c.code}`,
241
- uid,
242
- };
243
- }
244
- const claimed = Number(findField(c.body, 'credits')) || expectedCredits;
245
- return { ...base, status: 'checked-in', claimedAmount: claimed, message: claimed ? `签到成功,+${claimed} 积分` : '签到成功', uid };
246
- }
247
-
248
- /**
249
- * 添加账号时的校验:Trae 的 token 是本地可自证的 JWT(含 uid 与到期时间),
250
- * 不需要网络往返;凭据只来自 Trae 客户端登录后的本机导入。
251
- */
252
- export async function verifyTraeAccount(account) {
253
- const token = account.token;
254
- if (!token || token.split('.').length !== 3) throw new Error('不是 Trae 的 Cloud-IDE-JWT token');
255
- let payload;
256
- try { payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch {
257
- throw new Error('Trae token 的 JWT 负载无法解析');
258
- }
259
- const uid = payload?.data?.id ? String(payload.data.id) : null;
260
- if (!uid) throw new Error('Trae token 里缺少用户 id');
261
- const exp = Number(payload.exp);
262
- if (Number.isFinite(exp) && exp * 1000 < Date.now()) {
263
- throw new Error(`这个 Trae token 已于 ${new Date(exp * 1000).toLocaleString()} 过期,请到 Trae 客户端重新登录后重新导入`);
264
- }
265
- return { uid, name: account.name || null, email: account.email || null };
266
- }
1
+ /**
2
+ * Trae CN 服务端 API — 额度查询与每日签到领积分。
3
+ *
4
+ * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/status {} 是否已签 / 可领额度
5
+ * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim {} 领取(约 150 通用积分/天)
6
+ * POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage {"require_usage":true,"req_source":2}
7
+ *
8
+ * 鉴权:authorization: Cloud-IDE-JWT <jwt>(本机客户端 storage.json 解出的那个)。
9
+ * 这些接口是 IDE 自己在调的,因此必须带全套 IDE 请求头;风控要求 x-device-id 为纯数字设备号,
10
+ * 传 GUID 会返回 code 9074。多账号签到时每号必须用独立设备码(同一设备号服务端限每日一领),
11
+ * 故 x-device-id 按 uid 确定性派生。响应统一信封 { code, message, ... },code !== 0 即业务失败。
12
+ *
13
+ * 请求头集与错误码口径来自社区逆向(TraeWorkAssistant trae_checkin.rs / star620 TraeApiClient.cs 实测)。
14
+ */
15
+
16
+ import crypto from 'node:crypto';
17
+ import { fetchJsonRace } from './zcodeClient.js';
18
+ import { FETCH_TIMEOUT_MS } from './constants.js';
19
+ import { uidFromJwt } from './traeLocal.js';
20
+
21
+ export const TRAE_API = 'https://api.trae.cn';
22
+
23
+ const STATUS_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/status`;
24
+ const CLAIM_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/claim`;
25
+ const USAGE_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_ent_usage`;
26
+ const PAY_STATUS_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_pay_status`;
27
+
28
+ /** 签到应得额度兜底:claim 有时只返回 { code:0 } 不带 credits 字段(status 预告值也拿不到时用) */
29
+ const TRAE_DEFAULT_DAILY_CREDITS = 100;
30
+
31
+ /** 时间戳宽容转换:Trae 部分字段是秒、部分是毫秒 */
32
+ const iso = (v) => {
33
+ const n = Number(v);
34
+ if (!Number.isFinite(n) || n <= 0) return null;
35
+ const ms = n < 1e11 ? n * 1000 : n;
36
+ const d = new Date(ms);
37
+ return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
38
+ };
39
+
40
+ const hex = (bytes) => crypto.randomBytes(bytes).toString('hex');
41
+
42
+ /**
43
+ * x-market-user-id / vscode-sessionid 不在客户端 storage.json 里,按 uid 确定性派生:
44
+ * 同一账号每次请求指纹恒定(随机值会让服务端看到「每台请求换一台设备」)。
45
+ */
46
+ function seeded(uid, salt, n) {
47
+ const prefix = `${salt}:${uid}`;
48
+ const out = [];
49
+ for (let counter = 0; out.length < n; counter++) {
50
+ out.push(...crypto.createHash('sha256').update(`${prefix}:${counter}`).digest());
51
+ }
52
+ return out.slice(0, n);
53
+ }
54
+
55
+ export function traeDevice(uid, storedDeviceId) {
56
+ const d = seeded(uid, 'devid', 15).map((b) => String(48 + (b % 10)));
57
+ const m = seeded(uid, 'market', 16);
58
+ m[6] = (m[6] & 0x0f) | 0x40;
59
+ m[8] = (m[8] & 0x3f) | 0x80;
60
+ const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
61
+ return {
62
+ // x-device-id 按 uid 派生(每号独立且稳定):本机导入的 N 个账号若共用客户端自报的
63
+ // 同一设备号,服务端按设备限每日一领——第二个账号起签到必吃 9074(issue #28,
64
+ // TraeTools 的「设备码切换」本质就是给每个账号换独立设备码)。这里不落 storedDeviceId。
65
+ deviceId: d.join(''),
66
+ marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
67
+ sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
68
+ };
69
+ }
70
+
71
+ function traeHeaders(jwt, dev) {
72
+ const token = jwt.startsWith('Cloud-IDE-JWT ') ? jwt : `Cloud-IDE-JWT ${jwt.trim()}`;
73
+ return {
74
+ accept: '*/*',
75
+ 'accept-language': 'zh-CN',
76
+ authorization: token,
77
+ 'content-type': 'application/json',
78
+ 'user-agent': 'VSCode 1.107.1 (TRAE SOLO CN)',
79
+ 'x-market-client-id': 'VSCode 1.107.1',
80
+ 'x-market-user-id': dev.marketUserId,
81
+ 'x-user-region': 'CN',
82
+ 'x-device-id': dev.deviceId,
83
+ 'x-lgw-req-sdk-type': '3',
84
+ 'package-type': 'stable_cn',
85
+ 'x-request-id': hex(16),
86
+ 'x-lscbd-aid': '787976',
87
+ 'x-lscbd-platform': process.platform === 'win32' ? 'windows' : process.platform,
88
+ 'app-version': '0.1.45',
89
+ 'x-tt-trace-id': `00-${hex(8)}-01`,
90
+ 'vscode-sessionid': dev.sessionId,
91
+ };
92
+ }
93
+
94
+ /** checked_in / credits 可能被包在 data / result 信封里,只读顶层会把已签账号判成未签而重复领取 */
95
+ function findField(obj, key, depth = 0) {
96
+ if (!obj || typeof obj !== 'object' || depth > 2) return undefined;
97
+ if (key in obj) return obj[key];
98
+ for (const child of [obj.data, obj.result, obj.Result]) {
99
+ const v = findField(child, key, depth + 1);
100
+ if (v !== undefined) return v;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效) */
106
+ async function post(url, account, body = {}) {
107
+ const jwt = account.token;
108
+ if (!jwt) throw new Error('账号缺少 Trae 登录 token(请到 Trae 客户端登录后用「本机导入」同步)');
109
+ const uid = account.uid || uidFromJwt(jwt);
110
+ const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId);
111
+ const res = await fetchJsonRace(url, {
112
+ method: 'POST',
113
+ headers: traeHeaders(jwt, dev),
114
+ body: JSON.stringify(body),
115
+ timeoutMs: FETCH_TIMEOUT_MS,
116
+ });
117
+ if (res.status === 401) {
118
+ const e = new Error('Trae 登录凭据已失效,请在 Trae 客户端重新登录后重新本机导入');
119
+ e.auth = true;
120
+ throw e;
121
+ }
122
+ const parsed = await res.json().catch(() => null);
123
+ if (!parsed || typeof parsed !== 'object') {
124
+ throw new Error(`Trae 接口返回无法解析(HTTP ${res.status})`);
125
+ }
126
+ const code = Number(parsed.code);
127
+ if (code === 1001) {
128
+ const e = new Error(String(parsed.message || 'Trae JWT 已被服务端吊销'));
129
+ e.auth = true;
130
+ throw e;
131
+ }
132
+ return { status: res.status, body: parsed, uid, code: Number.isFinite(code) ? code : null, message: String(parsed.message || '') };
133
+ }
134
+
135
+ /** claim/status 的业务失败(code !== 0)不带 HTTP 错误状态,按 code 与 message 判定 */
136
+ const authDead = (e) => Boolean(e && e.auth);
137
+
138
+ /** 业务失败提示统一附 code:9074 之外的风控码全靠它定位(issue #31 排查被 message 吞码坑过) */
139
+ const withCode = (message, code) => (Number.isFinite(code) && code !== 0 ? `${message}(code ${code})` : message);
140
+
141
+ /**
142
+ * 额度:ide_user_ent_usage 的权益包列表 → CreditDaddy 统一结构。
143
+ * 剩余 = entitlement_base_info.quota.credits_limit − pack 顶层 usage.credits_amount(逐包求和)。
144
+ *
145
+ * ⚠️ 这个接口返回裸对象,没有 { code, message } 信封(与签到接口不同),
146
+ * 只有 code 存在且非 0 才算业务失败。总额度优先取服务端自报的 usage_summary,
147
+ * 逐包明细仍按 pack 展开(面板要显示每包到期时间)。
148
+ */
149
+ export async function fetchTraeQuota(account) {
150
+ const r = await post(USAGE_URL, account, { require_usage: true, req_source: 2 });
151
+ if (r.code !== null && r.code !== 0) throw new Error(`Trae 额度接口返回错误:${r.message || `code ${r.code}`}`);
152
+ const packs = (findField(r.body, 'user_entitlement_pack_list') || []).filter((p) => p && typeof p === 'object');
153
+ // 每天签到会新发一个 150 分 / 7 天有效期的包,账户上同名包能堆到 30 个;
154
+ // 逐包直出会淹掉面板,故按名称归并(到期时间取最早的那个)。
155
+ const byName = new Map();
156
+ let total = 0;
157
+ let used = 0;
158
+ for (const pack of packs) {
159
+ const base = pack.entitlement_base_info || {};
160
+ const limit = Number(base.quota?.credits_limit);
161
+ if (!Number.isFinite(limit) || limit <= 0) continue;
162
+ const spent = Number(pack.usage?.credits_amount) || 0;
163
+ // product_id 209 = Work 积分,其余是通用积分
164
+ const isWork = Number(base.product_id) === 209;
165
+ const name = pack.group_name || pack.display_desc || (isWork ? 'Work 积分' : '通用积分');
166
+ const expiresAt = iso(pack.expire_time ?? base.end_time);
167
+ const prev = byName.get(name);
168
+ if (prev) {
169
+ prev.total += limit;
170
+ prev.used += spent;
171
+ if (expiresAt && (!prev.expiresAt || expiresAt < prev.expiresAt)) prev.expiresAt = expiresAt;
172
+ } else {
173
+ byName.set(name, { name, total: limit, used: spent, expiresAt, recurring: !isWork, unit: '积分' });
174
+ }
175
+ total += limit;
176
+ used += spent;
177
+ }
178
+ const parts = [...byName.values()]
179
+ .map((p) => ({ ...p, total: round2(p.total), used: round2(p.used), remaining: round2(Math.max(p.total - p.used, 0)) }))
180
+ .sort((a, b) => b.remaining - a.remaining);
181
+ const summary = r.body.usage_summary && typeof r.body.usage_summary === 'object' ? r.body.usage_summary : null;
182
+ if (summary && Number(summary.total_amount) > 0) {
183
+ total = Number(summary.total_amount);
184
+ used = Number(summary.consumed_amount) || 0;
185
+ }
186
+ const plan = await fetchTraePlan(account).catch(() => null);
187
+ return {
188
+ total: round2(total),
189
+ used: round2(used),
190
+ remaining: round2(Math.max(total - used, 0)),
191
+ exceeded: total > 0 && total - used <= 0,
192
+ // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
193
+ // Trae 与 Qoder 同为「积分」口径,必须计入剩余积分合计。
194
+ plan: plan?.plan || null,
195
+ planExpiresAt: plan?.expiresAt || null,
196
+ parts,
197
+ empty: parts.length === 0 && !summary,
198
+ };
199
+ }
200
+
201
+ const round2 = (n) => Math.round(n * 100) / 100;
202
+
203
+ /** ide_user_pay_status → 套餐名(Free / Lite / Pro)与到期时间;查不到不影响额度 */
204
+ async function fetchTraePlan(account) {
205
+ const r = await post(PAY_STATUS_URL, account, { req_source: 2 });
206
+ if (r.code !== null && r.code !== 0) return null;
207
+ const name = findField(r.body, 'user_pay_identity_str');
208
+ const expire = findField(r.body, 'end_time') ?? findField(r.body, 'next_billing_time');
209
+ return { plan: typeof name === 'string' && name ? name : null, expiresAt: iso(expire) };
210
+ }
211
+
212
+ /**
213
+ * 每日签到:先查 status,已签则报 already,未签才 claim。
214
+ * 返回 CreditDaddy 的签到契约 { status, claimedAmount?, message?, uid? }。
215
+ */
216
+ export async function checkinTrae(account, ctx = {}) {
217
+ const uid = account.uid || uidFromJwt(account.token || '');
218
+ const label = account.name || account.id;
219
+ const base = { accountId: account.id, account: label, provider: 'trae' };
220
+ let r;
221
+ try {
222
+ r = await post(STATUS_URL, account);
223
+ } catch (e) {
224
+ if (authDead(e)) throw e;
225
+ return { ...base, status: 'failed', message: e.message, uid };
226
+ }
227
+ if (r.code !== 0) {
228
+ // 服务端部分拒绝文案也长「已经签到」样(如「当前设备今日已经签到」)——归 already,
229
+ // 面板显示「今日已领」而非「领取失败」;多账号同 IP / 同登录源被服务端风控合并时走这条
230
+ if (/已经签到|已签到|already.*sign/i.test(String(r.message || ''))) {
231
+ return { ...base, status: 'already', message: withCode(r.message || '今天已签到', r.code), uid };
232
+ }
233
+ // 9074 实测文案「当前参与用户太多,请稍后再试」= 服务端瞬时限流(issue #31 面板实测),
234
+ // 不是设备限领——退避重试而非直接放弃;重试耗尽归 failed(不写签到 memo,下一轮还试)
235
+ if (r.code === 9074) {
236
+ return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, TRAE_DEFAULT_DAILY_CREDITS);
237
+ }
238
+ return { ...base, status: 'failed', message: withCode(r.message || `code ${r.code}`, r.code), uid };
239
+ }
240
+ const already = findField(r.body, 'checked_in');
241
+ if (already === true || already === 1) {
242
+ return { ...base, status: 'already', message: '今天已签到', uid };
243
+ }
244
+
245
+ // 签到应得额度:status 接口里通常预告 credits / extra_credits(如 100 积分),
246
+ // claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
247
+ const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
248
+
249
+ return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, expectedCredits);
250
+ }
251
+
252
+ /** 9074 瞬时限流退避重试:3 次,间隔 3s/8s/15s;始终失败返回最后一次响应(由 finalizeClaim 归 failed) */
253
+ async function claimWithRetry(ctx, attempt) {
254
+ const backoff = [0, 3000, 8000, 15000];
255
+ let res = await attempt();
256
+ for (let i = 1; i < backoff.length && res.code === 9074; i++) {
257
+ ctx.log?.(`Trae 服务端限流(code 9074),${backoff[i] / 1000}s 后重试(${i}/${backoff.length - 1})`);
258
+ await new Promise((ok) => setTimeout(ok, backoff[i]));
259
+ res = await attempt();
260
+ }
261
+ return res;
262
+ }
263
+
264
+ /** claim 响应 → 签到契约;「已经签到」文案归 already,9074 重试耗尽归 failed(不写 memo 可下轮重试) */
265
+ function finalizeClaim(c, base, uid, fallbackCredits = 0) {
266
+ if (c.code !== 0) {
267
+ if (/已经签到|已签到|already.*sign/i.test(String(c.message || ''))) {
268
+ return { ...base, status: 'already', message: withCode(c.message || '今天已签到', c.code), uid };
269
+ }
270
+ return { ...base, status: 'failed', message: withCode(c.message || `code ${c.code}`, c.code), uid };
271
+ }
272
+ const claimed = Number(findField(c.body, 'credits')) || fallbackCredits;
273
+ return { ...base, status: 'checked-in', claimedAmount: claimed, message: claimed ? `签到成功,+${claimed} 积分` : '签到成功', uid };
274
+ }
275
+
276
+ /**
277
+ * 添加账号时的校验:Trae 的 token 是本地可自证的 JWT(含 uid 与到期时间),
278
+ * 不需要网络往返;凭据只来自 Trae 客户端登录后的本机导入。
279
+ */
280
+ export async function verifyTraeAccount(account) {
281
+ const token = account.token;
282
+ if (!token || token.split('.').length !== 3) throw new Error('不是 Trae 的 Cloud-IDE-JWT token');
283
+ let payload;
284
+ try { payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch {
285
+ throw new Error('Trae token 的 JWT 负载无法解析');
286
+ }
287
+ const uid = payload?.data?.id ? String(payload.data.id) : null;
288
+ if (!uid) throw new Error('Trae token 里缺少用户 id');
289
+ const exp = Number(payload.exp);
290
+ if (Number.isFinite(exp) && exp * 1000 < Date.now()) {
291
+ throw new Error(`这个 Trae token 已于 ${new Date(exp * 1000).toLocaleString()} 过期,请到 Trae 客户端重新登录后重新导入`);
292
+ }
293
+ return { uid, name: account.name || null, email: account.email || null };
294
+ }