creditdaddy 1.6.2 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 讯飞 Loomy 独立登录授权模拟器(无需安装客户端,支持纯手机号验证码两步直登)。
3
+ *
4
+ * 逆向自讯飞 Loomy 生产客户端(account.xfinfr.com HMAC-SHA1 签名体系):
5
+ * - AppId: GM3LOOMY
6
+ * - AccessKeyId: 2thryby66wxi53sk
7
+ * - AccessKeySecret: zsak6eadrbawz683wf5r3m2snrwj868r
8
+ * - BaseUrl: https://account.xfinfr.com
9
+ *
10
+ * 流程:
11
+ * 1. sendLoomySms(phone) -> POST /login/phone/sendMsgCode (获取 msgid)
12
+ * 2. loginLoomyBySms({ phone, code, msgid }) -> POST /login/phone/checkCode (换取 14 天 session token)
13
+ * 3. registerLoomyByPhone({ phone, code }) -> POST /register/phone/submit (新手机号注册;与登录共用同一条验证码)
14
+ * 4. fetchLoomyIdentity(session) -> POST /userinfo/query/baseInfo (会话校验 / 昵称)
15
+ */
16
+
17
+ import crypto from 'node:crypto';
18
+
19
+ export const LOOMY_AUTH_CONFIG = {
20
+ baseUrl: 'https://account.xfinfr.com',
21
+ accessKeyId: '2thryby66wxi53sk',
22
+ accessKeySecret: 'zsak6eadrbawz683wf5r3m2snrwj868r',
23
+ appId: 'GM3LOOMY',
24
+ };
25
+
26
+ function contentMD5(body) {
27
+ if (!body) return '';
28
+ const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
29
+ const hash = crypto.createHash('md5').update(bodyStr, 'utf8').digest();
30
+ return hash.toString('base64');
31
+ }
32
+
33
+ function escapePathSegment(segment) {
34
+ return encodeURIComponent(segment)
35
+ .replace(/!/g, '%21')
36
+ .replace(/'/g, '%27')
37
+ .replace(/\(/g, '%28')
38
+ .replace(/\)/g, '%29')
39
+ .replace(/\*/g, '%2A');
40
+ }
41
+
42
+ function buildEscapedPath(path) {
43
+ let cleanPath = path.startsWith('/') ? path : '/' + path;
44
+ if (cleanPath.length > 1 && cleanPath.endsWith('/')) {
45
+ cleanPath = cleanPath.slice(0, -1);
46
+ }
47
+ return cleanPath.split('/').map((seg) => (seg ? escapePathSegment(seg) : '')).join('/');
48
+ }
49
+
50
+ function generateSignature({ method, path, queryParams = {}, body = '', contentType = 'application/json', date, nonce, accessKeySecret }) {
51
+ const escapedPath = buildEscapedPath(path);
52
+ const md5 = contentMD5(body);
53
+ const parts = [
54
+ method.toUpperCase(),
55
+ escapedPath,
56
+ '', // escapedQuery
57
+ md5,
58
+ contentType,
59
+ date,
60
+ nonce,
61
+ '', // signedHeaders
62
+ '' // canonicalizedHeaders
63
+ ];
64
+ const stringToSign = parts.join('\n');
65
+ const hmac = crypto.createHmac('sha1', accessKeySecret);
66
+ hmac.update(stringToSign, 'utf8');
67
+ return hmac.digest('base64');
68
+ }
69
+
70
+ function generateRequestHeaders({ method, path, body = '', contentType = 'application/json' }) {
71
+ const date = new Date().toUTCString();
72
+ const nonce = crypto.randomUUID();
73
+ const signature = generateSignature({
74
+ method,
75
+ path,
76
+ body,
77
+ contentType,
78
+ date,
79
+ nonce,
80
+ accessKeySecret: LOOMY_AUTH_CONFIG.accessKeySecret,
81
+ });
82
+
83
+ const authorization = `account ${LOOMY_AUTH_CONFIG.accessKeyId}:${signature}`;
84
+ const md5 = contentMD5(body);
85
+
86
+ const headers = {
87
+ Authorization: authorization,
88
+ Date: date,
89
+ Nonce: nonce,
90
+ 'Content-Type': contentType,
91
+ Accept: 'application/json',
92
+ };
93
+ if (md5) headers['Content-MD5'] = md5;
94
+ return headers;
95
+ }
96
+
97
+ function makeBase() {
98
+ return {
99
+ appid: LOOMY_AUTH_CONFIG.appId,
100
+ modelid: 'Web',
101
+ version: '1.0.0',
102
+ devid: 'web',
103
+ ua: 'Loomy|Desktop|Electron|Windows',
104
+ traceid: crypto.randomUUID().replace(/-/g, ''),
105
+ };
106
+ }
107
+
108
+ async function requestLoomy(path, body) {
109
+ const bodyString = JSON.stringify(body);
110
+ const headers = generateRequestHeaders({
111
+ method: 'POST',
112
+ path,
113
+ body: bodyString,
114
+ });
115
+
116
+ const url = `${LOOMY_AUTH_CONFIG.baseUrl}${path}`;
117
+ const res = await fetch(url, {
118
+ method: 'POST',
119
+ headers,
120
+ body: bodyString,
121
+ signal: AbortSignal.timeout(15000),
122
+ });
123
+
124
+ if (!res.ok) {
125
+ const text = await res.text().catch(() => '');
126
+ throw new Error(`讯飞认证接口 HTTP ${res.status}: ${text}`);
127
+ }
128
+
129
+ const json = await res.json().catch(() => ({}));
130
+ if (json.code && json.code !== '000000') {
131
+ const err = new Error(json.desc || json.message || `业务错误 (${json.code})`);
132
+ err.code = json.code;
133
+ // 100002 / 020002 与客户端 AUTH_ERROR_CODES 同口径:登录态无效
134
+ if (json.code === '100002' || json.code === '020002') err.auth = true;
135
+ throw err;
136
+ }
137
+
138
+ return json.data;
139
+ }
140
+
141
+ /** 发送手机验证码 */
142
+ export async function sendLoomySms(phone) {
143
+ const cleanPhone = String(phone || '').trim();
144
+ if (!/^1[3-9]\d{9}$/.test(cleanPhone)) {
145
+ throw new Error('手机号格式不正确');
146
+ }
147
+
148
+ const data = await requestLoomy('/login/phone/sendMsgCode', {
149
+ base: makeBase(),
150
+ param: {
151
+ ccode: '86',
152
+ phone: cleanPhone,
153
+ expire: 300,
154
+ },
155
+ });
156
+
157
+ if (!data?.msgid) {
158
+ throw new Error('讯飞接口未返回 msgid');
159
+ }
160
+
161
+ return { success: true, msgid: data.msgid };
162
+ }
163
+
164
+ /** 提交手机验证码换取 Session */
165
+ export async function loginLoomyBySms({ phone, code, msgid }) {
166
+ const cleanPhone = String(phone || '').trim();
167
+ const cleanCode = String(code || '').trim();
168
+ const cleanMsgid = String(msgid || '').trim();
169
+
170
+ if (!cleanPhone || !cleanCode || !cleanMsgid) {
171
+ throw new Error('手机号、验证码和 msgid 均为必填项');
172
+ }
173
+
174
+ const data = await requestLoomy('/login/phone/checkCode', {
175
+ base: makeBase(),
176
+ param: {
177
+ ccode: '86',
178
+ phone: cleanPhone,
179
+ mcode: cleanCode,
180
+ msgid: cleanMsgid,
181
+ expire: 14 * 24 * 3600, // 14 天有效
182
+ },
183
+ });
184
+
185
+ if (!data?.session) {
186
+ throw new Error('登录未返回 session 凭据');
187
+ }
188
+
189
+ return {
190
+ session: data.session,
191
+ userid: data.userid || data.uid,
192
+ phone: cleanPhone,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * 新手机号注册(与登录共用同一条短信验证码,param 不带 msgid)。
198
+ * 渲染层 registerByPhone 同款:phone + code(可选 password,这里不设密码)。
199
+ */
200
+ export async function registerLoomyByPhone({ phone, code }) {
201
+ const cleanPhone = String(phone || '').trim();
202
+ const cleanCode = String(code || '').trim();
203
+ if (!/^1[3-9]\d{9}$/.test(cleanPhone)) throw new Error('手机号格式不正确');
204
+ if (!cleanCode) throw new Error('验证码不能为空');
205
+
206
+ const data = await requestLoomy('/register/phone/submit', {
207
+ base: makeBase(),
208
+ param: { phone: cleanPhone, code: cleanCode },
209
+ });
210
+
211
+ // 注册成功即视为已登录(服务端语义同客户端:注册后可直接 checkCode 换 session)
212
+ return { success: true, ...data };
213
+ }
214
+
215
+ /** 会话校验 / 资料查询:POST /userinfo/query/baseInfo { param: { session } } */
216
+ export async function fetchLoomyIdentity(session) {
217
+ const cleanSession = String(session || '').trim();
218
+ if (!cleanSession) throw new Error('缺少 session');
219
+
220
+ const data = await requestLoomy('/userinfo/query/baseInfo', {
221
+ base: makeBase(),
222
+ param: { session: cleanSession },
223
+ });
224
+
225
+ return {
226
+ userid: String(data?.userid || ''),
227
+ phone: String(data?.phone || ''),
228
+ nickname: String(data?.nickname || '').trim() || null,
229
+ };
230
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * 讯飞 Loomy API 客户端 — 模型接口探活、账号信息校验与积分查询。
3
+ *
4
+ * 端点(协议逆向自 Loomy 客户端 points-service.js / LoginPage,2026-10-31 从 app.asar 实证):
5
+ * GET https://loomyad.xunfei.cn/api/v1/models 模型列表(Header: token)
6
+ * GET {pointsBase}/api/v1/points/records?pageNo=1&pageSize=1&recordType=all
7
+ * 积分余额(Header: token)——客户端没有 /points/balance 端点!余额字段就挂在
8
+ * records 响应 data.data 上:balance(永久积分)/ dailyBalance(每日赠送剩余)/
9
+ * dailyLimitPoints(每日上限)/ dailyConsumedPoints / dailyRemainingPoints。
10
+ * V2 records(/api/v2/points/records?direction&granularity)同样带 balance/dailyBalance。
11
+ * POST {pointsBase}/api/v1/points/first-login body {} 积分档案初始化(幂等,
12
+ * 客户端每次手机号登录成功后必调;100002 时补调一次再重试余额)
13
+ * pointsBase = https://loomyad.xunfei.cn(生产;2026-10-31 实测 wire 证据:客户端运行期
14
+ * 只连 loomyad 不连 ossptest,且同一 session 在 loomyad 上 records 直接返回
15
+ * code=000000、balance=10500 与客户端 UI 一致)
16
+ * POST https://account.xfinfr.com/userinfo/query/baseInfo 会话校验 / 昵称(HMAC 签名,见 loomyAuth.js)
17
+ *
18
+ * 已知坑(2026-10-31 实测):客户端 points-config.js 的 DEFAULT_BASE_URL 是
19
+ * ossptest.voicecloud.cn/loomy/integration —— 那是开发/测试环境,积分会话表与生产独立,
20
+ * 拿生产 session 打它必得 100002「登录已失效」(认证层 baseInfo 却是活的,极具迷惑性)。
21
+ * 生产积分服务与 models/auth 同域 loomyad.xunfei.cn。之前 100002 的真正根因就是打错了环境,
22
+ * 而不是会话失效。V1 records 响应不回 dailyLimitPoints(客户端 UI 的 /5000 上限来自文案口径)。
23
+ * 无论如何,绝不再拿 /models 的模型数量冒充积分(v1.6.5 初版显示「剩余 13」就是这么来的)。
24
+ */
25
+
26
+ import { fetchLoomyIdentity } from './loomyAuth.js';
27
+
28
+ export const LOOMY_BASE_URL = 'https://loomyad.xunfei.cn/api/v1';
29
+ /** 积分服务 base(生产域,与 models/auth 同域;可通过账号 meta.pointsBaseUrl 覆盖) */
30
+ export const LOOMY_POINTS_BASE = 'https://loomyad.xunfei.cn';
31
+ /** 客户端 points-config 的测试环境默认值——会话表独立,别用(留作显式覆盖的参考) */
32
+ export const LOOMY_POINTS_BASE_TEST = 'https://ossptest.voicecloud.cn/loomy/integration';
33
+
34
+ function pointsBaseOf(account) {
35
+ const v = String(account?.meta?.pointsBaseUrl || '').trim();
36
+ return v || LOOMY_POINTS_BASE;
37
+ }
38
+
39
+ function sessionOf(account) {
40
+ const token = account?.token || account?.apiKey || account?.session;
41
+ return String(token || '').trim();
42
+ }
43
+
44
+ /** 验证 Loomy 凭据有效性并获取可用模型数量 */
45
+ export async function fetchLoomyModels(token) {
46
+ if (!token) throw new Error('缺少 Loomy session token');
47
+ const res = await fetch(`${LOOMY_BASE_URL}/models`, {
48
+ method: 'GET',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ token: String(token).trim(),
52
+ },
53
+ signal: AbortSignal.timeout(10000),
54
+ });
55
+
56
+ if (res.status === 401 || res.status === 403) {
57
+ const err = new Error('Loomy session 已过期或无效,请在 Loomy 客户端重新登录');
58
+ err.auth = true;
59
+ throw err;
60
+ }
61
+
62
+ if (!res.ok) {
63
+ const text = await res.text().catch(() => '');
64
+ throw new Error(`Loomy /models 接口返回失败: HTTP ${res.status} ${text}`);
65
+ }
66
+
67
+ const data = await res.json().catch(() => ({}));
68
+ if (data?.code === '100002') {
69
+ const err = new Error('Loomy 提示缺少 token 或鉴权失败');
70
+ err.auth = true;
71
+ throw err;
72
+ }
73
+
74
+ const models = data?.data || data?.models || [];
75
+ return models;
76
+ }
77
+
78
+ /** verify 接口:添加账号时的基本资料校验(优先认证层 baseInfo 拿昵称,/models 只做能力面确认) */
79
+ export async function verifyLoomyAccount(account) {
80
+ const token = sessionOf(account);
81
+ if (!token) throw new Error('账号缺少 session token');
82
+
83
+ // 会话真伪以认证层为准(models 网关与认证服务口径可能不一致)
84
+ let identity = null;
85
+ try {
86
+ identity = await fetchLoomyIdentity(token);
87
+ } catch (e) {
88
+ if (e.auth) {
89
+ const err = new Error('Loomy session 已失效,请重新登录(短信验证码或客户端本机导入)');
90
+ err.auth = true;
91
+ throw err;
92
+ }
93
+ // 认证层抖动:退回 models 探活,别让一次网络抖动挡导入
94
+ await fetchLoomyModels(token);
95
+ }
96
+
97
+ const phone = identity?.phone || account?.phone || '';
98
+ const uid = identity?.userid || account?.uid || account?.userid || null;
99
+ const nickname = identity?.nickname || null;
100
+
101
+ const displayName = nickname
102
+ ? `Loomy (${nickname})`
103
+ : phone
104
+ ? `Loomy (${String(phone).slice(0, 3)}****${String(phone).slice(-4)})`
105
+ : uid
106
+ ? `Loomy (${String(uid).slice(-6)})`
107
+ : 'Loomy 账号';
108
+
109
+ return { name: displayName, uid, email: null };
110
+ }
111
+
112
+ /**
113
+ * records V1 → 统一 quota。字段与客户端「积分详情」页完全同源(侧边栏
114
+ * permanentPointsLabel=ly(balance)、dailyPointsLabel=ly(dailyBalance)):
115
+ * 永久积分 = balance(服务端总账,客户端就把它标成「永久积分」)
116
+ * 每日赠送积分 = dailyBalance / dailyLimitPoints(每日登录自动刷新为 5000)
117
+ * 兼容少量旧字段名;每日上限缺省按客户端文案口径 5000。
118
+ */
119
+ export function normalizePointsBalance(data) {
120
+ const d = data?.data || data || {};
121
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
122
+ const hasAny = d && typeof d === 'object' && Object.keys(d).length > 0;
123
+ const permanent = num(d.balance ?? d.permanentPoints ?? d.currentBalance ?? d.longTermPoints);
124
+ const daily = num(d.dailyBalance ?? d.dailyPoints ?? d.dailyGiftPoints);
125
+ // 每日上限:服务端显式给了就用它的值(哪怕是 0);没给才按客户端文案口径缺省 5000——
126
+ // 且只在账号确实有积分活动时缺省,零余额空号不再被捏造出「0/5000 已用 5000」的幻影额度
127
+ const limitRaw = d.dailyLimitPoints ?? d.dailyLimit ?? d.daily_limit;
128
+ const hasLimit = limitRaw !== undefined && limitRaw !== null && limitRaw !== '';
129
+ const dailyLimit = hasLimit ? num(limitRaw) : ((permanent > 0 || daily > 0) ? 5000 : 0);
130
+ const total = permanent + daily;
131
+ const parts = [];
132
+ if (permanent > 0) parts.push({ name: '永久积分', total: permanent, used: 0, remaining: permanent, expiresAt: null, recurring: false, unit: '积分' });
133
+ // 服务端上报的 dailyConsumedPoints 存在即直读(哪怕是 0——「今日确实没消耗」),
134
+ // 缺字段才按 dailyLimit - daily 倒推;|| 链会把如实上报的 0 当缺失,
135
+ // 倒推出「已用 2000」的幻影(#42)。顶层 used 同口径:永久积分无消耗数据,总已用 = 每日已用。
136
+ const hasConsumed = d.dailyConsumedPoints !== undefined && d.dailyConsumedPoints !== null;
137
+ const dailyUsed = hasConsumed ? num(d.dailyConsumedPoints) : Math.max(0, dailyLimit - daily);
138
+ if (daily > 0 || dailyLimit > 0) {
139
+ parts.push({ name: '每日赠送积分', total: dailyLimit, used: dailyUsed, remaining: daily, expiresAt: null, recurring: true, unit: '积分' });
140
+ }
141
+ return {
142
+ total,
143
+ used: dailyUsed,
144
+ remaining: total,
145
+ unit: '积分',
146
+ plan: null,
147
+ parts,
148
+ empty: total === 0 && parts.length === 0,
149
+ exceeded: false,
150
+ };
151
+ }
152
+
153
+ const LOOMY_POINTS_OK_CODES = new Set(['000000', 0, '0']);
154
+
155
+ async function pointsRecordsCall(base, token) {
156
+ let res;
157
+ try {
158
+ res = await fetch(`${base}/api/v1/points/records?pageNo=1&pageSize=1&recordType=all`, {
159
+ method: 'GET',
160
+ headers: { token },
161
+ signal: AbortSignal.timeout(12000),
162
+ });
163
+ } catch (e) {
164
+ const err = new Error(`Loomy 积分接口网络失败: ${e.message}`);
165
+ err.transient = true;
166
+ throw err;
167
+ }
168
+ const text = await res.text().catch(() => '');
169
+ let data = null;
170
+ if (text) { try { data = JSON.parse(text); } catch { data = null; } }
171
+ // 会话失效的业务码放行给上层(first-login 重试 / 如实报错),其余 HTTP/解析失败都要显性报错,
172
+ // 绝不让 5xx/HTML 错误页被吞成「空余额」
173
+ if (data?.code === '100002' || data?.code === '020002') return data;
174
+ if (!res.ok) {
175
+ throw new Error(`Loomy 积分接口 HTTP ${res.status}: ${(data?.desc || data?.message || text.slice(0, 120) || '').trim()}`);
176
+ }
177
+ if (!data) throw new Error(`Loomy 积分接口返回了非 JSON 数据 (HTTP ${res.status})`);
178
+ return data;
179
+ }
180
+
181
+ /** 积分档案初始化(客户端登录后必调的 first-login,幂等)。尽力而为,失败交给余额重试暴露。 */
182
+ async function pointsFirstLogin(base, token) {
183
+ try {
184
+ await fetch(`${base}/api/v1/points/first-login`, {
185
+ method: 'POST',
186
+ headers: { token, 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({}),
188
+ signal: AbortSignal.timeout(12000),
189
+ });
190
+ } catch { /* 初始化失败不阻断流程 */ }
191
+ }
192
+
193
+ /** 积分余额查询:records V1(余额字段挂在响应上)→ 100002 时按客户端协议补 first-login 再重试一次。 */
194
+ export async function fetchLoomyPoints(account) {
195
+ const token = sessionOf(account);
196
+ if (!token) throw new Error('账号缺少 session token');
197
+ const base = pointsBaseOf(account);
198
+
199
+ let data = await pointsRecordsCall(base, token);
200
+
201
+ if (data?.code === '100002' || data?.code === '020002') {
202
+ await pointsFirstLogin(base, token);
203
+ data = await pointsRecordsCall(base, token);
204
+ }
205
+
206
+ if (data?.code === '100002' || data?.code === '020002') {
207
+ // 仍不认此会话——回认证层确认会话真伪,把责任说清楚
208
+ let identity = null;
209
+ try {
210
+ identity = await fetchLoomyIdentity(token);
211
+ } catch (e) {
212
+ if (e.auth) {
213
+ const err = new Error('Loomy session 已失效,请重新登录');
214
+ err.auth = true;
215
+ throw err;
216
+ }
217
+ throw new Error('Loomy 积分接口与认证服务均不可达,请稍后重试');
218
+ }
219
+ const err = new Error(`会话有效(${identity.nickname || identity.phone}),但积分服务始终不认该会话(已按客户端协议补做 first-login 初始化)——请在 Loomy 客户端重新登录一次`);
220
+ err.pointsUnavailable = true;
221
+ throw err;
222
+ }
223
+ if (data?.code && !LOOMY_POINTS_OK_CODES.has(data.code)) {
224
+ throw new Error(data.desc || data.message || `Loomy 积分接口错误 (${data.code})`);
225
+ }
226
+ return normalizePointsBalance(data);
227
+ }
228
+
229
+ /** quota 接口:CreditDaddy 配额总览 —— 真实积分口径,失败如实上抛 */
230
+ export async function fetchLoomyQuota(account) {
231
+ return fetchLoomyPoints(account);
232
+ }
233
+
234
+ /** 邀请码激活状态:GET /api/v1/points/activation → {activated, appliedInvitationCode?}(实测响应形状) */
235
+ export async function fetchLoomyActivation(account) {
236
+ const token = sessionOf(account);
237
+ if (!token) throw new Error('账号缺少 session token');
238
+ let res;
239
+ try {
240
+ res = await fetch(`${pointsBaseOf(account)}/api/v1/points/activation`, {
241
+ method: 'GET',
242
+ headers: { token },
243
+ signal: AbortSignal.timeout(12000),
244
+ });
245
+ } catch (e) {
246
+ const err = new Error(`Loomy 激活状态查询网络失败: ${e.message}`);
247
+ err.transient = true;
248
+ throw err;
249
+ }
250
+ const text = await res.text().catch(() => '');
251
+ let data = null;
252
+ if (text) { try { data = JSON.parse(text); } catch { data = null; } }
253
+ if (data?.code === '100002' || data?.code === '020002') {
254
+ const err = new Error('Loomy session 已失效,请重新登录');
255
+ err.auth = true;
256
+ throw err;
257
+ }
258
+ if (!res.ok) {
259
+ throw new Error(`Loomy 激活状态查询 HTTP ${res.status}: ${(data?.desc || data?.message || text.slice(0, 120) || '').trim()}`);
260
+ }
261
+ if (!data) throw new Error(`Loomy 激活状态查询返回了非 JSON 数据 (HTTP ${res.status})`);
262
+ if (data?.code && !LOOMY_POINTS_OK_CODES.has(data.code)) {
263
+ throw new Error(data.desc || data.message || `Loomy 激活状态查询错误 (${data.code})`);
264
+ }
265
+ const d = data?.data || {};
266
+ return { activated: Boolean(d.activated), appliedInvitationCode: d.appliedInvitationCode || null };
267
+ }
268
+
269
+ /** 绑定邀请码:POST /api/v1/points/activation {inviteCode} → 服务端 +5000 永久积分(每号一次) */
270
+ export async function bindLoomyInviteCode(account, code) {
271
+ const token = sessionOf(account);
272
+ if (!token) throw new Error('账号缺少 session token');
273
+ const clean = String(code || '').trim();
274
+ if (!clean) throw new Error('缺少邀请码');
275
+ let res;
276
+ try {
277
+ res = await fetch(`${pointsBaseOf(account)}/api/v1/points/activation`, {
278
+ method: 'POST',
279
+ headers: { token, 'Content-Type': 'application/json' },
280
+ body: JSON.stringify({ inviteCode: clean }),
281
+ signal: AbortSignal.timeout(15000),
282
+ });
283
+ } catch (e) {
284
+ const err = new Error(`Loomy 邀请码绑定网络失败: ${e.message}`);
285
+ err.transient = true;
286
+ throw err;
287
+ }
288
+ const text = await res.text().catch(() => '');
289
+ let data = null;
290
+ if (text) { try { data = JSON.parse(text); } catch { data = null; } }
291
+ if (data?.code === '100002' || data?.code === '020002') {
292
+ const err = new Error('Loomy session 已失效,请重新登录');
293
+ err.auth = true;
294
+ throw err;
295
+ }
296
+ if (!res.ok) {
297
+ throw new Error(`Loomy 邀请码绑定 HTTP ${res.status}: ${(data?.desc || data?.message || text.slice(0, 120) || '').trim()}`);
298
+ }
299
+ if (!data) throw new Error(`Loomy 邀请码绑定返回了非 JSON 数据 (HTTP ${res.status})`);
300
+ if (data?.code && !LOOMY_POINTS_OK_CODES.has(data.code)) {
301
+ const err = new Error(data.desc || data.message || `邀请码绑定失败 (${data.code})`);
302
+ err.serverCode = data.code;
303
+ throw err;
304
+ }
305
+ return { bound: true, desc: data.desc || null, data: data.data || {} };
306
+ }