File size: 38,120 Bytes
96cd77a | 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 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 | # DeepSeek Android 客户端逆向研究报告
> 研究对象:`https://download.deepseek.com/apk/deepseek.apk`(官方 Android 客户端)
> 包名:`com.deepseek.chat` | 版本:`2.6.2`(versionCode 281)
> 样本时间:APK `Last-Modified: 2026-10-13`,本次分析时间 2026-10-19
> 样本校验:
> - `SHA-256: 1468cae414245e6bd447b0ae79c277958a6da1c25f4e4798ffc04e42127fa14a`
> - `MD5: 784159bc8c1e161d80b72377f076814c`
> - 大小:`22,217,501` 字节
>
> 研究工具:jadx 1.5.3(Java 反编译)、apktool 2.11.1(资源/smali)、Ghidra 12.1.3 + ghidra-mcp(原生库分析)、OpenJDK 21/25
> 对照资料:本仓库 `deepseek-recall-research.md`(Web 端 SSE 撤回机制)、`docs/web-endpoint-upgrade.md`、`docs/shumei-device-id-research.md`
---
## 0. 摘要(TL;DR)
本次逆向的目标是弄清三件事:**消息如何存储、消息如何被撤回(内容过滤)、以及“修改/重新生成次数上限”的机制和“复制粘贴重发绕过”的行为**。核心结论:
1. **消息存储是“服务端权威 + 本地 WCDB 缓存”的双层结构。**
本地是腾讯 WCDB(SQLite 内核)数据库 `deepseek_chat_<uid>.db`,按会话分表(`chat_session_messages_<sessionId>`),字段与 Web 端 SSE 的 `response/AppStaticMessage` 模型一一对应。**未发现应用层设置数据库加密密钥**,即本地缓存基本是明文 SQLite。服务端才是持久化权威,`/api/v0/chat/history_messages` 拉取的就是服务端已落库(可能已被撤回改写)的消息。
2. **撤回(内容过滤)仍是“模式 B:先流式原文、后覆盖替换”。**
客户端的 `AppStaticMessage` 状态机包含 `WIP / FINISHED / INCOMPLETE / INTERRUPTED / CONTENT_FILTER`,片段类型包含 `THINK / RESPONSE / SEARCH / TOOL_SEARCH / TOOL_OPEN / TEMPLATE_RESPONSE`(TIP 走独立的 `tips` 字段)。撤回时服务端通过 SSE JSON-Patch 下发 `status=CONTENT_FILTER`、`ban_edit=true`、`ban_regenerate=true`,并把 `fragments` 整体覆盖为 `TEMPLATE_RESPONSE`。客户端只是“照单全收”,因此 App 内**没有**防撤回能力。
3. **“修改/重新生成次数上限”是服务端配额,客户端只负责展示。**
`ModelConfig`(`com.deepseek.chat.settings.ModelConfig`)中携带 `edit_quota`、`regenerate_quota`、`regenerate_options` 三个由服务端下发的字段;客户端把它们读进来,在消息操作栏(“修改 / 重新生成”)判断是否已超额,超额时弹出 `edit_quota_exceed_toast`(“修改输入次数超过限制”)或 `regenerate_quota_exceed_toast`(“重新生成次数超过限制”)。**反编译代码中没有任何“本地计数 + 自增”的逻辑**——次数完全由服务端记录并判定。
4. **“复制粘贴重发绕过并顶替上一条”的本质:换了一个 endpoint,而不是突破了配额。**
“修改”走 `POST /api/v0/chat/edit_message`,“重新生成”走 `POST /api/v0/chat/regenerate`,二者都针对**已存在的消息**(`parent_message_id`/上下文),因而受 `edit_quota`/`regenerate_quota` 约束;而“复制文本重新发送”走的是 `POST /api/v0/chat/completion`,这会创建**一条全新的用户消息**(新 `message_id`、新父链),根本不进入 edit/regenerate 配额。新消息成为会话的最新叶节点,旧的那条(已撤回/失败)在 UI 上被“顶替/越过”。这不是客户端的漏洞,而是**动作语义与配额作用域不同**导致的必然结果。
> 说明:本报告只做**静态逆向分析**与机制解释,不包含可直接执行的绕过脚本,也不包含对线上服务的压测/滥用操作。
---
## 1. 研究方法与工具链
### 1.1 环境
沙箱内已具备 OpenJDK 25。工作流使用以下工具(均可从 GitHub Release 经 `gh-proxy.com` 镜像加速下载):
| 工具 | 版本 | 用途 | 获取方式 |
|------|------|------|----------|
| jadx | 1.5.3 | DEX→Java 反编译(主力) | `https://gh-proxy.com/https://github.com/skylot/jadx/releases/...` |
| apktool | 2.11.1 | 资源解码、smali 反汇编、manifest | `https://gh-proxy.com/https://github.com/iBotPeaches/Apktool/releases/...` |
| Ghidra | 12.1.3 | 原生 `.so` 分析(PoW/加密/风控) | `https://gh-proxy.com/https://github.com/NationalSecurityAgency/ghidra/releases/...` |
| ghidra-mcp | 7.0.0 | 用 MCP/REST 驱动 Ghidra headless 分析 | `ghidra-mcp`(bethington)一键脚本 |
> 关于 `Ghidra (2).zip`:该压缩包实际内容是 **ghidra-mcp 的一键环境搭建脚本**(`setup-ghidra-mcp.sh` / `start-ghidra-mcp.sh` / `README-ghidra-mcp.md`),并不内含 Ghidra 本体。脚本会安装 JDK 21、Maven、下载 Ghidra 12.1.3(约 500MB)、构建并部署 ghidra-mcp headless 服务(默认 `127.0.0.1:8089`)。注意该脚本会重写 `PATH`(写 `/etc/profile.d/ghidra-env.sh`),在与既有 JDK 共存的沙箱里会因覆盖 `PATH` 导致 `head` 等基础命令找不到,故本次改为**手动安装 Ghidra**(`curl` 下载 release zip → `unzip` → `analyzeHeadless`)。
### 1.2 分析流程
```
deepseek.apk
├─ apktool d → AndroidManifest.xml / res/ / smali (资源与字符串证据)
├─ jadx -d → 14152 个 .java(随 R8 混淆为 defpackage/*)
└─ unzip lib/ → arm64-v8a/*.so(Ghidra 分析对象)
```
R8 把大量 Kotlin 类合并进 `defpackage` 下的“上帝类”,但**字符串常量、资源 ID、字段名、JSON 键名均未被抹除**,这为还原协议与数据模型提供了可靠锚点。本报告所有结论均以“文件 + 行号 + 代码片段”为证据,见附录 A。
---
## 2. 样本概况
### 2.1 应用信息(来自 `AndroidManifest.xml` 与 `apktool.yml`)
| 项 | 值 |
|----|----|
| 包名 | `com.deepseek.chat` |
| versionName | `2.6.2` |
| versionCode | `281` |
| minSdk / targetSdk | `23` / `36` |
| 编译 SDK | `36`(Android 16) |
| Application 类 | `com.deepseek.chat.App` |
| 网络安全配置 | `network_security_config`,`usesCleartextTraffic="false"` |
| 备份 | `allowBackup="false"` |
关键组件:
- `com.deepseek.chat.MainActivity`(LAUNCHER;App Links:`dpsk://chat/new`、`dpsk://chat/share`、`com.deepseek.chat://authorized_android`)
- `com.deepseek.chat.ShareProxyActivity`(接收系统分享:`ACTION_SEND`/`SEND_MULTIPLE`,覆盖大量 MIME)
- `com.deepseek.chat.wxapi.WXEntryActivity`(微信登录/分享回调)
- `com.deepseek.chat.system.AppFileProvider`(`authority=com.deepseek.chat.provider`)
- `com.deepseek.chat.system.ShareResultReceiver`(`exported=true`)
`<application>` 内嵌第三方 SDK 元数据:
```xml
<meta-data android:name="custom-AppKey" android:value="3b199b7a06468"/>
<meta-data android:name="custom-AppSecret" android:value="98c6a376bdf6b632fda2a316b99b7ea4"/>
<meta-data android:name="custom-Https" android:value="yes"/>
```
权限以网络/媒体/相机/录音为主(`INTERNET`、`ACCESS_NETWORK_STATE`、`CAMERA`、`RECORD_AUDIO`、`POST_NOTIFICATIONS`、`FOREGROUND_SERVICE*` 等),未申请读取外部存储以外的敏感权限。
### 2.2 代码构成
- `classes.dex`(12.5MB)、`classes2.dex`(0.55MB,多为 Compose/协程/序列化)、`classes3.dex`(2.7MB)
- 应用是 **Jetpack Compose + Kotlin + kotlinx.serialization + Ktor/Retrofit(HTTP)+ WCDB(本地库)** 的典型组合。
### 2.3 原生库清单与用途(`lib/arm64-v8a/`)
| `.so` | 大小 | 归属与用途 |
|-------|------|-----------|
| `libWCDB.so` | 4.33MB | 腾讯 WCDB(微信数据库内核)——**本地聊天消息存储** |
| `libmmkv.so` | 0.30MB | 腾讯 MMKV——本地键值设置存储 |
| `librscrypto.so` | 0.34MB | DeepSeek 自研:**PoW 工作量证明哈希**(`com.deepseek.crypto.PowCalculator`) |
| `libEncryptorP.so` | 0.08MB | 字节跳动 AppLog/APM 的加密器(`com.bytedance.applog.encryptor`) |
| `libapminsighta/b.so`、`libvolc_log.so` | — | 监控/日志上报(火山引擎 APM) |
| `libsmsdk.so` | 0.84MB | **数美(Shumei)风控 SDK**(设备指纹 `device_id`),详细协议见 `docs/shumei-device-id-research.md` |
| `libliteavsdk.so`、`libtxffmpeg.so`、`libtxsoundtouch.so` | — | 腾讯 LiteAV/TRTC:**语音通话**音视频 |
| `libc++_shared.so`、`libflipped.so`、`libandroidx.graphics.path.so` 等 | — | 运行库/图形辅助 |
> 值得注意:真正与“消息存储”相关的只有 `libWCDB.so`;`libEncryptorP.so`(字节跳动)与 `librscrypto.so`(PoW)都**不用于本地消息库加密**。
---
## 3. 网络协议层
### 3.1 聊天相关 endpoint 全清单
从 smali 中提取的 `/api/v0/...` 常量(`apktool-out/smali*` 内多处出现),按模块归类:
**聊天流(SSE,撤回协议同源)** —— 见 `smali/gt3.smali`:
| 路径 | 方法 | 用途 |
|------|------|------|
| `/api/v0/chat/completion` | POST | 新消息发送 → 生成回复 |
| `/api/v0/chat/regenerate` | POST | 重新生成上一条回复 |
| `/api/v0/chat/continue` | POST | 续写/继续生成 |
| `/api/v0/chat/resume_stream` | POST | 恢复中断的流 |
| `/api/v0/chat/edit_message` | POST | 编辑用户消息后重新生成 |
| `/api/v0/chat/stop_stream` | POST | 停止当前生成(`smali/d31.smali`) |
| `/api/v0/chat/message_feedback` | POST | 消息反馈/举报(`smali/d31.smali`) |
| `/api/v0/chat/history_messages` | GET | 拉取历史消息(普通 JSON,非 SSE) |
| `/api/v0/chat/create_pow_challenge` | POST | PoW 挑战(`smali/d31.smali`) |
| `/api/v0/chat/tts/{...}`、`/api/v0/chat/tts/voice`、`/api/v0/chat/tts/voices` | POST/GET | 语音朗读 |
**会话管理** —— 见 `smali/jb2.smali`、`smali/qx0.smali`:
| 路径 | 用途 |
|------|------|
| `/api/v0/chat_session/create` | 新建会话 |
| `/api/v0/chat_session/delete` / `delete_all` | 删除会话 |
| `/api/v0/chat_session/fetch_page` | 会话列表分页 |
| `/api/v0/chat_session/update_title` / `update_pinned` / `batch_update_pinned` | 标题/置顶 |
**用户与设置**:
| 路径 | 用途 |
|------|------|
| `/api/v0/users/current` | 当前用户信息 |
| `/api/v0/users/settings` / `update_settings` | 用户设置 |
| `/api/v0/client/settings` / `client/settings/report` | **客户端(远端)配置**——模型/配额下发处 |
| `/api/v0/check_client_update` | 版本检查 |
| `/api/v0/users/login`、`register`、`login_by_mobile_sms`、`oauth/google/login`、`oauth/wechat/login` … | 登录/注册 |
**其他**:`/api/v0/file/upload_file`、`/api/v0/file/fetch_files`、`/api/v0/file/fork_file_task`;`/api/v0/share/{create,list,content,fork,delete}`;`/api/v0/call/{start,update,stop,rating,config}`;`/api/v0/index/{prepare,query}`;`/api/v0/ip_to_country_code`。
### 3.2 通用请求头(`defpackage/jv5.java` case 3)
Android 端每个请求带:
```java
je7.t(hn3Var, "Referer", "https://chat.deepseek.com");
je7.t(hn3Var, "x-client-platform", "android");
je7.t(hn3Var, "x-client-version", "2.6.2");
je7.t(hn3Var, "x-client-locale", <locale>);
je7.t(hn3Var, "x-client-bundle-id", "com.deepseek.chat");
```
响应头被解析用于观测:`x-ds-served-by`、`x-ds-trace-id`(`defpackage/p5b.java`)、`cf-ray`、`hwc-ray`。
> 对照 `docs/web-endpoint-upgrade.md`:Web 端额外需要 `x-hif-dliq` / `x-hif-leim`(HIF 轮询),**Android 端不需要**这两个头,UA 为 `DeepSeek/2.5.2 Android/32` 一类。
### 3.3 聊天请求模型(`ChatFullCompletionRequest`)
`defpackage/rv1.java` 是 `com.deepseek.chat.network.chat.model.chat.ChatFullCompletionRequest` 的 kotlinx.serialization 描述符,字段如下(顺序即 JSON 字段序):
| JSON 字段 | 类型 | 必填 | 说明 |
|-----------|------|------|------|
| `chat_session_id` | String | ✔ | 会话 ID |
| `parent_message_id` | Int? | ✔ | 父消息 ID(决定分支) |
| `prompt` | String | ✔ | 用户输入文本 |
| `ref_file_ids` | List | ✗ | 引用文件 |
| `thinking_enabled` | Bool | ✗ | 深度思考 |
| `search_enabled` | Bool | ✗ | 联网搜索 |
| `audio_id` | String? | ✗ | 语音输入 ID |
| `preempt` | Bool | ✔ | 抢占/打断进行中的流 |
| `model_type` | String? | ✗ | 模型类型(default/expert/vision…) |
| `action` | Enum? | ✗ | 动作标记 |
`completion` / `regenerate` / `edit_message` / `continue` / `resume_stream` 共享同一套流式响应与 patch 协议,差异只体现在路径与部分字段。
### 3.4 流式响应模型(`AppStaticMessage`)
`defpackage/cy.java` 是 `com.deepseek.chat.domain.chat.model.completion.message.AppStaticMessage` 的描述符:
```
message_id, parent_id, role, inserted_at,
ban_edit, ban_regenerate, status, quasi_status,
thinking_enabled, search_enabled, search_triggered,
accumulated_token_usage, extra_search_providers,
feedback, tips, fragments, has_pending_fragment,
conversation_mode, incomplete_message
```
与 Web 端抓包一致(见 `deepseek-recall-research.md` 第 5 节):`ban_edit` / `ban_regenerate` 是撤回后置位的关键布尔;`status`/`quasi_status` 是状态机;`fragments` 是内容数组;`tips` 单独存放提示横幅。
运行时的消息对象为 `defpackage/hy.java`(`extends lr implements qy2`),其字段 `g5a`(LiveData 风格)包括:
- `o`/`p`:`status` / `quasi_status`(`y12` 包装的字符串)
- `n`:`mv1` 片段列表
- `r`:`ban_regenerate`,`s`:`ban_edit`(布尔流)
- `v`:`accumulated_token_usage`,`w`:`search_triggered`,`z`:`conversation_mode`
- `y`:`has_pending_fragment`,`B`:`incomplete_message`
### 3.5 SSE / JSON Patch 协议与客户端 patch 应用
DeepSeek 用“类 JSON Patch”的增量协议更新 `response` 对象(详见 `deepseek-recall-research.md` 第 6 节)。Android 端的应用方就是 `defpackage/hy.java` 的 `k(rw5, String)` / `m(rw5, String)` 两个方法(重写自 `qy2` 接口)。关键分支:
```java
case -892481550: if (str.equals("status")) → 写 this.o = status
case 425478872: if (str.equals("ban_regenerate")) → 写 this.r
case 381249571: if (str.equals("fragments")) → this.n.clear(); addAll(list) // 整体覆盖
case -233917914: if (str.equals("incomplete_message"))→ 写 this.B
case 1550380638: if (str.equals("accumulated_token_usage")) → 写 this.v
case 757362655: if (str.equals("conversation_mode")) → 写 this.z
```
`m(...)` 是**追加语义**(`APPEND`):对 `fragments` 追加新片段、对 `tips` 追加提示、对 `extra_search_providers` 合并。**注意 `fragments` 在 `k(...)` 中是“清空 + 覆盖”**——这正是撤回事件得以“抹掉原文”的关键:撤回 patch 把 `fragments` 换成 `TEMPLATE_RESPONSE`,客户端整体替换,原文在内存模型里即刻消失。
片段类型(`defpackage/rr4.java` 的子类型反序列化 + `e3c.java` 的判定):
| type | 含义 |
|------|------|
| `THINK` | 深度思考 |
| `RESPONSE` | 正文 |
| `SEARCH` | 联网搜索卡片(非思考流程) |
| `TOOL_SEARCH` | 联网搜索卡片(思考流程) |
| `TOOL_OPEN` | “打开网页”卡片 |
| `TEMPLATE_RESPONSE` | **撤回模板话术(仅撤回事件出现)** |
| `link` / `""` | 引用/占位 |
> TIP(“本回答由 AI 生成,内容仅供参考…”)不是 fragment,而是 `tips` 字段,`defpackage/e7a`/`o37` 单独序列化。
### 3.6 客户端远程配置与模型配额(`ModelConfig`)
`defpackage/w97.java` + `ya7.java` 定义了 `com.deepseek.chat.settings.ModelConfig`,由 `/api/v0/client/settings` 下发,字段:
```
model_type, name, description, welcome_msg, is_default,
enabled, switchable, show_model_name_in_session,
input_character_limit, think_feature, search_feature, tts_feature,
file_feature, call_feature, camera_mode, prompt_feature,
regenerate_options, tips,
edit_quota, ← Integer?(可空)
regenerate_quota ← Integer?(可空)
```
`regenerate_quota` / `edit_quota` 为 **可空 Integer**,即“服务端可下发一个上限值,也可不设”。可空意味着:**是否限额、上限多少,完全由服务端配置决定**;客户端只做读取和展示。
本地缓存这些远端设置使用 MMKV(键名如 `kv_settings_*` / `kv_remote_settings_*`),见 `defpackage/bu2.java`、`jc2.java`、`lb3.java`、`pn5.java`、`y62.java` 等。
### 3.7 错误与提示映射
`defpackage/pwa.java` 的 `M(int)` 把聊天错误种类映射到提示串(节选):
| 序号 | 提示资源 |
|------|----------|
| 0 | `hint_network_error` |
| 1 | `hint_server_error` |
| 2 | `hint_max_message_count`(消息数量达到上限) |
| 3 | `hint_last_message_is_w_i_p`(请在其他消息生成结束后重试) |
| 4 | `hint_prompt_empty` |
| 6 | `hint_invalid_params` |
| 8 | `hint_rate_limit_reached`(消息发送过于频繁) |
| 9/13/14/15 | `hint_fallback`(消息未能发送) |
| 12 | `completion_ref_file_audit_unsatisfied` |
聊天流错误的“原因”类型由 `defpackage/oq1.java` 家族表达(`iq1..nq1`,其 `toString()` 是原因名):
```
ContextLength, Rating, NoAudio, Quota, ServiceError, Superseded
```
消息级“被拦截”类型由 `defpackage/ey1.java` 家族表达(其 `a()` 返回原因码):
| 类 | 返回码 |
|----|--------|
| `by1` | `CONTENT_EMPTY` |
| `dy1` | `CONTENT_TOO_LONG` |
| `cy1` | `CONTENT_FILTER` |
> 注意:**没有** `EDIT_QUOTA_EXCEED` / `REGENERATE_QUOTA_EXCEED` 这类“服务端错误码”;配额超额在客户端只体现为“操作被禁用 + 点击弹 toast”,见第 6 节。
### 3.8 PoW(工作量证明)
- 挑战:`POST /api/v0/chat/create_pow_challenge`(`smali/d31.smali`)。
- 计算:`com.deepseek.crypto.PowCalculator` 通过 JNI 调 `liblibrscrypto.so`,JNI 方法签名 `calculateDeepSeekHashV1Pow(Ljava/lang/String;Ljava/lang/String;J)J`(`defpackage/jc2.java` 可见)。
- 预取:客户端可通过 MMKV 设置 `kv_settings_pow_prefetch` / `kv_settings_pow_prefetch_count` 预取挑战(`jc2.java`)。
- `completion` 请求在发出前会带上 PoW 结果(`jc2.k(...)` 构造含挑战上下文与 `/api/v0/chat/completion` 的请求对象)。
---
## 4. 本地消息存储机制
### 4.1 数据库:WCDB(`deepseek_chat_<uid>.db`)
数据库实例在 DI 图中构造,见 `defpackage/cs9.java:119`:
```java
return new rbb(new Database(
((Context) ...).getDatabasePath(
"deepseek_chat_" + ((xy) ...).b + ".db" // 每个账号一个文件
).getAbsolutePath()));
```
- **文件**:`<app>/databases/deepseek_chat_<accountKey>.db`(`<accountKey>` 取自 `xy.b`)。
- **打开方式**:`com.tencent.wcdb.core.Database(String path)` → `createDatabase(path,false,false)`。
- **是否加密**:全仓库检索 `setCipherKey` / `Cipher` / `passphrase`,**未发现应用侧调用 WCDB 的加密接口**(`setCipherKey` 只有 native 声明本身)。结论:**本地库未加密,是普通 WCDB/SQLite**。这也解释了为何“消息存储”可被本地取证工具(如微信 WCDB 生态的工具)直接读取。
`rbb` 是消息仓储(Repository):持有 `Database`,并用 `ConcurrentHashMap<String, abb>` 按会话缓存“会话分表(Table)”,见 `defpackage/rbb.java:46-59`。
### 4.2 会话元数据表(`eh3` / 模型 `lbb`)
`defpackage/eh3.java` 定义了“会话”表的 ORM 绑定(模型类 `lbb`):
| 列 | 类型 | 备注 |
|----|------|------|
| `id` | TEXT | 主键(chat_session_id) |
| `title` | TEXT | 标题 |
| `titleType` | TEXT | 标题类型 |
| `cache_version` | INT | 缓存版本(用于并发写入去旧) |
| `cache_reset_at` | INT | 缓存重置时间 |
| `inserted_at` | REAL | 创建时间 |
| `updated_at` | REAL | 更新时间 |
| `current_message_id` | INT | 当前叶消息 ID(决定显示到哪条) |
| `schema_version` | INT | 本地 schema 版本 |
| `pinned` | INT | 是否置顶 |
| `model_type` | TEXT | 会话所用模型 |
### 4.3 会话消息表(`chat_session_messages_<sessionId>` / `dh3` / `zab`)
每个会话一张表,表名由 `defpackage/abb.java:13` 生成:
```java
String strM = sq3.M("'chat_session_messages_", str, "'"); // 形如 'chat_session_messages_<id>'
database.g(strM, dh3.a); // 注册 ORM
```
表结构由 `defpackage/dh3.java` 定义(模型类 `zab`):
| 列 | 类型 | 对应 `AppStaticMessage` 字段 |
|----|------|------------------------------|
| `message_id` | INT(主键) | message_id |
| `parent_id` | INT? | parent_id |
| `role` | TEXT | role(USER/ASSISTANT) |
| `thinking_enabled` | BOOL? | thinking_enabled |
| `status` | TEXT | status(含 `CONTENT_FILTER`) |
| `inserted_at` | REAL | inserted_at |
| `feedback_type` | TEXT? | feedback |
| `accumulated_token_usage` | INT | accumulated_token_usage |
| `ban_edit` | BOOL | ban_edit |
| `ban_regenerate` | BOOL | ban_regenerate |
| `tips` | TEXT? | tips(JSON) |
| `fragments` | TEXT? | fragments(JSON) |
| `conversation_mode` | TEXT? | conversation_mode |
| `extra_search_providers` | TEXT? | extra_search_providers |
> 重点:**`ban_edit`、`ban_regenerate`、`status=CONTENT_FILTER` 都会被持久化到本地库**。也就是说,一条被撤回的消息即使你回看本地缓存,看到的仍是“已被撤回”的模板话术与禁止编辑/再生的标记。
### 4.4 写入/合并逻辑(`rbb.b(...)`,`defpackage/rbb.java:61-135`)
一次事务内:
1. 读取会话缓存条目 `mbb`(含 `cache_version`);
2. 若本地 `cache_version <= 传入版本` → **接受更新**:命中 `eh3.c == sessionId` 则 `UPDATE`,否则 `INSERT`;
3. 更新 `cache_version`/`cache_reset_at`、把 `eh3.k`(`schema_version`)置 5、必要时更新 `eh3.j`(`current_message_id`);
4. 对会话分表:
- 若 `str2 == "MERGE"` → **UPSERT/追加**(保留旧消息,插入新消息);
- 否则 → **先删空该会话所有消息,再整体插入**(`ci7Var2.P()` 清表 + 批量 INSERT)。
这解释了 App 的两种同步语义:增量“合并”与全量“覆盖”。`current_message_id` 决定了 UI 把哪条视为“当前叶”——这就是“新消息顶替旧消息”的**数据层根因**:当新 `completion` 写入后,`current_message_id` 指向新消息,旧消息虽在库里但不再是叶节点。
### 4.5 键值存储(MMKV)
除 WCDB 外,App 大量使用 MMKV 存设置/状态:`kv_settings_*`(本地偏好)、`kv_remote_settings_*`(远端下发缓存),如 `kv_settings_session_prefetch_count`、`kv_settings_pow_prefetch`、`kv_settings_tts_resume_max_times` 等(`bu2.java`/`jc2.java`/`lb3.java`/`y62.java`)。会话切换到本地也会记录 `chat_session_id`/`model_type`(`defpackage/fb2.java:24`)。
### 4.6 存储小结
| 层 | 载体 | 加密 | 权威性 |
|----|------|------|--------|
| 消息 | WCDB `deepseek_chat_<uid>.db`,按会话分表 | ❌(未见 cipher key) | 缓存,镜像服务端 |
| 会话元数据 | 同库 `eh3` 表 | ❌ | 缓存 |
| 设置/状态 | MMKV | ❌ | 本地 + 远端缓存 |
| **真源** | 服务端(`history_messages`) | — | 服务端持久化,撤回后不可逆 |
---
## 5. 消息撤回 / 内容过滤机制
### 5.1 状态机
`defpackage/e3c.java:486` 的 `O(status, quasiStatus)` 给出客户端状态组合逻辑(简化):
```java
return status=="WIP"
? (quasi=="WIP" ? c32.a : quasi=="INCOMPLETE" ? new a32(true)
: quasi=="CONTENT_FILTER" ? new a32(false) : new a32(false))
: status=="INCOMPLETE" ? b32.d
: status=="CONTENT_FILTER" ? b32.b
: status=="INTERRUPTED" ? (...)
: b32.c; // 正常完成 FINISHED
```
由此可得客户端认可的状态集合:
| status | 含义 |
|--------|------|
| `WIP` | 生成中 |
| `FINISHED`(映射到 `b32.c`/正常态) | 完成 |
| `INCOMPLETE` | 未完成(上下文超限等) |
| `INTERRUPTED` | 被打断 |
| `CONTENT_FILTER` | **内容过滤/撤回** |
`quasi_status`(准状态)在流未结束时先行指示。`defpackage/c50.java:28` 与 `ns.java:909` 都有 `"CONTENT_FILTER"` 判定分支,用于 UI 与业务分支。
另有文件/附件解析状态枚举 `defpackage/cv1.java`:`PENDING/PARSING/SUCCESS/FAILED/CONTENT_FILTER/CONTENT_TOO_LONG/CONTENT_EMPTY/CANCELED/UNKNOWN`(用于文件与内容审校,不与消息 status 混用)。
### 5.2 撤回的 SSE 事件形态(与 Web 端一致)
Web 抓包已证实撤回是“模式 B”,其 patch 形态为:
```json
{"v":[
{"p":"ban_regenerate","v":true},
{"p":"status","v":"CONTENT_FILTER"},
{"p":"fragments","v":[{"id":5,"type":"TEMPLATE_RESPONSE","content":"你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。"}]},
{"p":"quasi_status","v":"CONTENT_FILTER"}
]}
```
Android 端的 `hy.java` 对这四个键的处理(`ban_regenerate`→`this.r`;`status`→`this.o`;`fragments`→清空覆盖;`quasi_status`→`this.p`)**完全等价**。因此:
- 撤回发生时,App 内存模型里 `fragments` 被替换为 `TEMPLATE_RESPONSE`,`status` 变 `CONTENT_FILTER`;
- `ban_edit` / `ban_regenerate` 被置真,操作栏的“修改 / 重新生成”随之被禁用;
- 撤回状态随后被写入本地 WCDB(`dh3` 的 status/ban_* 列)。
### 5.3 撤回的触发与持久化
- 触发时机:`FINISHED` 之后追加覆盖事件(`deepseek-recall-research.md` 附录 A 的时间线)。
- 触发位置:服务端(内容审核策略),客户端无法预判、也无法阻止。
- 持久化:服务端 `/api/v0/chat/history_messages` 返回的就是撤回后的状态;本地库同步的也是撤回后状态。
- **客户端不具备“防撤回”能力**:与浏览器扩展不同,App 里的 patch 应用是编译进 `hy.java` 的,且原文在被覆盖的瞬间即从内存碎片数组中移除,除非在**收到 SSE 字节流的第一时间**做过滤,否则无法保留原文。
---
## 6. 修改次数 / 重新生成次数限制机制
### 6.1 配额来源
配额来自服务端下发的 `ModelConfig.edit_quota` / `ModelConfig.regenerate_quota`(`defpackage/w97.java:36-37`、`ya7.java:189-191`)。它们是**可空 Integer**:服务端可按模型/账号/风控策略灵活设置是否限额及上限。
### 6.2 提示与触发点
两条针对性提示:
| 资源名 | 文案 | ID |
|--------|------|----|
| `edit_quota_exceed_toast` | 「修改输入次数超过限制」 | `0x7f0f00fa` |
| `regenerate_quota_exceed_toast` | 「重新生成次数超过限制」 | `0x7f0f02b6` |
触发点位于**消息操作栏(footer/menu)**的 Compose 逻辑:
- `defpackage/w40.java`:`case 0`(footer)当 `z2` 为真时执行
`ds0.L(nsVar, lrVar, "footer"); r0a.a(r0aVar, null, new cv(8), 3);`
其中 `cv(8)` 返回 `R.string.regenerate_quota_exceed_toast`。
- `defpackage/x40.java`:`default` 分支 `r0a.a(r0aVar, null, new uab(11), 3)`(`uab(11)` 为朗读相关提示;`uab(2)` 为 `edit_quota_exceed_toast`)。
- 构造签名 `w40(boolean 配额超额, ns 会话VM, lr 消息, r0a 提示器, boolean ..., Object obj, su4, int)`:**第一个布尔即“是否因超额而禁止该动作”**。
即:**当服务端判定该消息的修改/重新生成次数已达上限时,客户端把对应动作置为“禁用态”,用户点击时弹上述 toast**。
### 6.3 相关但不同的限额(不要混淆)
- `hint_max_message_count`「消息数量达到上限」:**单会话消息条数上限**(`completion_max_message_count_toast`),与编辑/再生次数无关。
- `hint_rate_limit_reached`「消息发送过于频繁」:**全局频率限制**,与单消息次数无关。
- `hint_last_message_is_w_i_p`「请在其他消息生成结束后重试」:并发约束(`preempt` 字段相关)。
### 6.4 客户端没有本地计数器(关键证据)
全量检索 `edit_count`、`regenerate_count`、`regenerate_times`、`editQuota/regenerateQuota` 的使用点:
- 除 `ModelConfig` 的定义(`w97.java`/`ya7.java`)与其 `toString` 外,**没有任何“本地计数自增/比较”的逻辑**;
- 消息模型 `AppStaticMessage`/`hy`/`dh3` 中**没有**“已修改次数/已再生次数”字段;
- 因此“第 6~7 次触发上限”这一现象**完全由服务端统计与判定**,客户端只是被动接收“超额”信号并弹 toast。
> 结论:**次数限制不可通过改客户端绕过**(改了客户端也不会改变服务端的计数与判定)。
---
## 7. “复制粘贴重发绕过并顶替上一条”行为分析
### 7.1 三个动作的语义差异
| 动作 | 端点 | 目标 | 是否受 `edit_quota`/`regenerate_quota` 约束 | 结果 |
|------|------|------|---------------------------------------------|------|
| 修改 | `POST /api/v0/chat/edit_message` | 对**已存在**的用户消息改写并重生成 | ✅ 受 `edit_quota` | 在父子链上替换/分支 |
| 重新生成 | `POST /api/v0/chat/regenerate` | 对**已存在**的回复再生成 | ✅ 受 `regenerate_quota` | 同级“变体/兄弟” |
| **复制文本重发** | `POST /api/v0/chat/completion` | **创建一条全新用户消息** | ❌ 不受上述配额,仅受全局频率限制 | 新叶节点 |
请求模型 `ChatFullCompletionRequest` 中,`completion` 与 `edit_message` 的差别正是:前者产生**新的 `message_id`**(`parent_message_id` 指向当时叶节点),后者作用于既有消息。服务端对 `completion` 只做“新消息”的常规校验(会话条数、频率、PoW 等),**不会**去校验“这条文本是不是之前那条的复制”。
### 7.2 为什么会“又增加次数 / 顶替上一条”
1. **不进入配额作用域**:因为走的是 `completion`,配额(edit/regenerate)只对相应的两个端点生效,故“重发”不被计数。
2. **新叶节点顶替旧消息**:本地库 `eh3.current_message_id` 与 UI 都指向“当前叶”。新 `completion` 落库后,`current_message_id` 更新为新消息;旧消息(已撤回/失败)在库中仍存在,但**不再是叶节点**,在会话视图中表现为“被顶到上面/被新消息取代”。
3. **不是“删除/修改旧消息”**:旧消息并未被改写成新内容,只是它的位置被新分支越过了。用 Web 术语说,两者是同一会话中的不同叶子,UI 默认只呈现当前叶路径。
### 7.3 边界与注意
- 该行为**不是客户端漏洞**,而是“动作语义 + 配额作用域”的自然结果;服务端若收紧策略(例如对 `completion` 也做文本去重/频率/内容策略校验),现象会消失。
- `preempt` 字段决定是否打断进行中的生成;重发时若前一条仍在 `WIP`,可能命中 `hint_last_message_is_w_i_p`。
- 会话条数上限(`hint_max_message_count`)与频率上限(`hint_rate_limit_reached`)仍会生效,因此“重发”并非无限。
---
## 8. 其他相关机制
### 8.1 数美(Shumei)风控与 device_id
`libsmsdk.so` + `com.ishumei.smantifraud.*`。Android 端登录/请求携带数美签发的 `device_id`,伪造会被判 `RISK_DEVICE_DETECTED`。完整协议(RSA/DES/AES/Gzip 流水线、`tn`/`er` 完整性校验)见 `docs/shumei-device-id-research.md`,本报告不重复。
### 8.2 埋点与监控(字节跳动 AppLog / 火山 APM)
- `com.bytedance.applog.encryptor.EncryptorUtil` 加载 `libEncryptorP.so`(`System.loadLibrary("EncryptorP")`),用于本地埋点日志加密。
- `libapminsighta/b.so`、`libvolc_log.so` 用于性能/崩溃监控上报。
- 埋点会记录聊天动作,例如 `switch_session`(`fb2.java`)、`regenerate_menu_show`(`ds0.java:238`)、`deep_think_toggle`(`el0.java`)、`session_reload_click` 等,可作为行为分析线索。
### 8.3 语音通话 / TTS / 文件 / 分享
- 通话:`/api/v0/call/{start,update,stop,rating,config}`,本地错误枚举 `defpackage/o01.java`(`ConnectionUnavailable`、`DailyQuotaExhausted`、`ConcurrentCallLimit`、`RtcJoinFailed`…),音视频由腾讯 TRTC/LiteAV 提供。
- TTS:`/api/v0/chat/tts/*`,设置项 `kv_settings_tts_resume_*`。
- 文件:上传/引用/审计(`completion_ref_file_audit_unsatisfied`),文件解析状态用 `cv1` 枚举。
- 分享:`/api/v0/share/*`,含“生成链接数上限”(`share_create_link_exceed_limit_toast`)。
### 8.4 客户端设置同步
`/api/v0/client/settings` 下发远端配置,写入 MMKV `kv_remote_settings_*`;`client/settings/report` 回传使用情况。`ModelConfig`(含配额)即由此同步,App 冷启动/切前台会刷新。
---
## 9. 与 Web 端的对照
| 维度 | Web 端 | Android 端(本报告) |
|------|--------|----------------------|
| 传输 | XHR + SSE(axios) | Ktor/Retrofit + SSE |
| 端点 | 同名 `/api/v0/chat/*` | 同名 `/api/v0/chat/*` |
| 撤回协议 | JSON-Patch:status=CONTENT_FILTER + fragments↦TEMPLATE_RESPONSE + ban_regenerate | 完全一致(`hy.java` 逐键处理) |
| 额外请求头 | `x-hif-dliq`/`x-hif-leim` | 无;有 `x-client-platform/version/locale/bundle-id` |
| 本地存储 | 浏览器缓存/IndexedDB | WCDB `deepseek_chat_<uid>.db`(未加密) |
| 配额来源 | 会话设置下发 | `ModelConfig.edit_quota/regenerate_quota` |
| 防撤回可行性 | 可拦截 SSE 事件块 | 需拦截 SSE 字节流,App 内不可行 |
---
## 10. 研究局限与后续验证建议
1. **配额的具体阈值未在客户端出现**:`edit_quota`/`regenerate_quota` 是服务端下发的可空值,客户端不含“6 次/7 次”之类常量。若要确证阈值,只能在**服务端配置或真实会话统计**层面验证。
2. **本地库未加密的结论基于“未发现 cipher key 调用”**:属“证据缺失型”结论,建议在真机 root 后导出 `deepseek_chat_<uid>.db` 并用 SQLite 工具直接打开以最终确认(若可打开即证实)。
3. **撤回事件的字节级时序**:本报告依据 Web 抓包 + 客户端 patch 逻辑推断 Android 端等价,建议对 Android 端做一次真实 SSE 抓包交叉验证。
5. **混淆还原**:`defpackage` 下大量类被 R8 合并,个别“上帝类”(如 `vt2`、`p5b`)难以完全还原;本报告优先采用“字符串/资源 ID/JSON 键名”这类未被混淆的稳定锚点。
---
## 附录 A:证据索引(文件:行)
| 主题 | 证据 |
|------|------|
| 请求头 | `defpackage/jv5.java:65-73` |
| 聊天端点点 | `apktool-out/smali/gt3.smali:1428,1638,1843,2038,2249`;`d31.smali:1957,2257,2603,2953`;`jb2.smali`;`qx0.smali` |
| 完成请求模型 | `defpackage/rv1.java:16-27`(`ChatFullCompletionRequest`) |
| 消息响应模型 | `defpackage/cy.java:17-37`(`AppStaticMessage`) |
| 运行时消息对象/patch | `defpackage/hy.java:280-352, 355-382` |
| 状态机 | `defpackage/e3c.java:486` |
| 片段类型 | `defpackage/rr4.java`;`e3c.java:673-721`;`ir4/lr4/gr4`(TOOL_OPEN/TOOL_SEARCH) |
| 拦截原因码 | `defpackage/cy1.java`(CONTENT_FILTER)、`by1.java`、`dy1.java` |
| 流错误原因 | `defpackage/iq1..nq1`(ContextLength/Rating/NoAudio/Quota/ServiceError/Superseded) |
| 提示映射 | `defpackage/pwa.java:345-381` |
| 配额字段 | `defpackage/w97.java:34-37`;`ya7.java:185-192` |
| 配额 toast 触发 | `defpackage/w40.java:41-65`;`x40.java:19-27`;`cv.java:50`;`uab.java:39` |
| 本地库构造 | `defpackage/cs9.java:119` |
| 会话表 ORM | `defpackage/eh3.java:24-91` |
| 消息表 ORM | `defpackage/dh3.java:28-82` |
| 会话分表命名 | `defpackage/abb.java:11-21` |
| 仓储/写入合并 | `defpackage/rbb.java:46-135` |
| WCDB 打开无密钥 | `com/tencent/wcdb/core/Database.java:82-83`;`setCipherKey` 仅 native 声明(同文件 283) |
| PoW | `defpackage/jc2.java:290,324`;`com/deepseek/crypto/PowCalculator.java:13` |
| 数美 | `docs/shumei-device-id-research.md` |
| 撤回协议对照 | `deepseek-recall-research.md` |
## 附录 B:关键 JSON 字段速查
```
// 请求:POST /api/v0/chat/completion
{ chat_session_id, parent_message_id, prompt, ref_file_ids?, thinking_enabled?,
search_enabled?, audio_id?, preempt, model_type?, action? }
// 流式响应对象(AppStaticMessage)
{ message_id, parent_id, role, inserted_at, ban_edit, ban_regenerate,
status, quasi_status, thinking_enabled, search_enabled, search_triggered,
accumulated_token_usage, extra_search_providers, feedback, tips,
fragments[], has_pending_fragment, conversation_mode, incomplete_message }
// 撤回 patch
{ "v":[ {"p":"ban_regenerate","v":true}, {"p":"status","v":"CONTENT_FILTER"},
{"p":"fragments","v":[{"type":"TEMPLATE_RESPONSE","content":"..."}]},
{"p":"quasi_status","v":"CONTENT_FILTER"} ] }
// 远端模型配置(/api/v0/client/settings → ModelConfig)
{ model_type, ..., regenerate_options?, tips?, edit_quota?, regenerate_quota? }
```
## 附录 C:本地库 schema 速查
```sql
-- 会话表(eh3 / lbb)
CREATE TABLE session(
id TEXT PRIMARY KEY, title TEXT, titleType TEXT,
cache_version INT, cache_reset_at INT,
inserted_at REAL, updated_at REAL,
current_message_id INT, schema_version INT,
pinned INT, model_type TEXT
);
-- 每个会话一张消息表(dh3 / zab):chat_session_messages_<sessionId>
CREATE TABLE 'chat_session_messages_<sessionId>'(
message_id INT PRIMARY KEY, parent_id INT, role TEXT,
thinking_enabled INT, status TEXT, inserted_at REAL,
feedback_type TEXT, accumulated_token_usage INT,
ban_edit INT, ban_regenerate INT,
tips TEXT, fragments TEXT, conversation_mode TEXT,
extra_search_providers TEXT
);
```
---
> 本报告基于对 `deepseek.apk`(v2.6.2)的静态逆向,所有结论均标注了代码级证据;涉及服务端行为的部分(配额阈值、撤回触发策略)基于协议对照与客户端逻辑推断,已在正文中明确区分。
|