File size: 23,708 Bytes
1a72a9a
 
 
 
 
 
3df9d54
1a72a9a
 
 
 
 
3df9d54
1a72a9a
 
 
3df9d54
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
 
 
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
 
 
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
 
1a72a9a
 
 
 
 
 
 
3df9d54
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
 
 
1a72a9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df9d54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
# DeepSeek Token 刷新机制 逆向研究报告

> 研究目标:确认 ds2api 目前的「刷新 token」是否等价于「重新登录」,以及
> DeepSeek 官方客户端是否存在真正的 token 刷新(refresh / rotate)通道。
> 研究时间:2026-10-17
> 证据来源:`chat.deepseek.com (1)/`(Web 端完整抓包)、根目录若干 `.har`、`deepseek.apk`(Android 2.2.2 / 2.3.3)
> 状态:**调研结论已明确**;阶段 1 已落地(§9),并有单账号线上实测(§9.5)

---

## 一、结论摘要(TL;DR)

1. **改动前的「刷新 token」等于「重新登录」。** `Resolver.RefreshToken()`
   清空本地 token 后走 `loginAndPersist()` → `Client.Login()` →
   `POST /api/v0/users/login`,也就是**每次都把邮箱/手机号 + 明文密码重新提交一次**。
   定时刷新(默认 6h)也一样走这条路径。
   (已改:现在是「无密码探针优先,明确失效才登录」,见 §9。)

2. **DeepSeek 没有「access_token + refresh_token」那套机制。** 我们在
   - Web 端 JS bundle(`main.7d19d7e901.js`)
   - Android APK 的 `classes.dex` / `classes2.dex` / `classes3.dex`

   三个产物里做了端点全量枚举(APK 侧提取到 206 条 URL 字符串),
   **不存在** `/refresh`、`/refresh_token`、`/token/refresh`、`/auth_token/refresh`
   之类的端点。登录接口下发的 token 是**不透明字符串(opaque,64 字符 base64),不是 JWT**,
   客户端无法本地判断过期时间。

3. **但官方客户端确实「不重新登录就拿到新 token」**,有两条真实通道:

   | 通道 | 端点 | 是否需要密码 | 观察到的行为 |
   |------|------|--------------|--------------|
   | 会话回填(主线) | `GET /api/v0/users/current` | ❌ 不需要 | 响应 `data.biz_data.token` 就是当前 token;Web 端拿到后**直接写回本地存储** |
   | token 轮换(支线) | `POST /api/v0/users/auth_token/check_device` | ❌ 不需要 | 响应 `data.biz_data.rotate`,结构为 `{"token": "..."}`;**抓包里始终是 `null`** |

   所以「更优雅的刷新」是可行的:**先试 `users/current` 回填,失败再回退到密码登录**,
   而不是一上来就重新登录。

4. **另外发现一个副作用风险**:当前每次刷新都是一次真实登录,而登录要过数美设备风控
   (`RISK_DEVICE_DETECTED`)与密码校验。项目里 `handleBannedAccount()` 和
   `loginWithDevice()` 的「风控时重签 device_id」逻辑,本质上就是在给这条高频登录路径擦屁股。
   降低登录频次本身就能减少被风控/锁号的面。

---

## 二、现状:刷新 == 重新登录(代码事实)

> 本节记录的是**改动前**的代码事实(行号同当时版本)。
> 改动后的实现见 §9:`RefreshToken()` 现在先走无密码探针,密码登录只是兜底。

### 2.1 调用链

```
Resolver.RefreshToken(ctx, a)                       internal/auth/request.go:201
  ├─ Store.UpdateAccountToken(accountID, "")        // 先清空
  ├─ a.Account.Token = ""
  └─ loginAndPersist(ctx, a)                       internal/auth/request.go:153
       ├─ r.Login(ctx, a.Account)                  // ← 注入的 LoginFunc
       └─ Store.UpdateAccountToken(accountID, token)
```

`r.Login` 由 `Client.Login` 实现:

```
Client.Login(ctx, acc)                              internal/deepseek/client/client_auth.go:25
  └─ loginWithDevice(ctx, acc, regenerated=false)
       ├─ persistedDeviceID(acc) / issueDeviceID(ctx, acc)   // 数美签发 device_id
       └─ POST https://chat.deepseek.com/api/v0/users/login   // internal/deepseek/protocol/constants.go:12
            payload: { password, device_id, os, email|mobile(+area_code) }
            ← data.biz_data.user.token
```

