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` 校验、分支返回范围)的部分已标注为推断/未确认。