ds2api / docs /deepseek-android-reverse-research.md
Bai Chen
Update deepseek-android-reverse-research.md
7c47499 unverified
|
Raw History Blame
38.1 kB
# 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)的静态逆向,所有结论均标注了代码级证据;涉及服务端行为的部分(配额阈值、撤回触发策略)基于协议对照与客户端逻辑推断,已在正文中明确区分。