即:**刷新 = 提交密码 = 完整登录**。没有任何「拿旧 token 换新 token」的动作。

### 2.2 刷新被触发的 5 个错误路径

`shouldAttemptRefresh()`(`internal/deepseek/client/client_auth.go:320`)命中后都会调
`c.Auth.RefreshToken()`,从而走完整登录:

| 位置 | 场景 |
|------|------|
| `internal/deepseek/client/client_auth.go:145` | 创建会话失败且判定 token 失效 |
| `internal/deepseek/client/client_auth.go:209` | 取 PoW challenge 失败且判定 token 失效 |
| `internal/deepseek/client/client_session.go:90` | 拉取会话列表失败 |
| `internal/deepseek/client/client_session_delete.go:65` | 删除会话失败 |
| `internal/deepseek/client/client_upload.go:161` | 上传文件失败 |

### 2.3 定时刷新也是重新登录

`ensureManagedToken()`(`internal/auth/request.go:300`)在 `shouldForceRefresh()`
(`internal/auth/request.go:314`)到期时同样调 `loginAndPersist()`。

- 间隔配置:`runtime.token_refresh_interval_hours`,默认 **6h**
  (`internal/config/store_accessors.go:137`、`config.example.json:67`)
- 带 ±10% 抖动 + 0~30min 随机偏移,避免同刻集中刷新

也就是说:**只要服务跑满 6 小时,每个托管账号就必然重新登录一次**,哪怕 token 完全没坏。

> 已修复:本节描述的是调研时的实现。现在到期只做一次无密码探针复核,
> 只有服务端明确拒绝会话才回退到密码登录,见 §9。

### 2.4 `MarkTokenInvalid` 也是清空

`internal/auth/request.go:214` 把 token 清空落盘,下一次 `ensureManagedToken` 会再登录一次。

---

## 三、这样做的代价(为什么不优雅)

1. **密码高频出网。** 每 6h × 每账号一次的明文密码提交,纯属没必要的暴露面。
   一旦某次请求被中间人/代理记录,损失的是账号主凭证,而不是一个可吊销的会话。
2. **每次刷新都要过一次风控。** `client_auth.go` 已被迫实现
   「`RISK_DEVICE_DETECTED` → 重新签发数美 device_id → 重试一次」;
   登录频次越高,触发风控与设备指纹漂移的概率越大。
3. **可能直接锁号。** `USER_IS_BANNED` 会让项目自动把账号置为 banned
   (`internal/auth/request.go:171` `handleBannedAccount`),
   而频繁登录正是被风控盯上的典型特征之一。
4. **多一次网络 + 多依赖一个签发服务(数美)。** 刷新本该是一次轻量 GET,
   现在变成「数美签发 device_id + 登录 + 落盘」三步,其中任何一步挂了,刷新就失败。
5. **与官方客户端行为不一致。** 官方 Web/Android 都不会为了续期去重新登录(见 §4)。

---

## 四、官方客户端实际怎么「刷新」

### 4.1 先确认:没有任何 refresh 端点

| 产物 | 枚举方式 | 结果 |
|------|----------|------|
| Web | `fe-static.deepseek.com/chat/static/*.js` 里 `"/api/v0/..."` 字面量全提取 | 44 个端点,无 refresh |
| APK | 解析 `classes{,2,3}.dex` 字符串池,过滤含 `/api/` 或 `http` 的字符串 | 206 条,无 refresh |

APK 侧 auth 相关端点全集(节选):

```
/api/v0/users/login
/api/v0/users/current                      ← 会话回填
/api/v0/users/auth_token/check_device      ← rotate 通道
/api/v0/users/logout
/api/v0/users/logout_all_sessions
/api/v0/users/login_by_mobile_sms
/api/v0/users/oauth/{wechat,google,apple}/login
/api/v0/users/oauth/get_token
/api/v0/users/mfa/{verify_signature,trusted_device/register,trusted_device/verify,...}
```

> ⚠️ 排除一个**假线索**:APK 里能 grep 到 `Refreshing token for ` /
> `Refreshing token failed for ` / `Can not add or refresh token`,这些字符串来自随包的
> **Ktor 客户端库**(`io.ktor.client.plugins.auth.BearerAuthProvider`),
> 不是 DeepSeek 自己的 token 刷新逻辑,不能当作「存在 refresh 流程」的证据。

