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 原先担心的「密码出网」更进一步——高频重登不仅暴露密码,
还在持续堆积可用的会话。
|