magicrew-auth 0.3.26 → 0.3.29

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/captcha.mjs CHANGED
@@ -1,17 +1,32 @@
1
- // 无头登录的底层:旋转验证码 + 手机验证码换取登录态
1
+ // 无头登录的底层:旋转验证码 + 手机/邮箱验证码换取登录态
2
2
  //
3
3
  // 全部是**纯 HTTP**,不开浏览器、不读 localStorage。协议是从网页版
4
- // `SMSVerificationCodeForm` chunk 里逆出来的,每一步都实测过
4
+ // `SMSVerificationCodeForm` / `LoginCoreForm` chunk 里逆出来的,每一步都实测过
5
5
  // (见 docs/internals.md「无头登录」一节):
6
6
  //
7
- // ① POST /captcha/generate { account_type, mobile, state_code }
7
+ // ① POST /captcha/generate { account_type, mobile, state_code } 手机
8
+ // { account_type, email } 邮箱
8
9
  // → { token, image(data:image/webp;base64,…), expire_time }
9
10
  // ② 算角度 → angle = enc(deg, n)
10
11
  // ③ POST /captcha/verify { token, angle, account_type, mobile, state_code }
12
+ // { token, angle, account_type, email }
11
13
  // → { verified: true, business_token: "biz_…" }
12
- // ④ POST /v4/user/send-sms { type, phone, state_code }
13
- // + header `X-Captcha-Token: <business_token>`
14
- // ⑤ POST /v4/user/login { type: "phone_captcha", phone, state_code, code, device }
14
+ // ④ 发验证码(两条路**是两个端点**,别混):
15
+ // 手机 POST /v4/user/send-sms { type, phone, state_code }
16
+ // 邮箱 POST /v4/user/send-email { email, type }
17
+ // + header `X-Captcha-Token: <business_token>`,type 都是 "account_login"
18
+ // ⑤ 换登录态(同样是两个端点):
19
+ // 手机 POST /v4/user/login { type:"phone_captcha", phone, state_code, code, device }
20
+ // 邮箱 POST /v4/user/login-email { email, captcha, device }
21
+ //
22
+ // ⚠ 邮箱那条为什么走 `/v4/user/login-email` 而不是 `/v4/user/login`:
23
+ // 实测(2026-10-28,两站)——往 `/v4/user/login` 发任何 type(email / email_captcha
24
+ // / email_code)都回 2000「phone 字段是必须的」,服务端根本不认邮箱。
25
+ // 前端 `UserApi` 里也是两条独立方法:`loginByEmail()` → `/v4/user/login-email`,
26
+ // 并且它显式 `delete("organization-code")`。
27
+ //
28
+ // ⚠ 邮箱那条的**验证码字段名是 `captcha`,不是 `code`** —— 实测发 `code` 回
29
+ // 2000「captcha 字段是必须的」;发 `captcha` 回 2168「验证码错误」。
15
30
  //
16
31
  // ⚠ 三个域名各管一段,别混:
17
32
  // www.<站点> → 前端页面(只用来取 Origin,别的没用)
@@ -23,6 +38,34 @@
23
38
  // · 「验证不存在或已过期」 expire_time 给 10 分钟
24
39
  // · 「验证提交过于频繁」 连续批量提交会被拦
25
40
  // ⇒ 不能枚举角度,一次要算准;失败就换图重来。