### 4.2 主线:`GET /api/v0/users/current` 回填 token(Web 端已实现)

抓包证据(`chat.deepseek.com_2026_08_01_11_25_19.har`,Android 客户端启动):

```http
GET /api/v0/users/current
authorization: Bearer <TOKEN>

200 OK
{"code":0,"data":{"biz_code":0,"biz_data":{
   "id":"<user_id>",
   "token":"<TOKEN>",            ← 和请求头里带的是同一个
   "mobile_number":"152******03", ...}}}
```

紧接着的 `POST /users/login` 返回的 `user.token` **和第 49 条的 token 完全相同**。
`gator.volces.com_2026_09_12_20_19_40.har`(第 66 / 70 条)观察到同样现象。

Web 端 JS 里这个动作有明确实现(`main.7d19d7e901.js`),启动时的伪代码:

```js
// 启动引导
if (!getPersistedCurrentUser().token || isIOS) { navigateToSignIn(); return; }

const localToken = getPersistedCurrentUser().token;
getCurrentUser({ http, baseUrl: "/api/v0/users" })     // → GET /api/v0/users/current
  .then(({ data: { user } }) => {
     // 只有本地 token 没被并发改写,才应用服务端结果
     if (getPersistedCurrentUser().token === localToken) applySession(user);
  })
  .catch(() => tracker.error({ name: "getCurrentFailed",
                               message: "刷新当前用户状态失败" }));

// applySession(user):
//   user.token && setPersistedCurrentUser(user.token, user.id)   ← 采纳服务端下发的 token
```

关键点:

- **注释级事实**:官方前端自己把这一步叫做「刷新当前用户状态」(tracker 消息原文)。
- `setPersistedCurrentUser(user.token, user.id)` 会**用服务端返回的 token 覆盖本地**。
  也就是说,只要服务端在 `biz_data.token` 里给了新值,客户端就自动接班,
  全程不需要密码。
- 失败时的处理是**跳转到登录页让用户手动登录**(`wE()` 清 token + `navigateToSignIn`),
  不是自动重新登录。

### 4.3 支线:`POST /api/v0/users/auth_token/check_device` 的 `rotate`

Web 抓包(`chat.deepseek.com (1)/2222.har` 第 61 条):

```http
POST /api/v0/users/auth_token/check_device
authorization: Bearer <TOKEN>
{"device_id":"7d3c1bcd-...","device_model":""}

200 OK
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"rotate":null}}}
```

Web 端 JS 对它的处理**只看 `code` / `biz_code` 是否成功,完全忽略 `rotate`**
(`disableChallengeRetry`、`skipGlobalAuthEffects`,纯粹当「上报设备」用)。
所以**光看 Web 端会得出「rotate 没用」的错误结论**。

Android 端不是这样。APK 里有显式的模型类(`classes.dex` 字符串池):

| 类 | 说明 |
|----|------|
| `com.deepseek.chat.network.auth.model.users.CheckDeviceResponseBizData` | 字段 `rotate` |
| `com.deepseek.chat.network.auth.model.users.CheckDeviceRotateData` | 字段 `token`,`toString` 字面量为 `CheckDeviceRotateData(token=` |

即服务端可以为 `rotate` 下发 `{"token": "..."}`,客户端已按「有 token 就说明要换 token」
的数据结构建好了模型。

> **未完成的一步**:Android 侧「拿到 `rotate.token` 之后是否落盘替换本地 token」的
> 业务侧追踪被打断(时间原因),只确认到**模型层已完整建模**,见 §7。

### 4.4 token 语义(决定了「刷新」能是什么样)

综合三次抓包:

| 观察 | 结论 |
|------|------|
| 形如 `<44 字符随机段><20 字符随机段>`(含 `+` `/`,共 64 字符) | 64 字符 base64,**不是 JWT**(无 `.` 分段,无 `exp` 可解析) |
| 同一账号 + 同一 `device_id` 反复登录 | **返回同一个 token** · ⚠️ 该行已被 §9.5 的线上实测推翻 |
| `users/current` 响应 | 回显**同一个 token**(线上实测仍成立) |
| 不同平台 / 不同 `device_id` | token 不同(`os=web` 与 `os=android` 互不相同) |

所以 DeepSeek 是「**服务端长期会话 token,按设备绑定**」模型,
不是短效 access token。这意味着:

- 没有「token 快过期了所以必须刷新」这回事 → **定时 6h 强制重登在语义上没有必要**;
- 真正需要处理的只有两类事件:
  1. **token 被服务端吊销/轮换** → `users/current` 可能回填到新值,或直接报鉴权失败;
  2. **会话彻底死了** → 才需要密码登录。

---

## 五、建议方案

### 5.1 目标

- 绝大多数「刷新」用**无密码**通道完成;
- 密码登录只作为**最终兜底**;
- 定时任务不再触发密码登录。

### 5.2 分阶段实现

**阶段 1(低风险,收益最大):新增 `GET /api/v0/users/current` 回填 —— ✅ 已实现(见 §9)**

- 在 `dsprotocol` 增加 `DeepSeekCurrentUserURL = ".../api/v0/users/current"`。
- `Client` 新增 `RefreshSessionToken(ctx, a) (string, error)`:
  带 `Bearer a.DeepSeekToken`(或持久化 token)发 GET,取 `data.biz_data.token`;
  非空则 `Store.UpdateAccountToken()` 落盘并回写 `a.DeepSeekToken`。
- `Resolver.RefreshToken()` 改为:**先调用该通道**,成功即返回;
  失败(网络/401/biz 错误)才 fallback 到现有 `loginAndPersist()`。
- `shouldForceRefresh()` 到期时同样走这条轻量路径。

  收益:把默认 6h 的强制重登从「必发密码」变成「一次 GET」,风险面直接降一个量级。

**阶段 2(可选):接入 `check_device` 的 rotate**

- `POST /api/v0/users/auth_token/check_device`,body
  `{"device_id": <acc.DeviceID>, "device_model": ""}`。
- 若 `data.biz_data.rotate` 非 null 且带 `token`,落盘替换。
- 注意:`DeviceID` 为空(用户直接填 token、没填 device_id)时**不要调用**,
  否则等于主动上报一个可疑设备身份。
- 该通道在全部抓包里都是 `null`,属于「有更好、没有也不影响」的增强。

**阶段 3:明确降级顺序,收敛重试**

建议把认证失败的处置顺序固定为:

```
users/current 回填  →  check_device rotate  →  密码登录  →  切换账号
```

并且**每次请求生命周期内每级最多触发一次**(现有 `refreshed` 布尔量已经做了雏形,
建议随新通道扩成枚举/计数,避免多级连环重试放大延迟)。

### 5.3 不建议做的事

- ❌ 不建议靠缩短 `token_refresh_interval_hours` 来「保持新鲜」——只会加剧重登。
- ❌ 不建议把 `MarkTokenInvalid()` 之后的路径也改成必然登录;应统一走 §5.2 的降级链。

---

## 六、与现有文档的关系

- `security_best_practices_report.md:171` 曾建议「定期刷新 token(已有
  `TokenRefreshIntervalHours` 配置)」。本文确认该配置**旧语义是定期重新登录**;
  该条目已随 §9 的改动同步为「定期做无密码探针复核」。
- `docs/web-endpoint-upgrade.md` 记录的是登录协议的数美风控细节,与本文互补:
  那条链路越少走越好,正是本文的动机。

---

## 七、未完成 / 待验证(重要)

下列项目**没有闭环**,后续要补时从这里接:

1. **Android 是否真的采纳 `rotate.token`。**
   已确认:`CheckDeviceResponseBizData.rotate` 与 `CheckDeviceRotateData.token`
   两个模型类齐全,反序列化器(`Ldq2;` / `Lgq2;`)齐全;
   业务侧「读到 rotate 后写入本地 token」的调用点追踪中断在 `Lma0;`,
   **未形成完整证据链**。
   续做法:继续用 `classes.dex` 方法级交叉引用往上追,
   或等 jadx 全量反编译完成后(本次因机器只剩 ~2GB 提交内存、jadx 反复 OOM,
   只能小堆跑,最终未跑完)直接读 `com.deepseek.chat.network.auth` 包下的 UseCase / Repository。

2. **`rotate` 什么时候非 null。**
   三次抓包(含 Web 首次登录后、App 启动、长时间使用后)全部为 `null`。
   推测与「设备指纹变更 / 会话被顶号 / 服务端主动轮换」有关,**未验证**。

3. **`users/current` 是否会返回「与请求不同的 token」。**
   所有抓包中 `response.token == request token`。若永远相同,
   则阶段 1 的价值是「用一次 GET 确认会话有效 + 拿到落盘值」,
   而不是「换到新 token」——仍然比重新登录好,但收益要按这个预期描述。

