luckfun233 commited on
Commit ·
ce5a019
1
Parent(s): a4e61a0
docs: 第三阶段逆向(重发语义/配额频率/重打包/多端同步)
Browse files基于新版 2.6.3(283) 静态逆向新增研究报告:消息树负 id 与 leaf 选取
解释“重发占同一条消息”;确认 regenerate_quota 本地强制默认 5 而
edit_quota 无消费点;给出 completion/edit/regenerate 端点等价性边界、
重打包与 Hook 可行性、以及 cache_version/cache_control 多端同步模型。
docs/deepseek-android-phase3-recall-quota-sync-research.md
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepSeek Android 逆向 · 第三阶段:重发语义、配额/频率、重打包与多端同步
|
| 2 |
+
|
| 3 |
+
> **样本**:`https://download.deepseek.com/apk/deepseek.apk`(本次重新下载)
|
| 4 |
+
> **版本**:`2.6.3`(versionCode **283**)
|
| 5 |
+
> **SHA-256**:`9979f87639b691ede83040cb83b3226cdbdc17d03c0f03dd480dd6fb714a5edc`
|
| 6 |
+
> **大小**:`21,975,277` 字节(上一版 2.6.2 为 `22,217,501`,SHA-256 `1468cae4…`)
|
| 7 |
+
> **工具链**:jadx 1.5.3(14k+ `.java`)、apktool 2.11.1、OpenJDK 25
|
| 8 |
+
> **对照**:[`deepseek-android-reverse-research.md`](./deepseek-android-reverse-research.md)(一阶段)、[`deepseek-android-recall-quota-phase2-research.md`](./deepseek-android-recall-quota-phase2-research.md)(二阶段)
|
| 9 |
+
|
| 10 |
+
> ⚠️ **版本提示**:2.6.3 的 R8 混淆名几乎全部改变(二阶段的 `C0794hy`/`rv1`/`tu1`/`kc2`/`zo0` 等名称在本版均不可用)。本文所有定位均改用**字符串、资源 ID、模型全限定名、行为特征**,可在后续版本复用。
|
| 11 |
+
>
|
| 12 |
+
> 本文只做静态逆向与机制解释,不含可执行补丁脚本,不含账号凭据,不对线上服务做任何绕过/压测操作。
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 0. 结论速览
|
| 17 |
+
|
| 18 |
+
针对你这一轮提出的四个问题,先给结论:
|
| 19 |
+
|
| 20 |
+
| # | 问题 | 结论 |
|
| 21 |
+
|---|---|---|
|
| 22 |
+
| 1 | 「重发」在服务端看来是否类似「编辑」?是否占同一条消息? | **树结构上高度等价,协议语义上不等价**。用 `completion` 且 `parent_message_id = 被编辑消息的父节点`,服务端看到的是「同一父节点下新增一条 USER 消息」,与 edit 产生的分支在**树形上同构**;但服务端不会把它记成 edit,也不会消耗 `edit_quota`。UI 之所以看起来「占同一条」,根因是**当前叶(leaf)只取 id>0 的消息**,本地临时消息(负 id)被跳过。 |
|
| 23 |
+
| 2 | 编辑/重新生成的**频率**限制 | 客户端有两条远端开关:`allow_parallel_streams`(允许并行流)与 `interrupt_and_send_enabled`(打断并发送)。二者决定「生成中能否再发」,对应错误码 `hint_last_message_is_w_i_p` / `hint_rate_limit_reached`。真正的**频率配额在服务端**,客户端只做展示与预判。 |
|
| 24 |
+
| 3 | 反编译改包注入逻辑可行吗? | **可行,且本 App 没有应用级签名自校验**。但有三个现实障碍:① 重签名后微信 OAuth/分享、Fly 一键登录、App Links 等**签名绑定能力会失效**;② 数美 SDK 会检测 Xposed/调试器/设备风险;③ 需自行处理 `network_security_config`。**最稳的是 Xposed/LSPosed Hook,其次是 smali 重打包,最后才是 MITM。** |
|
| 25 |
+
| 4 | 多端同步如何不冲突 | 官方模型是「**服务端权威 + 本地 WCDB 缓存 + 版本号乐观并发**」:拉取时带 `cache_version`/`cache_reset_at` 做条件请求,响应带 `cache_control = REPLACE/MERGE` 决定落库方式。它**不解决**「分支可见性」「撤回后原文」「生成中状态」三类跨端缺口。 |
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 1. 本版新增/变化的代码锚点
|
| 30 |
+
|
| 31 |
+
| 语义 | 2.6.2(二阶段) | **2.6.3(本版)** |
|
| 32 |
+
|---|---|---|
|
| 33 |
+
| 助手消息 patch 应用器 | `C0794hy` / `hy.smali` | 由字符串定位:`fragments` 的 `clear()+addAll()` 分支(见 §3) |
|
| 34 |
+
| `AppStaticMessage` 描述符 | `C0609cy` | `C1238qx`(`pd8("...AppStaticMessage", …, 19)`) |
|
| 35 |
+
| 完成请求 | `rv1` / `tv1` | `cw1`(描述符)/ `ew1`(模型) |
|
| 36 |
+
| 编辑请求 | `tu1` / `vu1` | `ev1`(描述符)/ 模型含 `message_id` |
|
| 37 |
+
| 再生成请求 | `kc2` / `mc2` | `bd2`(描述符)/ `pd8("...ChatRegenerateRequest", …, 5)` |
|
| 38 |
+
| `ModelConfig` | `w97` / `ya7` | `va7`(描述符)/ `xb7`(模型) |
|
| 39 |
+
| 配额 toast 源 | `uz2` | `x13`(case 4=edit, case 5=regenerate) |
|
| 40 |
+
| 消息树节点基类 | `AbstractC1412lr` | `AbstractC0046ar` |
|
| 41 |
+
| 本地仓储 | `rbb` | `ncb` |
|
| 42 |
+
| 会话表 ORM | `eh3` | `wh3` |
|
| 43 |
+
| 消息表 ORM | `dh3` | `vh3` |
|
| 44 |
+
| 历史响应模型 | — | `nw1` → `pw1`(`ChatHistoryMessagesResponseBizData`) |
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 2. 消息树模型:为什么「重发会占同一条消息」
|
| 49 |
+
|
| 50 |
+
### 2.1 节点与 id 语义
|
| 51 |
+
|
| 52 |
+
消息树节点基类 `AbstractC0046ar`(`p000/AbstractC0046ar.java`):
|
| 53 |
+
|
| 54 |
+
```java
|
| 55 |
+
// L83-86:id < 0 视为“已移除/临时”
|
| 56 |
+
public final boolean m1089M() { return mo1118w() < 0; }
|
| 57 |
+
|
| 58 |
+
// L100-129:节点 → DB 行(wbb)
|
| 59 |
+
int iMo1118w = mo1118w(); // message_id
|
| 60 |
+
Integer numMo1120y = mo1120y(); // parent_id
|
| 61 |
+
String strMo1079C = mo1079C(); // role
|
| 62 |
+
...
|
| 63 |
+
boolean zMo1103d = mo1103d(); // ban_edit
|
| 64 |
+
boolean zMo1104e = mo1104e(); // ban_regenerate
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
本地临时 id 的生成(`p000/C0604cs.java:621-625`):
|
| 68 |
+
|
| 69 |
+
```java
|
| 70 |
+
public final int m6116l() {
|
| 71 |
+
int i = this.f8495r;
|
| 72 |
+
this.f8495r = i - 1; // 递减:-1, -2, -3 ...
|
| 73 |
+
return i;
|
| 74 |
+
}
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### 2.2 「当前叶」如何选取(关键)
|
| 78 |
+
|
| 79 |
+
```java
|
| 80 |
+
// p000/C0604cs.java:262-283
|
| 81 |
+
public final Integer m6099D() {
|
| 82 |
+
InterfaceC1380ur st = (InterfaceC1380ur) this.f8487j.getValue();
|
| 83 |
+
if (!(st instanceof C1343tr)) return null;
|
| 84 |
+
m2a list = ((C1343tr) st).f36679a;
|
| 85 |
+
int size = list.size() - 1;
|
| 86 |
+
...
|
| 87 |
+
while (true) {
|
| 88 |
+
if (((AbstractC0046ar) list.get(size)).mo1118w() > 0) { // ← 只认正 id
|
| 89 |
+
return Integer.valueOf(...mo1118w());
|
| 90 |
+
}
|
| 91 |
+
...
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
**结论**:正在生成/失败回滚的本地消息 id 为负数,**不会成为 leaf**。于是:
|
| 97 |
+
|
| 98 |
+
- 发送时 `parent_message_id` = **最后一个服务端已确认(id>0)的消息**;
|
| 99 |
+
- 如果上一条回复因为失败/撤回被回滚,leaf 会**落回你那条用户消息**(或更早的助手消息);
|
| 100 |
+
- 此时新的 `completion` 产生的回复会挂在**同一条用户消息下** → UI 上表现为「同一个回合 / 同一条消息」,而不是新开一条。
|
| 101 |
+
|
| 102 |
+
这解释了你观察到的「复制粘贴重发,好像还是占同一条消息」。**它不是服务端特判,而是客户端 leaf 选择 + 服务端父子链共同作用的结果。**
|
| 103 |
+
|
| 104 |
+
### 2.3 分支(变体)如何表示
|
| 105 |
+
|
| 106 |
+
- 父节点持有子节点 id 列表(`mo1105f().f14978b`,类型 `m2a`)。
|
| 107 |
+
- 兄弟节点数量统计:`p000/azb.java:2047-2067`
|
| 108 |
+
|
| 109 |
+
```java
|
| 110 |
+
public static final int m1347x(AbstractC0046ar node, C0604cs session) {
|
| 111 |
+
AbstractC0046ar parent = (AbstractC0046ar) session.f8483f.get(node.m1086J());
|
| 112 |
+
if (parent == null || parent.mo1105f().f14978b.isEmpty()) return 0;
|
| 113 |
+
int count = 0;
|
| 114 |
+
for (id : parent.mo1105f().f14978b) {
|
| 115 |
+
child = session.f8483f.get(id);
|
| 116 |
+
if (child != null && !child.m1089M() && sameRole(child, node)) count++;
|
| 117 |
+
}
|
| 118 |
+
return count;
|
| 119 |
+
}
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
- UI:`MessageBranchSwitcher`(`p000/dq0.java`、`p000/v13.java`),显示 `当前序号/总数`,左右为 `ic_chevron_left_outline_20_small` / `ic_chevron_right_outline_20_small`(`v13.java:452,469`)。
|
| 123 |
+
- 编辑的文案是「修改并创建分支」(`message_press_edit_to_branch`,`p000/iw7.java:2711`)——**编辑即造分支**。
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## 3. 撤回(CONTENT_FILTER)在 2.6.3 的复核
|
| 128 |
+
|
| 129 |
+
### 3.1 patch 应用器仍是「清空 + 覆盖」
|
| 130 |
+
|
| 131 |
+
`fragments` 的 SET 分支在 v2.6.3 依旧:
|
| 132 |
+
|
| 133 |
+
```java
|
| 134 |
+
if (key.equals("fragments")) {
|
| 135 |
+
List incoming = parse(...);
|
| 136 |
+
container.clear(); // 清空 THINK/RESPONSE/TIP
|
| 137 |
+
container.addAll(incoming); // 用 TEMPLATE_RESPONSE 覆盖
|
| 138 |
+
}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
配套字段:`status`、`quasi_status`、`ban_regenerate`、`accumulated_token_usage`、`conversation_mode`、`incomplete_message` 等逐键写入。
|
| 142 |
+
|
| 143 |
+
**`ban_edit` 仍然没有任何 patch 处理器**:本版全仓库检索 `ban_edit` 只命中两处——序列化描述符(`C1238qx.java:25`)与数据库列(`vh3.java:96`)。即 **客户端读取并落库,但不消费**。
|
| 144 |
+
|
| 145 |
+
### 3.2 撤回后 UI 的实际表现(代码级)
|
| 146 |
+
|
| 147 |
+
助手菜单构建处(`p000/vz2.java:8632-8648`):
|
| 148 |
+
|
| 149 |
+
```java
|
| 150 |
+
zj6 items = ...;
|
| 151 |
+
items.add(retryItem);
|
| 152 |
+
items.add(copyItem);
|
| 153 |
+
if (!hidden && !msg.m1089M()) {
|
| 154 |
+
if (!msg.mo1104e()) { // mo1104e() = ban_regenerate
|
| 155 |
+
items.add(regenerateMenu); // ← 只有未被 ban 时才加“重新生成”
|
| 156 |
+
}
|
| 157 |
+
items.add(likeItem);
|
| 158 |
+
items.add(dislikeItem);
|
| 159 |
+
...
|
| 160 |
+
}
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
**这解释了「撤回后重新生成按钮消失」**:不是按钮被置灰,而是 `ban_regenerate=true` 时**菜单项根本没被加入**。
|
| 164 |
+
|
| 165 |
+
### 3.3 黄色小图标到底是什么
|
| 166 |
+
|
| 167 |
+
代码里有三处「消息旁边的小图形」,按出现条件区分:
|
| 168 |
+
|
| 169 |
+
| UI | 图标资源 | 触发条件 | 证据 |
|
| 170 |
+
|---|---|---|---|
|
| 171 |
+
| **「⚠ + 继续生成」** | `ic_warning_outline_16` | 消息 `INCOMPLETE` / `INTERRUPTED`(未完成/被中断) | `p000/p40.java:39,56`;按钮文案 `R.string.message_continue`(`p000/p1c.java:1630`) |
|
| 172 |
+
| **分支切换器 `‹ 2/3 ›`** | `ic_chevron_left/right_outline_20_small` | 同父节点存在多个同 role 变体 | `p000/v13.java:452,469`;计数 `p000/azb.java:2047` |
|
| 173 |
+
| **「重新生成」子菜单箭头** | `ic_chevron_right_outline_20` | 助手消息菜单里的二级入口 | `p000/vz2.java:8518`(颜色 `z19 ? j : j2`,`z19` 即「未超配额」) |
|
| 174 |
+
|
| 175 |
+
> **判定**:你描述的「黄色小箭头」最可能是 **`ic_warning_outline_16` 的警告图标 + 「继续生成」**(它的语义正是「上一条没生成完」),颜色来自 Compose 主题的警告色(不在 `res/values/colors.xml` 中硬编码,因此无法从资源直接取色值,属**视觉推断**)。若你看到的是「数字/序号」形态,则对应分支切换器。
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## 4. 配额与频率:2.6.3 的精确事实
|
| 180 |
+
|
| 181 |
+
### 4.1 `ModelConfig` 字段(本版索引)
|
| 182 |
+
|
| 183 |
+
`p000/va7.java:17-40`(`pd8("com.deepseek.chat.settings.ModelConfig", …, 20)`):
|
| 184 |
+
|
| 185 |
+
```
|
| 186 |
+
0 model_type 1 name 2 description 3 welcome_msg
|
| 187 |
+
4 is_default 5 enabled 6 switchable 7 show_model_name_in_session
|
| 188 |
+
8 input_character_limit 9 think_feature 10 search_feature 11 tts_feature
|
| 189 |
+
12 file_feature 13 call_feature 14 camera_mode 15 prompt_feature
|
| 190 |
+
16 regenerate_options 17 tips 18 edit_quota 19 regenerate_quota
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
`p000/xb7.java`:`f42341s = edit_quota`,`f42342t = regenerate_quota`。
|
| 194 |
+
|
| 195 |
+
### 4.2 `regenerate_quota`:客户端本地强制,默认 5
|
| 196 |
+
|
| 197 |
+
```java
|
| 198 |
+
// p000/kwb.java:3385-3398
|
| 199 |
+
int count = azb.m1347x(msg, session); // 同父同 role 的未删除兄弟数
|
| 200 |
+
Integer quota = modelConfig.f42342t; // regenerate_quota
|
| 201 |
+
boolean exceeded = count > (quota != null ? quota.intValue() : 5);
|
| 202 |
+
...
|
| 203 |
+
// 超额时:
|
| 204 |
+
j = theme.warningColor(...);
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
同样的逻辑在 `p000/vz2.java:8503-8535`(另一处菜单构建)。菜单项以 `j22(..., R.string.message_press_regenerate, exceeded, handler)` 构造,`exceeded` 即「禁用态」;点击时走 `g40.java:95`:
|
| 208 |
+
|
| 209 |
+
```java
|
| 210 |
+
h1a.m8870a(h1aVar, null, new x13(5), 3); // x13(5) = regenerate_quota_exceed_toast
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
`x13` 映射(`p000/x13.java:39-44`):
|
| 214 |
+
|
| 215 |
+
```java
|
| 216 |
+
case 4: return ctx.getString(R.string.edit_quota_exceed_toast);
|
| 217 |
+
case 5: return ctx.getString(R.string.regenerate_quota_exceed_toast);
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
中文文案(`res/values-b+zh+Hans/strings.xml`):
|
| 221 |
+
- `edit_quota_exceed_toast` = 「修改输入次数超过限制」(L163)
|
| 222 |
+
- `regenerate_quota_exceed_toast` = 「重新生成次数超过限制」(L472)
|
| 223 |
+
|
| 224 |
+
> **这解释了「第 6~7 次触发上限」**:首次生成不计入「兄弟变体」,默认配额 5,因此用户在**第 6 次左右**看到超限。
|
| 225 |
+
|
| 226 |
+
### 4.3 `edit_quota`:字段存在,但**仍未被消费**
|
| 227 |
+
|
| 228 |
+
全量检索 `f42341s`(即 `editQuota`)只有 8 处:字段声明、构造赋值、`equals`、`toString`、`copy`、序列化读写。**没有任何 UI 或业务比较逻辑读取它。**
|
| 229 |
+
|
| 230 |
+
同时,`edit_quota_exceed_toast` 确有一个 UI 接线点:
|
| 231 |
+
|
| 232 |
+
```java
|
| 233 |
+
// p000/C1507y5.java:395-401
|
| 234 |
+
case 25:
|
| 235 |
+
h1a.m8870a(h1aVar, null, new x13(4), 3); // edit_quota_exceed_toast
|
| 236 |
+
qjc.m14997s(c0604cs4, abstractC0046ar4, "menu");
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
但该分支的**触发条件未能静态确证**(case 以寄存器变量传入,且 R8 的 switch 反编译不完整)。
|
| 240 |
+
|
| 241 |
+
**诚实结论**:
|
| 242 |
+
- 客户端**没有**用 `edit_quota` 做本地计数比较(与 2.6.2 一致);
|
| 243 |
+
- 「修改输入次数超过限制」的 toast 存在且已接线,但它更可能是由**服务端下发的不可编辑状态/服务端错误**触发,而非本地算术;
|
| 244 |
+
- 因此**编辑配额基本可判定为服务端判定**;`regenerate_quota` 才是客户端本地强制的那个。
|
| 245 |
+
|
| 246 |
+
### 4.4 并发与频率:两条远端开关
|
| 247 |
+
|
| 248 |
+
`p000/re2.java`(本版新增/保留的发送闸门):
|
| 249 |
+
|
| 250 |
+
```java
|
| 251 |
+
// L18-34:allow_parallel_streams
|
| 252 |
+
public final boolean m15343a() { ... MMKV kv_settings_allow_parallel_streams / kv_remote_settings_allow_parallel_streams ... }
|
| 253 |
+
|
| 254 |
+
// L37-61:interrupt_and_send_enabled
|
| 255 |
+
public final int m15344b(boolean canAppend, boolean leafIsWip) {
|
| 256 |
+
if (!canAppend) {
|
| 257 |
+
if (leafIsWip) return 3; // 生成中
|
| 258 |
+
return m15343a() ? 2 : 4; // 2=允许并行, 4=阻塞
|
| 259 |
+
}
|
| 260 |
+
return interruptAndSendEnabled() ? 1 : 4; // 1=打断并发送
|
| 261 |
+
}
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
调用与错误映射(`p000/se2.java:373-394`):
|
| 265 |
+
|
| 266 |
+
```java
|
| 267 |
+
int state = re2.m15344b(...);
|
| 268 |
+
if (state == 2) { showError(yb2(18)); return state; }
|
| 269 |
+
if (state == 3) { showError(yb2(19)); return state; }
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
错误码 → 提示串(`p000/u38.java:256-292`,完整表):
|
| 273 |
+
|
| 274 |
+
| 码 | 提示资源 | 语义 |
|
| 275 |
+
|---|---|---|
|
| 276 |
+
| 0 | `hint_network_error` | 网络 |
|
| 277 |
+
| 1 | `hint_server_error` | 服务异常 |
|
| 278 |
+
| 2 | `hint_max_message_count` | **单会话消息条数上限** |
|
| 279 |
+
| 3 | `hint_last_message_is_w_i_p` | **其他消息还在生成**(并发) |
|
| 280 |
+
| 4 | `hint_prompt_empty` | 空输入 |
|
| 281 |
+
| 5 | `hint_message_search_with_file_error` | 搜索+文件冲突 |
|
| 282 |
+
| 6 | `hint_invalid_params` | 参数错误 |
|
| 283 |
+
| 7 | `invalid_message_id_toast` | 消息 ID 无效 |
|
| 284 |
+
| 8 | `hint_rate_limit_reached` | **发送过于频繁**(服务端频率限制) |
|
| 285 |
+
| 9/13/14/15 | `hint_fallback` | 发送失败兜底 |
|
| 286 |
+
| 10 | `hint_invalid_file_ref` | 文件引用无效 |
|
| 287 |
+
| 11/16 | — | 不提示 |
|
| 288 |
+
| 12 | `completion_ref_file_audit_unsatisfied` | 文件审核未通过 |
|
| 289 |
+
|
| 290 |
+
流错误原因枚举(本版):`tq1`=ContextLength、`uq1`=NoAudio、`vq1`=Quota、`wq1`=Rating、`xq1`=ServiceError、`yq1`=Superseded。
|
| 291 |
+
|
| 292 |
+
> **频率限制的边界**:客户端只解释「为什么不能发」,**不实现指数退避/冷却计时**;`hint_rate_limit_reached` 由服务端返回。因此「改客户端突破频率」没有意义——真正的闸门在服务端。
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## 5. 「重发 ≈ 编辑」到底等不等价
|
| 297 |
+
|
| 298 |
+
### 5.1 三个端点的字段语义(本版实测描述符)
|
| 299 |
+
|
| 300 |
+
```
|
| 301 |
+
ChatFullCompletionRequest (cw1, 10 fields)
|
| 302 |
+
0 chat_session_id 1 parent_message_id 2 prompt 3 ref_file_ids
|
| 303 |
+
4 thinking_enabled 5 search_enabled 6 audio_id 7 preempt
|
| 304 |
+
8 model_type 9 action
|
| 305 |
+
|
| 306 |
+
ChatRegenerateRequest (5 fields)
|
| 307 |
+
0 chat_session_id 1 child_message_id 2 thinking_enabled 3 search_enabled 4 user_options
|
| 308 |
+
|
| 309 |
+
ChatEditMessageRequest (8 fields)
|
| 310 |
+
0 chat_session_id 1 message_id 2 prompt 3 ref_file_ids
|
| 311 |
+
4 thinking_enabled 5 search_enabled 6 client_stream_id 7 action
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
三者的「作用对象」:
|
| 315 |
+
|
| 316 |
+
| 端点 | 作用对象 | 服务端动作 | 是否消耗配额 |
|
| 317 |
+
|---|---|---|---|
|
| 318 |
+
| `completion` | `parent_message_id` | 在父节点下**新建** USER 消息 + 生成 ASSISTANT | 否(仅频率/条数/PoW/内容策略) |
|
| 319 |
+
| `edit_message` | `message_id` | **改写**既有 USER 消息并重新生成 | 是(`edit_quota`,服务端) |
|
| 320 |
+
| `regenerate` | `child_message_id` | 在既有 ASSISTANT 的父节点下**新增兄弟** | 是(`regenerate_quota`,客户端+服务端) |
|
| 321 |
+
|
| 322 |
+
### 5.2 「重发」在树形上等价于「编辑」
|
| 323 |
+
|
| 324 |
+
设原消息链为:
|
| 325 |
+
|
| 326 |
+
```
|
| 327 |
+
P (ASSISTANT/或空)
|
| 328 |
+
└── U1 (USER, 你原本想编辑的那条)
|
| 329 |
+
└── A1 (ASSISTANT, 失败/被撤回)
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
- **编辑**:`edit_message{message_id=U1, prompt=U1'}` → 服务端把 U1 的 prompt 改为 U1',并生成 A1'。树仍是 `P → U1 → A1'`(A1 可作为旧分支保留)。
|
| 333 |
+
- **重发**:`completion{parent_message_id=P, prompt=U1'}` → 服务端在 P 下新建 `U2`,再生成 `A2`。树变成:
|
| 334 |
+
|
| 335 |
+
```
|
| 336 |
+
P
|
| 337 |
+
├── U1 → A1
|
| 338 |
+
└── U2 → A2 ← 与 U1 同级(分支)
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
**共同点**:两者在「从 P 往下的上下文」完全一致,模型看到的都是 `history(P) + U1'`。因此**语义/上下文上等价**。
|
| 342 |
+
**不同点**:
|
| 343 |
+
1. 重发会**多留一条 U1**(旧分支),编辑则原地改写;
|
| 344 |
+
2. 服务端记账不同:重发记为新 USER 消息,**不消耗 `edit_quota`**;
|
| 345 |
+
3. 若服务端对 `parent_message_id` 有「必须是当前叶」的校验,则挂到 `P` 可能被拒(**服务端策略,静态无法确证**)。
|
| 346 |
+
|
| 347 |
+
### 5.3 UI 为什么看起来「占同一条」
|
| 348 |
+
|
| 349 |
+
两个独立原因叠加:
|
| 350 |
+
|
| 351 |
+
1. **leaf 跳过负 id**(§2.2):失败的本地占位消息不算数,`parent_message_id` 会回落到上一条正 id 消息;
|
| 352 |
+
2. **UI 只渲染当前叶路径**:同父节点的兄弟默认只显示「当前分支」,靠 `MessageBranchSwitcher`(`‹ n/m ›`)切换。
|
| 353 |
+
|
| 354 |
+
所以你在界面上看到的是「同一条消息位置被替换/续上」,但底层其实是**兄弟分支**。这不代表服务端把它当成了 edit。
|
| 355 |
+
|
| 356 |
+
### 5.4 想在「服务端也当编辑」——不可行
|
| 357 |
+
|
| 358 |
+
服务端判定依据是端点 + 消息 id,而非文本内容。`completion` 无法伪装成 `edit_message`。任何「让服务端记成 edit」的方案,最终都必须**真的调用 `edit_message`**,也就必然受服务端配额约束。
|
| 359 |
+
|
| 360 |
+
**唯一可行的「绕开配额但保持上下文正确」路径**:
|
| 361 |
+
- 走 `completion` + `parent_message_id = 原 USER 消息的父节点`;
|
| 362 |
+
- 接受「多一条旧分支」与「不计入 edit 配额」;
|
| 363 |
+
- 在**自己的代理/客户端**里把这条 completion 标注为「逻辑上的编辑」。
|
| 364 |
+
|
| 365 |
+
---
|
| 366 |
+
|
| 367 |
+
## 6. 反编译改包(重打包)可行性
|
| 368 |
+
|
| 369 |
+
### 6.1 签名/完整性校验:**未发现应用级自校验**
|
| 370 |
+
|
| 371 |
+
全量检索结果:
|
| 372 |
+
|
| 373 |
+
| 位置 | 用途 | 是否反篡改 |
|
| 374 |
+
|---|---|---|
|
| 375 |
+
| `cn/fly/verify/util/C0303i.java:314` | Fly 一键登录读取签名 | ❌ 第三方 SDK 业务 |
|
| 376 |
+
| `p000/ka7.java:1644` | 崩溃 SDK 用 SHA1(签名) 作标识 | ❌ 业务标识 |
|
| 377 |
+
| `p000/k25.java:111` / `p000/h15.java:151` | Google Play Services 校验 **GMS 自身**签名 | ❌ 库内部 |
|
| 378 |
+
| `p000/f45.java:132` | WebView JS Bridge 的 debugInfo | ❌ 调试信息 |
|
| 379 |
+
|
| 380 |
+
- 未发现 `checkSignatures` 对**自身包**的校验;
|
| 381 |
+
- 未发现 SafetyNet / Play Integrity;
|
| 382 |
+
- 未发现 `ptrace`/`isDebuggerConnected` 在**应用自身**代码中(仅在数美 SDK 内)。
|
| 383 |
+
|
| 384 |
+
### 6.2 但有三个真实障碍
|
| 385 |
+
|
| 386 |
+
1. **数美风控(`libsmsdk.so` + `com.ishumei.*`)**
|
| 387 |
+
- Java 层可见 Xposed 检测:`com/ishumei/smantifraud/l1111l111111Il.java:104,131,208,322,332,486`(反射 `XposedBridge` / `XposedInit`);
|
| 388 |
+
- 调试器检测:`l11l111I111l.java:33`、`l111l1111llIl.java:656`(`Debug.isDebuggerConnected()`);
|
| 389 |
+
- 设备指纹/root 检测主要在 native。→ **Xposed 与重打包都可能被标记为风险设备**。
|
| 390 |
+
|
| 391 |
+
2. **签名绑定的第三方能力会失效**
|
| 392 |
+
- 微信开放平台:AppID 与「包名 + 签名」绑定 → 重签名后**微信登录/分享失败**;
|
| 393 |
+
- Fly Verify(`com.deepseek.chat.cn.fly.FlyProvider`):一键登录通常也绑包名/签名;
|
| 394 |
+
- App Links / OAuth 回调依赖签名校验;
|
| 395 |
+
- 腾讯 TRTC/LiteAV:License 与包名/SDKAppID 绑定(语音通话可能不可用)。
|
| 396 |
+
> 以上「会失效」属**机制推断**,具体以真机验证为准;至少微信登录是确定的签名绑定。
|
| 397 |
+
|
| 398 |
+
3. **网络信任配置**
|
| 399 |
+
- `AndroidManifest.xml`:`usesCleartextTraffic="false"`、`networkSecurityConfig="@xml/network_security_config"`、`extractNativeLibs="true"`、`allowBackup="false"`;
|
| 400 |
+
- `res/xml/network_security_config.xml`:`base-config` 仅信任 `system` CA,`debug-overrides` 才信任 `user` CA;
|
| 401 |
+
- **无证书固定(pinning)**,但非 debug 包不信任用户 CA → MITM 需 root 装系统 CA 或改包放宽。
|
| 402 |
+
|
| 403 |
+
### 6.3 反撤回的注入点(最小改造面)
|
| 404 |
+
|
| 405 |
+
**首选:流式 patch 应用层**(比字节流更稳,跨 HTTP 客户端实现不变)。
|
| 406 |
+
|
| 407 |
+
- 定位方式:搜索字符串 `"fragments"` + `"TEMPLATE_RESPONSE"` + `"CONTENT_FILTER"` 的类型;
|
| 408 |
+
- 改造逻辑(描述,不给脚本):
|
| 409 |
+
1. 在应用 `fragments` 的 SET 分支前,检查待写入片段是否包含 `TEMPLATE_RESPONSE`,且同批 patch 含 `status=CONTENT_FILTER`;
|
| 410 |
+
2. 命中则 **直接 return**(不 clear、不覆盖),并可选地把 `status` 保持为 `FINISHED`;
|
| 411 |
+
3. 对 `ban_regenerate` 的 patch 同样跳过,以保留「重新生成」入口。
|
| 412 |
+
- 为什么必须在**当次流**拦截:原文只存在于内存片段数组;一旦 clear+addAll 执行,后续任何环节都拿不到。
|
| 413 |
+
|
| 414 |
+
**次选:SSE 字节层**(丢帧):
|
| 415 |
+
- 在解析 `data:` 后、反序列化前,判断帧内是否同时含 `CONTENT_FILTER` 与 `TEMPLATE_RESPONSE`;命中则丢弃该帧。
|
| 416 |
+
- 代价:要兼容 App 的 HTTP 栈(Ktor OkHttp),且不同端点共用。
|
| 417 |
+
|
| 418 |
+
**必须同时处理的「二次覆盖」**:
|
| 419 |
+
- 会话重开/下拉刷新会走 `history_messages`,服务端返回的是**已撤回版本**;本地合并规则「旧 `cache_version` ≤ 新」即接受更新(§7.3)→ **会用模板覆盖你本地保留的原文**。
|
| 420 |
+
- 对策:① 拦截历史响应;② 或对命中撤回特征的消息**抬高本地 `cache_version`**/拒绝覆盖;③ 或在本地库层排除这些行。
|
| 421 |
+
|
| 422 |
+
### 6.4 路线对比
|
| 423 |
+
|
| 424 |
+
| 路线 | 免 root | 稳定性 | 风控暴露 | 维护成本 | 备注 |
|
| 425 |
+
|---|---|---|---|---|---|
|
| 426 |
+
| **Xposed/LSPosed Hook** | ❌ 需 root | 高(不改包) | 中(数美可检测 Xposed) | 中(版本升级要重新定位) | **推荐**:不动签名,微信登录等不受影响 |
|
| 427 |
+
| smali 重打包 | ✅ | 中(可离线固化) | 中高(签名变化) | 高(每次发版重做) | 第三方签名绑定能力会失效 |
|
| 428 |
+
| MITM 改流 | ❌(需系统 CA/改包) | 中 | 低-中 | 低 | 只能改网络,无法改本地落库逻辑 |
|
| 429 |
+
| Frida 运行时 | ❌ 需 root | 中 | 高(易被检测) | 低 | 适合研究,不适合长期 |
|
| 430 |
+
| **自建客户端/代理** | ✅ | 高 | 低 | 中 | 走公开 API,语义自己掌控(本仓库 `ds2api` 即此路线) |
|
| 431 |
+
|
| 432 |
+
**结论**:若目标是「自己看得见 + 本地留存」,**LSPosed Hook 最划算**;若要长期稳定且能控制上下文,**代理/自建客户端**才是终局。
|
| 433 |
+
|
| 434 |
+
---
|
| 435 |
+
|
| 436 |
+
## 7. 多端同步机制
|
| 437 |
+
|
| 438 |
+
### 7.1 拉取:条件请求 + 服务端指定合并模式
|
| 439 |
+
|
| 440 |
+
请求(`p000/v21.java:549-570`):
|
| 441 |
+
|
| 442 |
+
```java
|
| 443 |
+
gf7.m8623A(l7bVar, "/api/v0/chat/history_messages");
|
| 444 |
+
zv2.mo355k0("chat_session_id", sessionId);
|
| 445 |
+
zv2.mo355k0("scenario", scenario);
|
| 446 |
+
if (cacheVersion != null && cacheResetAt != null) {
|
| 447 |
+
zv2.mo355k0("cache_version", String.valueOf(cacheVersion));
|
| 448 |
+
zv2.mo355k0("cache_reset_at", String.valueOf(cacheResetAt));
|
| 449 |
+
}
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
- `scenario` 取值:`stream_close` / `session_switch` / `manual_reload`(`p000/qcc.java:999-1005`)。
|
| 453 |
+
- `cache_version` / `cache_reset_at` 是**条件拉取**参数:本地有缓存时才带;服务端据此判断是否返回增量/全量(或 304 语义)。
|
| 454 |
+
|
| 455 |
+
响应模型(`p000/nw1.java:16-21`,`ChatHistoryMessagesResponseBizData`):
|
| 456 |
+
|
| 457 |
+
```
|
| 458 |
+
0 chat_session 1 chat_messages 2 cache_control 3 cache_reset_at
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
其中 **`cache_control`** 是服务端下发的合并指令,取值 `"REPLACE"` / `"MERGE"`:
|
| 462 |
+
|
| 463 |
+
```java
|
| 464 |
+
// p000/fta.java:443-448
|
| 465 |
+
String mode = "replace";
|
| 466 |
+
if (!"REPLACE".equals(cacheControl) && "MERGE".equals(cacheControl)) mode = "merge";
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
### 7.2 落库:版本号乐观并发
|
| 470 |
+
|
| 471 |
+
`p000/ncb.java:75-150`(仓储写入):
|
| 472 |
+
|
| 473 |
+
```java
|
| 474 |
+
Integer localVersion = existing != null ? existing.f17451a : null;
|
| 475 |
+
if (localVersion == null || localVersion <= incomingVersion) { // L89
|
| 476 |
+
... upsert session meta ...
|
| 477 |
+
update cache_version / cache_reset_at
|
| 478 |
+
set schema_version = 5
|
| 479 |
+
if (incoming.currentMessageId != null) update current_message_id
|
| 480 |
+
if ("MERGE".equals(mode)) {
|
| 481 |
+
insertOrReplace(messageList); // L123-131:保留旧行,按主键覆盖同 id
|
| 482 |
+
} else {
|
| 483 |
+
deleteAll(); // L132-143:先清空,再整体插入
|
| 484 |
+
insert(messageList);
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
```
|
| 488 |
+
|
| 489 |
+
要点:
|
| 490 |
+
- **版本号不可回退**:`localVersion > incoming` 时整次写入被丢弃(防旧数据覆盖新数据)。
|
| 491 |
+
- **`MERGE` 是 UPSERT**(主键 `message_id` 冲突即覆盖),不是「只追加」;
|
| 492 |
+
- **非 MERGE 即全量替换**:会删掉本地所有该会话消息,包括你反撤回保留下来的行 → **这是反撤回持久化的最大敌人**。
|
| 493 |
+
- `current_message_id` 由服务端下发(`wh3.f40981j`),本地 UI 的「当前叶」以它为准。
|
| 494 |
+
|
| 495 |
+
### 7.3 多端冲突:谁会赢
|
| 496 |
+
|
| 497 |
+
| 场景 | 结果 | 依据 |
|
| 498 |
+
|---|---|---|
|
| 499 |
+
| A、B 各自新增消息 | 后拉取者接受服务端版本;本地未同步的新消息可能被全量替换删掉 | `cache_control=REPLACE` + 版本比较 |
|
| 500 |
+
| A 改了消息、B 也在改 | 服务端串行处理,客户端只接受 `cache_version` 更大的结果 | L89 |
|
| 501 |
+
| 撤回在多端传播 | 新设备首次拉取只能看到 `TEMPLATE_RESPONSE`;老设备刷新后被覆盖 | 服务端权威 |
|
| 502 |
+
| 分支/变体 | **取决于服务端是否返回兄弟节点**。`history_messages` 的 `chat_messages` 是消息数组,是否包含「非当前叶分支」在客户端无法确定(**未确认**) | `nw1.f30465b` |
|
| 503 |
+
| 正在生成(WIP) | 不做跨端同步;仅本端 `resume_stream`/`auto_resume` 可能恢复 | `ChatResumeStreamRequest` |
|
| 504 |
+
|
| 505 |
+
### 7.4 实现「多端无冲突同步」的设计原则
|
| 506 |
+
|
| 507 |
+
若你要自建客户端/代理(如 `ds2api` 方向),建议:
|
| 508 |
+
|
| 509 |
+
1. **单一权威源**:以服务端 `history_messages` 为唯一真源,本地只做缓存,不做「本地新增后不回传」。
|
| 510 |
+
2. **单调版本**:每次写库必须带 `cache_version`,拒绝回退(照抄 `ncb` 的 `<=` 规则)。
|
| 511 |
+
3. **区分 REPLACE/MERGE**:`REPLACE` 前先做本地差异备份(否则撤回/重发会把你的本地补丁清空)。
|
| 512 |
+
4. **消息 id 全局唯一**:本地临时消息用负数 id(照抄 `m6116l`),**绝不复用服务端 id**,避免 upsert 误覆盖。
|
| 513 |
+
5. **分支显式建模**:不要用「数组顺序」表达分支;用 `parent_id` + 兄弟集合,并单独存 `current_leaf_id`。
|
| 514 |
+
6. **撤回状态不可变**:把 `CONTENT_FILTER` 视为**墓碑(tombstone)**,原文另存一张「shadow」表,主表只记最终状态。
|
| 515 |
+
7. **幂等写入**:以 `(session_id, message_id)` 为幂等键,重试不产生重复。
|
| 516 |
+
8. **上下文自管**:若你要「反撤回后上下文仍完整」,不要依赖服务端父链重建;在代理侧保存完整 transcript,并显式重注入。
|
| 517 |
+
|
| 518 |
+
---
|
| 519 |
+
|
| 520 |
+
## 8. 方案矩阵(针对你的三个目标)
|
| 521 |
+
|
| 522 |
+
| 目标 | 方案 | 可行性 | 代价 |
|
| 523 |
+
|---|---|---|---|
|
| 524 |
+
| **反撤回可见/可存** | LSPosed Hook patch 应用层(跳过 `fragments` clear) | ✅ 高 | 需 root;需防 `history` 覆盖 |
|
| 525 |
+
| | smali 重打包 | ✅ 中 | 签名变更、微信登录失效 |
|
| 526 |
+
| | MITM 丢帧 | ✅ 中 | 需系统 CA;只影响显示,不影响本地逻辑 |
|
| 527 |
+
| **持久化不被刷新覆盖** | 抬高本地 `cache_version` / 拦截 `REPLACE` / shadow 表 | ✅ 高 | 需改本地库逻辑(Hook 或重打包) |
|
| 528 |
+
| **让模型下一轮记得原文** | 代理自管上下文(推荐);或把原文重注入为新一轮 | ✅ | 需自建代理/客户端 |
|
| 529 |
+
| **绕过 edit 配额** | 改走 `completion` + `parent=原消息父节点` | ✅(本地/树形) | 服务端不计为 edit;多一条旧分支 |
|
| 530 |
+
| **绕过 regenerate 配额** | completion 造兄弟;或改客户端 `count>quota` 判定 | ⚠️ 仅本地 | 服务端仍可能拒绝;不改变服务端记账 |
|
| 531 |
+
| **多端一致性** | 跟随官方 `cache_version`/`cache_control` 模型 | ✅ | 需处理分支与撤回墓碑 |
|
| 532 |
+
|
| 533 |
+
---
|
| 534 |
+
|
| 535 |
+
## 9. 研究局限
|
| 536 |
+
|
| 537 |
+
1. **`edit_quota_exceed_toast` 的触发条件未确证**:`edit_quota` 字段无消费点,但 toast 有 UI 接线(`C1507y5` case 25)。二者之间缺少可静态还原的桥(R8 switch 还原不完整)。建议用 Frida 在真机 hook `x13.mo104j` 观察调用栈。
|
| 538 |
+
2. **`parent_message_id` 的服务端校验规则未知**:能否挂到非叶节点、是否会去重,需单账号轻量实验。
|
| 539 |
+
3. **`history_messages` 是否返回非当前叶分支未确认**:这直接决定多端能否看到对方新建的变体。
|
| 540 |
+
4. **重打包后各 SDK 的签名绑定失效范围**属机制推断(微信为确定项,Fly/TRTC 需实测)。
|
| 541 |
+
5. jadx 对 `vz2`/`kwb`/`C1507y5` 等大类的 switch 还原不完整;本文关键点已用「资源 ID + 字符串 + 行为」交叉核对。
|
| 542 |
+
|
| 543 |
+
---
|
| 544 |
+
|
| 545 |
+
## 附录 A:证据索引(v2.6.3)
|
| 546 |
+
|
| 547 |
+
| 主题 | 文件:行 |
|
| 548 |
+
|---|---|
|
| 549 |
+
| 节点基类 / 负 id 判定 | `p000/AbstractC0046ar.java:83-86,100-129` |
|
| 550 |
+
| 本地临时 id 递减 | `p000/C0604cs.java:621-625` |
|
| 551 |
+
| 当前叶只取正 id | `p000/C0604cs.java:262-283` |
|
| 552 |
+
| 兄弟计数(默认 5) | `p000/azb.java:2047-2067`;`p000/kwb.java:3385-3398`;`p000/vz2.java:8503-8535` |
|
| 553 |
+
| 分支切换器 | `p000/dq0.java:76-220`;`p000/v13.java:452,469` |
|
| 554 |
+
| 编辑=造分支文案 | `p000/iw7.java:2711` |
|
| 555 |
+
| 撤回后隐藏再生成 | `p000/vz2.java:8632-8648` |
|
| 556 |
+
| 配额 toast 映射 | `p000/x13.java:39-44`;`p000/g40.java:95`;`p000/C1507y5.java:395-401` |
|
| 557 |
+
| ModelConfig 字段 | `p000/va7.java:17-40`;`p000/xb7.java:74,140-154` |
|
| 558 |
+
| 并发/频率闸门 | `p000/re2.java:18-61`;`p000/se2.java:373-394` |
|
| 559 |
+
| 错误码→提示 | `p000/u38.java:256-292` |
|
| 560 |
+
| 流错误枚举 | `p000/tq1..yq1.java` |
|
| 561 |
+
| completion 描述符 | `p000/cw1.java:16-29`;模型 `ew1` |
|
| 562 |
+
| regenerate 描述符 | `p000/bd2.java:14-20`(5 字段) |
|
| 563 |
+
| edit 描述符 | `p000/ev1.java:16-24`(8 字段) |
|
| 564 |
+
| 历史请求(条件拉取) | `p000/v21.java:549-570` |
|
| 565 |
+
| 历史响应模型 | `p000/nw1.java:16-21`;`pw1` |
|
| 566 |
+
| REPLACE/MERGE 判定 | `p000/fta.java:443-448`;`p000/qcc.java:988-997` |
|
| 567 |
+
| 落库/版本比较 | `p000/ncb.java:74-150` |
|
| 568 |
+
| 会话表列 | `p000/wh3.java:56-110` |
|
| 569 |
+
| 消息表列(含 ban_edit) | `p000/vh3.java`(`ban_edit` @96) |
|
| 570 |
+
| AppStaticMessage 描述符 | `p000/C1238qx.java:20-40` |
|
| 571 |
+
| 无 pinning / 网络配置 | `apktool-out/res/xml/network_security_config.xml`;`AndroidManifest.xml` |
|
| 572 |
+
| 数美 Xposed/调试检测 | `com/ishumei/smantifraud/l1111l111111Il.java:104,131,208,322,332,486`;`l11l111I111l.java:33` |
|
| 573 |
+
|
| 574 |
+
## 附录 B:本版关键 JSON 字段
|
| 575 |
+
|
| 576 |
+
```
|
| 577 |
+
// completion
|
| 578 |
+
{ chat_session_id, parent_message_id?, prompt, ref_file_ids?, thinking_enabled?,
|
| 579 |
+
search_enabled?, audio_id?, preempt, model_type?, action? }
|
| 580 |
+
|
| 581 |
+
// regenerate
|
| 582 |
+
{ chat_session_id, child_message_id, thinking_enabled, search_enabled, user_options? }
|
| 583 |
+
|
| 584 |
+
// edit_message
|
| 585 |
+
{ chat_session_id, message_id, prompt, ref_file_ids?, thinking_enabled,
|
| 586 |
+
search_enabled, client_stream_id?, action? }
|
| 587 |
+
|
| 588 |
+
// history_messages 请求(GET query)
|
| 589 |
+
{ chat_session_id, scenario, cache_version?, cache_reset_at? }
|
| 590 |
+
|
| 591 |
+
// history_messages 响应 biz_data
|
| 592 |
+
{ chat_session, chat_messages, cache_control: "REPLACE"|"MERGE", cache_reset_at? }
|
| 593 |
+
|
| 594 |
+
// 撤回 patch(相对 response 的 BATCH)
|
| 595 |
+
{ "v":[ {"p":"ban_regenerate","v":true}, {"p":"status","v":"CONTENT_FILTER"},
|
| 596 |
+
{"p":"fragments","v":[{"type":"TEMPLATE_RESPONSE","content":"..."}]},
|
| 597 |
+
{"p":"quasi_status","v":"CONTENT_FILTER"} ] }
|
| 598 |
+
```
|
| 599 |
+
|
| 600 |
+
## 附录 C:一句话回答
|
| 601 |
+
|
| 602 |
+
- **「重发≈编辑」**:在**上下文与树形**上等价(都从同一父节点续),在**协议与服务端记账**上不等价(completion 不消耗 edit 配额、会多留一条旧分支)。
|
| 603 |
+
- **「占同一条消息」**:因为 leaf 只认正 id,失败/回滚的本地消息被跳过,新回复挂回了同一条用户消息下。
|
| 604 |
+
- **「反编译改包」**:没有应用级签名自校验,**技术上可行**;LSPosed Hook 比改包更稳;最大风险是数美风控与第三方签名绑定。
|
| 605 |
+
- **「多端同步」**:官方用 `cache_version` + `cache_control(REPLACE/MERGE)` 做乐观并发;要无冲突,必须自己补上「分支显式建模、撤回墓碑、上下文自管」三件事。
|
| 606 |
+
|
| 607 |
+
---
|
| 608 |
+
|
| 609 |
+
> 本文基于 `deepseek.apk` v2.6.3(SHA-256 `9979f876…`)的静态逆向。所有机制结论均附代码级证据;涉及服务端策略(配额归属、`parent` 校验、分支返回范围)的部分已标注为推断/未确认。
|