41
+ //
42
+ // ═══════════════════════════════════════════════════════════════════════
43
+ // 那张旋转图到底能不能绕开(2026-10-29 逐条实测,别再来一遍)
44
+ // ═══════════════════════════════════════════════════════════════════════
45
+ //
46
+ // 结论:**只有「这个号已注册」这一条免费通道**,其余一律必须看懂图。
47
+ //
48
+ // ① 已注册的号 —— 服务端**不出图**:`/captcha/generate` 直接回 `business_token`。
49
+ // 实测:已注册邮箱 → business_token;没注册过的邮箱 → token + image。
50
+ // ⇒ 「把我已有的账号加进 magpie」根本不需要识图模型(见 headless.mjs 里
51
+ // startCodeLogin 的「先取图、再判模型」顺序)。
52
+ //
53
+ // ② 新号的答案**不在前端**,也不在图里:
54
+ // · 前端提交的 `angle` 就是**用户拖动的角度**(`verify(r)` → 这个文件里的
55
+ // encodeAngle),前端不知道正确答案 —— 知道的话就不会让你拖了;
56
+ // · 图里没有任何元数据:解剖 webp 只有 `VP8X` / `ALPH` / `VP8` 三个 chunk,
57
+ // 没有 EXIF / XMP / ICC / 自定义块,字符串扫描也是空的;
58
+ // · 服务端强制:`verify` 不传 angle → 「angle 字段是必须的」;乱填 →
59
+ // 「验证失败,请重试」;发码不带 `X-Captcha-Token`(哪怕带上账号自己的 JWT
60
+ // 和 organization-code)→ 「需要验证码验证」。
61
+ // ⇒ 答案 = 服务端生成图时随机定的角度,存在它自己的会话里(10 分钟过期)。
62
+ // 要过它只有两条路:识图模型,或人眼看。
63
+ //
64
+ // ③ 不经过验证码的登录入口只有两条,都不是邮箱验证码的替代品:
65
+ // · 手机号 + 密码 → `POST /api/v1/sessions {type:"phone_password"}`
66
+ // · 本机浏览器里已登录 → 读它的 localStorage(见 local-browser.mjs)
67
+ // 邮箱 + 密码(`type:"email_password"`)端点认这个 type,但要账号**事先设过
68
+ // 密码** —— 实测对一个已注册邮箱回 `33002 账号不存在`,不能当通用路。
26
69
 
27
70
  /** 服务端只认这一套 Unicode;错误码直接用数字,日志里好 grep */