4. **未做任何线上实测。**
   抓包里含真实账号的密码与 token,本文**已全部脱敏**;
   也没有用这些凭证去实际登录或验证端点,
   因为这会真实触发数美风控、可能影响该账号状态。

---

## 八、附录:证据与复现

### 8.1 关键证据位置

| 证据 | 位置 |
|------|------|
| `users/current` 回填 token(Android 启动) | `chat.deepseek.com_2026_08_01_11_25_19.har` 第 49、50 条 |
| `users/current` 回填 token(另一轮) | `gator.volces.com_2026_09_12_20_19_40.har` 第 66、70 条 |
| `users/current` 回填 token(Web) | `chat.deepseek.com_2026_07_14_20_56_28.har` 第 15 条 |
| `check_device` 请求/响应、`rotate:null` | `chat.deepseek.com (1)/2222.har` 第 61 条 |
| Web 端回填逻辑、`刷新当前用户状态失败` | `chat.deepseek.com (1)/fe-static.deepseek.com/chat/static/main.7d19d7e901.js` |
| Web 端忽略 `rotate` | 同上,`/auth_token/check_device` 调用点 |
| Android rotate 模型类 | `deepseek.apk` → `classes.dex` 字符串池 |

### 8.2 复现命令(DEX 串/交叉引用)

```powershell
# 1) 解包 APK(只解 dex 与 lib 即可,不需要资源)
Expand-Archive -LiteralPath deepseek.apk -DestinationPath $env:TEMP\apkext -Force

# 2) 提取所有 URL / 端点字符串(本次得到 206 条,无 refresh 端点)
#    见 §8.3 的脚本思路:对 *.dex 做 printable-run 提取 + 过滤 '/api/' | 'http'

# 3) 定位含某字符串的类与方法
#    解析 DEX 头 → string_ids / method_ids / class_defs / class_data_item,
#    扫 code_item 的 const-string(0x1a) / const-string/jumbo(0x1b) 操作数

# 4) 定位 cross-reference(谁构造了 CheckDeviceRotateData)
#    按 opcode 宽度表遍历 code_item,命中 invoke-*(0x6e-0x72,0x74-0x78)
#    的 method_ids 索引是否指向目标类
```

反编译 APK 的建议(避免踩本机内存坑):

```powershell
# jadx.bat 的 APP_HOME 解析有 bug(会去 D:\lib\... 找 jar),直接用 java 调用:
java -Xms64m -Xmx1100m -XX:MaxMetaspaceSize=320m -XX:ReservedCodeCacheSize=96m `
     -XX:+UseSerialGC -XX:+IgnoreUnrecognizedVMOptions `
     -cp "D:\apktool\jadx-1.5.3-all.jar" jadx.cli.JadxCLI `
     -d "$env:TEMP\apk_jadx" --no-res -j 2 "deepseek.apk"
```

> 注意:`deepseek.apk` 21MB、`classes.dex` 12MB、`defpackage` 下 8500+ 混淆类,
> 全量反编译约需 1.5–2GB 堆 + 3GB 提交内存。内存不足时 jadx 会报
> `Native memory allocation (mmap) failed`,此时要么加页面文件,要么只做 §8.2 的
> 定点 DEX 解析(本次结论主要靠后者得到)。

---

## 九、实现落地(阶段 1)

调研结论是「DeepSeek 没有可刷新的对象,只有失效后的恢复」。按这个结论落地后,
「刷新 token」从「必发密码」变成「先免费探针、明确失效才登录」。

### 9.1 改动清单

| 文件 | 内容 |
|------|------|
| `internal/deepseek/protocol/constants.go` | 新增 `DeepSeekCurrentUserURL` |
| `internal/deepseek/client/client_user_session.go` | 新增 `RefreshSessionToken()`:带现有 token GET `users/current`,取 `data.biz_data.token`(兼容 `biz_data.user.token`) |
| `internal/auth/request.go` | 新增 `RefreshSessionFunc` 注入点与 `ErrSessionExpired`;`RefreshToken()` / `ensureManagedToken()` 改为「探针优先」 |
| `internal/server/router.go` | 把 `Client.RefreshSessionToken` 注入 `Resolver.RefreshSession` |

### 9.2 三种探针结果,三种处置

判定刻意收窄,避免把限流、风控、临时故障误判成「必须重新登录」:

| 探针结果 | 判定依据 | 定时路径(§2.3) | 失败恢复路径(§2.2) |
|----------|----------|------------------|----------------------|
| 会话有效,回显同一 token | `status=200`、`code=0`、`biz_code=0` 且有 token | 只更新体检时间戳,不写盘 | 直接返回成功,重试原请求 |
| 服务端轮换了 token | 同上,但 token 与本地不同 | 落盘并采纳新 token | 落盘并采纳新 token |
| 会话被明确拒绝 | `401`/`403` 或 `code`/`biz_code` ∈ {40001, 40002, 40003} | 清空 token → 密码登录 | 清空 token → 密码登录 |
| 无法判定 | 网络错误、5xx、未知 biz_code、响应缺 token | **保留现有 token,不登录** | 保留 token,仍兜底密码登录(历史行为) |

两个调用点策略不同的原因:

- **定时路径**手里是一个还能用的 token,「一次网络抖动就提交密码」是净损失,
  所以探测失败宁可什么都不做,等下一个请求用失败路径去处理。
- **失败恢复路径**是请求已经吃到鉴权类错误、正在重试,宁可多登录一次也不能把
  本来可恢复的请求判死;探针的作用是挡掉 `isTokenInvalid()` 的宽匹配误判
  (消息里出现 `token` / `login` 就算鉴权失败)。

### 9.3 行为变化

| 场景 | 改动前 | 改动后 |
|------|--------|--------|
| 跑满 6h(默认间隔) | 每账号一次密码登录 + 数美风控 | 一次 `GET users/current`,通常不产生任何写盘 |
| 请求被误判为鉴权失败 | 清空 token + 密码登录 | 探针确认有效即继续,不登录 |
| token 真的被吊销 | 密码登录 | 探针 401 → 密码登录(同上,但只在真失效时发生) |
| 探针未注入(测试/自定义装配) | — | 保持历史行为:直接密码登录 |

### 9.4 本次没做的事

- **阶段 2(`check_device` 的 rotate)**:抓包里始终 `null`,Android 侧证据链未闭合
  (§7.1),收益不确定,暂不接入。
- **阶段 3 的重试计数**:现有 `refreshed` 布尔量仍按「每个请求生命周期每级最多一次」
  工作,未扩成枚举,等阶段 2 接入时再一并处理。
- 保留 `token_refresh_interval_hours` 配置与默认 6h,语义变为「会话体检间隔」;
  想彻底关掉可以设为 0(`shouldForceRefresh` 直接返回 false)。

### 9.5 线上实测(2026-10-18,单账号单设备)

用一个临时账号、固定 `device_id`、android 平台直接跑新代码,结果如下:

| 检查项 | 实测结果 |
|--------|----------|
| `GET users/current` 回填 | 连续 3 次全部 `200`,返回的 token 与登录 token **完全一致** |
| 探针耗时 | 约 150–165 ms(对比:一次密码登录要多走数美 + 登录两步) |
| 用无效 token 打探针 | `HTTP 200` + `code=40003` + `msg="Authorization Failed (invalid token)"` |
| 失效判定 | 被正确归类为「会话失效」而不是「无法判定」 |
| 刷新链(有效 token) | `RefreshToken` 成功,密码登录次数 **0** |
| 刷新链(失效 token) | 探针失败 → 清空 → 密码登录 **1** 次 → 恢复 |

两个推论,都影响本文之前的判断:

1. **鉴权失败不一定走 HTTP 401。** 实测是 `HTTP 200 + code=40003`。
   所以「只按状态码判断」的探针会漏判,`sessionRejectedStatus()` 必须同时看
   `code`/`biz_code`(这也解释了为什么不能直接复用宽匹配的 `isTokenInvalid()`:
   那个函数还会把消息里带 `token` 字样的非鉴权错误一起算进来)。
2. **§4.4 里「同一 device_id 反复登录返回同一个 token」这一行是错的。**
   实测每次密码登录都会下发**新的** token,而**旧 token 继续有效**
   (重新登录后再拿旧 token 打 `users/current` 仍 `200`)。
   也就是说:每次重新登录都在账号上多留下一个仍然可用的长期会话凭证。
   这比 §3 原先担心的「密码出网」更进一步——高频重登不仅暴露密码,
   还在持续堆积可用的会话。