28
71
  export const CAPTCHA_CODE = {
@@ -44,6 +87,33 @@ export function keewoodOf(site) {
44
87
  return site.id === "magicrew" ? "https://i-keewood-service.teamshare.cn" : "https://keewood-service.magicrew.ai"
45
88
  }
46
89
 
90
+ /**
91
+ * 「这条链路在给谁发验证码」→ 服务端要的那几个字段。
92
+ *
93
+ * 手机和邮箱走的是**同一套 captcha 协议**,只有三处不同,全收敛在这里:
94
+ * ① 标识字段:`mobile` + `state_code` vs `email`
95
+ * ② `account_type`:`"mobile"` vs `"email"`
96
+ * ③ 端点:`/v4/user/send-sms` + `/v4/user/login` vs
97
+ * `/v4/user/send-email` + `/v4/user/login-email`
98
+ *
99
+ * ⚠ 邮箱**不带 `state_code`** —— 前端邮箱表单的初始值里就没有这个字段
100
+ * (实测抓到的请求体逐字符是 `{"account_type":"email","email":"…"}`)。
101
+ *
102
+ * @param {{mobile?:string, stateCode?:string, email?:string}} ident
103
+ * @returns {{account_type:"mobile"|"email", mobile?:string, state_code?:string, email?:string}}
104
+ */
105
+ export function identFields(ident = {}) {
106
+ const email = String(ident?.email ?? "").trim()
107
+ if (email) return { account_type: "email", email }
108
+ const mobile = String(ident?.mobile ?? "").replace(/[\s-]/g, "")
109
+ return { account_type: "mobile", mobile, state_code: ident?.stateCode || "+86" }
110
+ }
111
+
112
+ /** 这条链路是手机还是邮箱 —— 决定走哪两个端点 */
113
+ export function identKind(ident = {}) {
114
+ return String(ident?.email ?? "").trim() ? "email" : "mobile"
115
+ }
116
+
47
117
  /**
48
118
  * 拖动角度 → 服务端要的 angle 字符串。
49
119
  *
@@ -109,14 +179,12 @@ async function postJSON(url, body, { headers = {}, timeoutMs = 20_000 } = {}) {
109
179
  return { status: res.status, text, json }
110
180
  }
111
181
 
112
- /** ① 取图。返回 null 表示服务端认为这个号**已经注册**,直接给了 business_token(跳过验证) */
113
- export async function generateCaptcha(site, { mobile, stateCode = "+86" } = {}) {
182
+ /** ① 取图。服务端认为这个号**已经注册**时直接给 business_token(跳过验证) */
183
+ export async function generateCaptcha(site, ident = {}) {
114
184
  const host = keewoodOf(site)
115
- const { json } = await postJSON(`${host}/captcha/generate`, {
116
- account_type: "mobile",
117
- mobile,
118
- state_code: stateCode,
119
- }, { headers: { origin: site.web } })
185
+ const { json } = await postJSON(`${host}/captcha/generate`, identFields(ident), {
186
+ headers: { origin: site.web },
187
+ })
120
188
 
121
189
  const data = json?.data ?? {}
122
190
  if (data.business_token) return { registered: true, businessToken: data.business_token }
@@ -131,16 +199,14 @@ export async function generateCaptcha(site, { mobile, stateCode = "+86" } = {})
131
199
  }
132
200
  }
133
201
 
134
- /** ③ 提交角度 */
135
- export async function verifyCaptcha(site, { token, deg, mobile, stateCode = "+86" }) {
202
+ /** ③ 提交角度(手机 / 邮箱同一套,标识字段由 identFields 决定) */
203
+ export async function verifyCaptcha(site, { token, deg, ...ident }) {
136
204
  const host = keewoodOf(site)
137
205
  const n = randomN()
138
206
  const { json } = await postJSON(`${host}/captcha/verify`, {
139
- account_type: "mobile",
140
207
  token,
141
208
  angle: encodeAngle(deg, n),
142
- mobile,
143
- state_code: stateCode,
209
+ ...identFields(ident),
144
210
  }, { headers: { origin: site.web } })
145
211
 
146
212
  const data = json?.data ?? {}
@@ -149,28 +215,42 @@ export async function verifyCaptcha(site, { token, deg, mobile, stateCode = "+86
149
215
  }
150
216
 
151
217
  /**
152
- * ④ 发短信。businessToken 缺了/假的都会被打回来,所以它必须是真的。
218
+ * ④ 发验证码。businessToken 缺了/假的都会被打回来,所以它必须是真的。
153
219
  *
154
220
  * ⚠ `type` 是 **`"account_login"`**,不是 `"login"`、也不是登录枚举里的 `phone_captcha`。
155
221
  * 填错的表现是「服务异常,请联系管理员」(9999) —— 后端内部炸了,不是参数校验失败,
156
222
  * 所以从错误信息上完全看不出是 type 的问题。这个值是从浏览器真实请求里抓到的:
157
- * {"type":"account_login","phone":"199…","state_code":"+86"}
158
- * ⇒ 别再按直觉改它。
223
+ * 手机 {"type":"account_login","phone":"199…","state_code":"+86"}
224
+ * 邮箱 {"email":"…","type":"account_login"}
225
+ * ⇒ 别再按直觉改它。两站实测:邮箱发 `type:"login"` 同样回 2000「服务异常」。
226
+ *
227
+ * ⚠ 邮箱和手机是**两个端点**:`/v4/user/send-email` vs `/v4/user/send-sms`。
228
+ * 把邮箱发去 send-sms 会 404(`/v4/user/send-sms` 只认 phone)。
159
229
  */
160
- export async function sendSms(site, { phone, stateCode = "+86", businessToken, type = "account_login" }) {
230
+ export async function sendCode(site, ident = {}, { businessToken, type = "account_login" } = {}) {
161
231
  const host = keewoodOf(site)
162
- const { json } = await postJSON(`${host}/v4/user/send-sms`, {
163
- type,
164
- phone,
165
- state_code: stateCode,
166
- }, { headers: { origin: site.web, "X-Captcha-Token": businessToken || "" } })
232
+ const f = identFields(ident)
233
+ const isEmail = f.account_type === "email"
234
+ const path = isEmail ? "/v4/user/send-email" : "/v4/user/send-sms"
235
+ const body = isEmail
236
+ ? { email: f.email, type }
237
+ : { type, phone: f.mobile, state_code: f.state_code }
238
+
239
+ const { json } = await postJSON(`${host}${path}`, body, {
240
+ headers: { origin: site.web, "X-Captcha-Token": businessToken || "" },
241
+ })
167
242
 
168
243
  if (json?.code === CAPTCHA_CODE.OK) return { ok: true }
169
- return { ok: false, message: json?.message ?? "发送短信失败" }
244
+ return { ok: false, message: json?.message ?? "发送验证码失败" }
245
+ }
246
+
247
+ /** 旧签名兼容:手机号发短信 */
248
+ export async function sendSms(site, { phone, stateCode = "+86", businessToken, type = "account_login" }) {
249
+ return sendCode(site, { mobile: phone, stateCode }, { businessToken, type })
170
250
  }
171
251
 
172
252
  /**
173
- * ⑤ 验证码换登录态。
253
+ * ⑤ 验证码换登录态(手机 / 邮箱两个端点)。
174
254
  *
175
255
  * ⚠ `device` 是必须的,而且结构是一层层试出来的:
176
256
  * 缺 device → 「系统异常」(9999)
@@ -179,7 +259,7 @@ export async function sendSms(site, { phone, stateCode = "+86", businessToken, t
179
259
  * { id, name } → 放行,返回 2168「验证码错误」
180
260
  * ⇒ { id: 64 位 hex, name: "web" } 就够。
181
261
  *
182
- * ⚠ `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
262
+ * ⚠ 手机那条的 `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
183
263
  *
184
264
  * { phone, state_code, code, auto_register: true,
185
265
  * type: "phone_captcha", redirect: window.location.href }
@@ -188,29 +268,52 @@ export async function sendSms(site, { phone, stateCode = "+86", businessToken, t
188
268
  * · `redirect` 是**当前页面的 URL**,服务端从它上面取 `?invitation=<码>`。
189
269
  * ⇒ **邀请码是靠 redirect 传的,不是独立字段**。这就是为什么浏览器登录的
190
270
  * URL 必须带 `?invitation=`:不带,新注册的号就没有邀请关系。
271
+ *
272
+ * ⚠ 邮箱那条**验证码字段叫 `captcha`,不叫 `code`**(实测:发 code 回
273
+ * 2000「captcha 字段是必须的」;发 captcha 回 2168「验证码错误」)。
274
+ * 端点也不同:`/v4/user/login-email`,前端调它时会先 `delete("organization-code")`。
275
+ * `auto_register` / `redirect` 前端没传,但我们**照传** —— 实测多这两个字段
276
+ * 服务端不报错(仍回 2168),而新号注册和邀请码都指着它们。
191
277
  */
192
- export async function loginBySmsCode(
278
+ export async function loginByCode(
193
279
  site,
194
- { phone, stateCode = "+86", code, device, autoRegister = true, redirect },
280
+ { code, device, autoRegister = true, redirect, ...ident },
195
281
  ) {
196
282
  const host = keewoodOf(site)
197
- const body = {
198
- type: "phone_captcha",
199
- phone,
200
- state_code: stateCode,
201
- code,
202
- auto_register: autoRegister,
203
- device: device ?? { id: makeDeviceId(), name: "web" },
204
- }
283
+ const f = identFields(ident)
284
+ const isEmail = f.account_type === "email"
285
+
286
+ const body = isEmail
287
+ ? {
288
+ email: f.email,
289
+ captcha: code,
290
+ auto_register: autoRegister,
291
+ device: device ?? { id: makeDeviceId(), name: "web" },
292
+ }
293
+ : {
294
+ type: "phone_captcha",
295
+ phone: f.mobile,
296
+ state_code: f.state_code,
297
+ code,
298
+ auto_register: autoRegister,
299
+ device: device ?? { id: makeDeviceId(), name: "web" },
300
+ }
205
301
  if (redirect) body.redirect = redirect
206
302
 
207
- const { json, status, text } = await postJSON(`${host}/v4/user/login`, body, {
208
- headers: { origin: site.web },
209
- })
303
+ const { json, status, text } = await postJSON(
304
+ `${host}${isEmail ? "/v4/user/login-email" : "/v4/user/login"}`,
305
+ body,
306
+ { headers: { origin: site.web } },
307
+ )
210
308
 
211
309
  return { json, status, text }
212
310
  }
213
311
 
312
+ /** 旧签名兼容:手机号验证码换登录态 */
313
+ export async function loginBySmsCode(site, { phone, stateCode = "+86", ...rest }) {
314
+ return loginByCode(site, { mobile: phone, stateCode, ...rest })
315
+ }
316
+
214
317
  /** 64 位 hex,形状与网页版 cus.device_id 一致 */
215
318
  export function makeDeviceId() {
216
319
  const bytes = new Uint8Array(32)
package/src/headless.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  // 无头登录的编排:一条纯 HTTP 的链路,不开浏览器
2
2
  //
3
- // 取图 → 识图判角度 → 过验证 → 发短信 → (用户填码)→ 换登录态 → 拿组织码
3
+ // 取图 → 识图判角度 → 过验证 → 发验证码 → (用户填码)→ 换登录态 → 拿组织码
4
4
  //
5
- // 每一步都实测过(见 docs/internals.md「无头登录」):
5
+ // **手机号和邮箱走同一条编排** —— 协议、风控、识图全一样,只有「标识字段」
6
+ // 和「发码 / 登录两个端点」不同,那些差异全在 captcha.mjs 里按 identFields 分派。
7
+ //
8
+ // 每一步都实测过(协议逐字段记在 src/captcha.mjs 头部;两站实测记录见
9
+ // CHANGELOG 0.3.17「手机号验证码」与 0.3.28「邮箱验证码 + 已有账号免图」):
6
10
  // · 取图/过验证在 keewood 用户中心,纯 JSON,不需要 cookie 或指纹
7
11
  // · 角度必须「一次算准」:同一 token 试错次数有限,且高频会被拦
8
12
  // · 识图模型是用户在 magpie 里选的那个(settings.json 的 vision)
@@ -14,8 +18,9 @@
14
18
  import {
15
19
  extractCreds,
16
20
  generateCaptcha,
17
- loginBySmsCode,
18
- sendSms,
21
+ identKind,
22
+ loginByCode,
23
+ sendCode,
19
24
  verifyCaptcha,
20
25
  } from "./captcha.mjs"
21
26
  import { decideDegFromImage, readVisionModel } from "./vision.mjs"
@@ -30,14 +35,20 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
30
35
  /**
31
36
  * 过掉旋转验证,拿到 business_token。
32
37
  *
38
+ * 手机和邮箱共用:只有传给服务端的「标识字段」不同(`{mobile, stateCode}` / `{email}`)。
39
+ *
40
+ * @param {object} site 站点对象(要有 id / web)
41
+ * @param {{mobile?:string, stateCode?:string, email?:string}} ident
42
+ * @param {object|null} firstCap 已经取到的那张图(调用方为了让「已注册免图」
43
+ * 这条短路先跑,会自己先调一次 generateCaptcha)—— 传进来就省一次取图
33
44
  * @returns {Promise<{businessToken:string, rounds:number, log:string[]}>}
34
45
  */
35
- export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
46
+ export async function passCaptcha(site, ident = {}, firstCap = null) {
36
47
  const log = []
37
48
  let lastReason = ""
38
49
 
39
50
  for (let round = 1; round <= MAX_CAPTCHA_ROUNDS; round++) {
40
- const cap = await generateCaptcha(site, { mobile, stateCode })
51
+ const cap = round === 1 && firstCap ? firstCap : await generateCaptcha(site, ident)
41
52
  // 已注册的号,服务端直接给 business_token,连图都不用看
42
53
  if (cap.registered) {
43
54
  log.push(`第 ${round} 轮:服务端直接放行(该号已注册)`)
@@ -53,7 +64,7 @@ export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
53
64
  continue
54
65
  }
55
66
 
56
- const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg, mobile, stateCode })
67
+ const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg, ...ident })
57
68
  if (res.ok) {
58
69
  log.push(`第 ${round} 轮:deg=${decided.deg}(钟点 ${decided.clock?.toFixed?.(2) ?? "?"},分歧 ${decided.spread?.toFixed?.(1) ?? 0})→ 通过`)
59
70
  return { businessToken: res.businessToken, rounds: round, log }
@@ -71,45 +82,79 @@ export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
71
82
  }
72
83
 
73
84
  /**
74
- * 第 1 步:发短信。成功即代表「验证已经过了」——服务端把 business_token 当门票。
85
+ * 第 1 步:发验证码(短信 / 邮件)。成功即代表「验证已经过了」——服务端把 business_token 当门票。
86
+ *
87
+ * ⚠ **顺序很重要:先取图,再决定要不要识图模型。**
88
+ *
89
+ * 服务端对**已注册**的号根本不要求过旋转验证 —— `POST /captcha/generate` 直接回
90
+ * `business_token`,连图都不给(实测:2026-10-29,已注册邮箱 `…@qq.com` → 免图;
91
+ * 同一个接口换一个没注册过的邮箱 → 给了 token+image)。
92
+ *
93
+ * 所以「加一个我自己已有的账号」这条最常见的路,**根本不需要识图模型**。
94
+ * 早先把 `readVisionModel()` 的判断放在取图**之前**,等于让没配识图模型的人
95
+ * 连自己已有的账号都加不进来 —— 那是白拦。现在只有确实要给新号过图时才要求它。
96
+ *
97
+ * @throws 需要过图但没配识图模型时,抛带 `code: "no-vision-model"` 的错误,
98
+ * 让调用方去回退浏览器(而不是在这里瞎猜角度)
75
99
  */
76
- export async function startSmsLogin(site, { mobile, stateCode = "+86" } = {}) {
77
- if (!readVisionModel()) {
78
- // 调用方应该先判断这个;走到这里说明判断被绕过了 —— 明确报出来,别静默瞎猜
79
- throw Object.assign(new Error("没有配置识图模型,不能无头过旋转验证"), { code: "no-vision-model" })
100
+ export async function startCodeLogin(site, ident = {}) {
101
+ const kind = identKind(ident)
102
+ const what = kind === "email" ? "邮件" : "短信"
103
+
104
+ // ① 先探一次。这一步是纯 JSON、不需要模型,也**不会真的发码**。
105
+ const first = await generateCaptcha(site, ident)
106
+
107
+ let businessToken
108
+ let rounds
109
+ let log
110
+
111
+ if (first.registered) {
112
+ // ⚡ 已注册 → 免图直接发码。识图模型在这条路上完全用不到。
113
+ businessToken = first.businessToken
114
+ rounds = 0
115
+ log = ["服务端认为该号已注册 → 免旋转验证"]
116
+ } else {
117
+ // ② 新号必须过图:这时才需要识图模型
118
+ if (!readVisionModel()) {
119
+ throw Object.assign(new Error("这个号还没注册,注册要过旋转验证,而你没配识图模型"), {
120
+ code: "no-vision-model",
121
+ })
122
+ }
123
+ const r = await passCaptcha(site, ident, first)
124
+ businessToken = r.businessToken
125
+ rounds = r.rounds
126
+ log = r.log
80
127
  }
81
128
 
82
- const { businessToken, rounds, log } = await passCaptcha(site, { mobile, stateCode })
83
- const sms = await sendSms(site, { phone: mobile, stateCode, businessToken })
84
- if (!sms.ok) {
85
- throw Object.assign(new Error(`发短信失败:${sms.message}`), { captchaLog: log })
129
+ const sent = await sendCode(site, ident, { businessToken })
130
+ if (!sent.ok) {
131
+ throw Object.assign(new Error(`发${what}失败:${sent.message}`), { captchaLog: log })
86
132
  }
87
- return { businessToken, rounds, log }
133
+ return { businessToken, rounds, log, skippedCaptcha: Boolean(first.registered) }
134
+ }
135
+
136
+ /** 旧签名兼容:手机号发短信 */
137
+ export async function startSmsLogin(site, { mobile, stateCode = "+86" } = {}) {
138
+ return startCodeLogin(site, { mobile, stateCode })
88
139
  }
89
140
 
90
141
  /**
91
142
  * 第 2 步:验证码换登录态,并补齐组织码。
92
143
  *
93
- * ⚠ 登录响应里 jwt / 组织码的**确切形状没实测过**(手上没有能收短信的号,
94
- * 只能验到 2168「验证码错误」)。所以:
144
+ * ⚠ 登录响应里 jwt / 组织码的**确切形状没实测过**(手上没有能收短信/收信的号,
145
+ * 只能验到 2168「验证码错误」——手机和邮箱都一样)。所以:
95
146
  * · jwt 用 extractCreds 从响应里按特征找(以 eyJ 开头的三段式)
96
147
  * · 组织码找不着就调 `/api/v1/aggregation/login-data`(带 authorization 头,
97
148
  * 服务端会回这个账号所在的组织列表)—— 这条路径是文档里查得到的
98
149
  */
99
- export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, device, redirect }) {
100
- const { json, status, text } = await loginBySmsCode(site, {
101
- phone: mobile,
102
- stateCode,
103
- code,
104
- device,
105
- autoRegister: true,
106
- redirect,
107
- })
150
+ export async function finishCodeLogin(site, { code, device, redirect, ...ident } = {}) {
151
+ const { json, status, text } = await loginByCode(site, { code, device, redirect, ...ident })
108
152
  if (!json) {
109
153
  throw new Error(`登录接口回了非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`)
110
154
  }
111
155
  if (json.code !== 1000) {
112
- // 2168 是验证码错;其余原样透出,别把它吞成「登录失败」
156
+ // 2168(手机 / 邮箱都是它)= 验证码错;2167 = 短信验证码无效或已过期;
157
+ // 其余原样透出,别把它吞成「登录失败」
113
158
  throw Object.assign(new Error(`登录失败:${json.message ?? json.code}`), {
114
159
  code: json.code,
115
160
  raw: json,
@@ -140,6 +185,11 @@ export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, de
140
185
  return { jwt, org: orgs[0], orgs, raw: json }
141
186
  }
142
187
 
188
+ /** 旧签名兼容:手机号验证码换登录态 */
189
+ export async function finishSmsLogin(site, { mobile, stateCode = "+86", ...rest } = {}) {
190
+ return finishCodeLogin(site, { mobile, stateCode, ...rest })
191
+ }
192
+
143
193
  /**
144
194
  * 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
145
195
  *