204848 commited on
Commit
7fb3b2e
·
1 Parent(s): 33b8f8e

feat: 对齐安卓端协议,隔离账号风控,透出文件元信息到对话历史

Browse files

基于安卓端最新抓包分析,修复以下问题:

P0 风控隔离:新增 account_identity.go,为每个账号生成独立 device_id 和 x-rangers-id,移除 x-rangers-id 全局硬编码,Account 新增 DeviceID/RangersID 字段支持用户自定义。

P1 协议对齐:create_session 移除多余 agent 字段;upload_file 移除 PoW 申请(仅 completion 需要 PoW);upload_file 恒携带 x-model-type 头;UploadFileResult 新增 AuditResult/ModelKind/SignedPath 字段。

文件审核开关:新增 file_audit.strict 配置,默认关闭(撤回机制对本项目无影响,completion 流完整推送,撤回事件在最后追加);新增 isPassedAuditResult/isFailedAuditResult 判断。

文件元信息透出(无论开关):chathistory.Entry 新增 file_infos 字段 + FileInfo 结构体;StandardRequest 新增 FileInfos,current_input_file 上传时转换并追加;chat_history.go 和 responsehistory/session.go 传递 FileInfos;前端 ChatHistoryDetail 新增 FileInfosView 组件;zh/en i18n 添加翻译。

其他:.gitignore 添加 *.har 通则排除抓包文件;新增 deepseek-recall-research.md 撤回机制研究文档。

.gitignore CHANGED
@@ -75,3 +75,4 @@ chat.deepseek.com2.har.txt
75
  chat.deepseek.com/
76
  chat.deepseek.com3.har
77
  chat.deepseek.com_2026_07_14_20_56_28.har
 
 
75
  chat.deepseek.com/
76
  chat.deepseek.com3.har
77
  chat.deepseek.com_2026_07_14_20_56_28.har
78
+ *.har
deepseek-recall-research.md ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepSeek 内容审查撤回机制研究文档
2
+
3
+ > 研究对象:`https://chat.deepseek.com` 前端 SSE(Server-Sent Events)流式响应协议
4
+ > 数据来源:实际抓包的 HAR 文件(含 `chat.deepseek.com11.har.txt` 正常会话、`2222.har.txt` 含撤回事件会话)
5
+ > 适用版本:2025 年 1 月前后前端版本(main.f7c9852789.js)
6
+ > 抓包时间戳基准:`1785306986` → 2026-05-28 左右(按 Unix 秒换算)
7
+
8
+ ---
9
+
10
+ ## 1. 研究背景
11
+
12
+ DeepSeek 网页版在生成 AI 回复后,若服务端判定回复内容触发审查策略,会执行**撤回操作**:把已流式输出给前端的完整回复替换为固定模板话术(典型如「你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。」)。
13
+
14
+ 这种行为对用户造成了双重损失:
15
+
16
+ 1. 已经看到的完整回复被无声抹除
17
+ 2. 重新生成按钮被禁用(`ban_regenerate = true`),无法换一个回答
18
+
19
+ 本研究旨在通过分析网络层 SSE 协议,厘清撤回的触发与执行机制,从而给出**比 DOM 拦截更稳定**的防撤回方案。
20
+
21
+ ---
22
+
23
+ ## 2. 撤回机制分类
24
+
25
+ 理论上,服务端实现「内容替换撤回」有两种可能路径:
26
+
27
+ ### 模式 A:服务端预判替换,原文不传输
28
+
29
+ - 服务端在生成阶段就完成审查判断
30
+ - SSE 流**从头到尾只传输**模板话术,原文从未离开服务器
31
+ - 客户端无论怎么拦截都**无法恢复原文**(原文根本没到客户端)
32
+ - 撤回消息通常**没有 THINK 片段**(因为根本没生成思考过程)
33
+
34
+ ### 模式 B:先流式原文,后发事件替换 ✅ DeepSeek 实际采用
35
+
36
+ - 服务端**先正常流式推送**完整 THINK + RESPONSE 内容(包含思考过程与正文)
37
+ - 流式推送完成后,**单独追加一个 SSE 事件**,把 `fragments` 数组整体替换为模板话术,并把状态置为 `CONTENT_FILTER`
38
+ - 客户端若**丢弃这个替换事件**,就能保留原始完整回复
39
+
40
+ ### DeepSeek 是哪种?——证据链
41
+
42
+ 通过对比 `2222.har.txt` 中一条触发撤回的 `regenerate` 请求响应,确认 DeepSeek 是**模式 B**:
43
+
44
+ | 证据 | 内容 |
45
+ |---|---|
46
+ | 事件 2 | 初始化事件,`status: "WIP"`,`fragments: [{id:2, type:"THINK", content:"嗯"}]` |
47
+ | 事件 3 ~ 6722 | 流式增量推送 THINK 与 RESPONSE 内容,累计 `accumulated_token_usage: 43946` tokens |
48
+ | 事件 6723 | 追加 TIP 片段:「本回答由 AI 生成,内容仅供参考,请仔细甄别」 |
49
+ | 事件 6724 | BATCH 设置 `quasi_status: "FINISHED"`(注意:**先到达 FINISHED**) |
50
+ | 事件 6725 | **撤回事件**:BATCH 同时设置 `status: CONTENT_FILTER` + `fragments: [TEMPLATE_RESPONSE]` + `quasi_status: CONTENT_FILTER` + `ban_regenerate: true` |
51
+ | 事件 6726 | `update_session` 更新时间戳 |
52
+ | 事件 6727 | `close` 关闭流 |
53
+
54
+ **结论**:DeepSeek 把 43946 tokens 的完整原文先全部推送给前端,再在最后用一个 BATCH 事件覆盖。这是教科书级的**模式 B**,因此**网络层拦截完全可行**。
55
+
56
+ ---
57
+
58
+ ## 3. 涉及的网络接口清单
59
+
60
+ 所有走 SSE(`text/event-stream`)的接口都共享同一套撤回协议。需要全部覆盖:
61
+
62
+ | 路径 | 方法 | 用途 | 是否会触发撤回 |
63
+ |---|---|---|---|
64
+ | `/api/v0/chat/completion` | POST | 新消息发送(用户发问后生成回复) | ✅ |
65
+ | `/api/v0/chat/regenerate` | POST | 重新生成上一条 AI 回复 | ✅(`2222.har` 即此场景) |
66
+ | `/api/v0/chat/edit_message` | POST | 编辑用户消息后重新生成 | ✅ |
67
+ | `/api/v0/chat/resume_stream` | POST | 恢复因网络中断等原因未完成的流 | ✅ |
68
+ | `/api/v0/chat/continue` | POST | 继续生成(auto_continue 触发) | ✅ |
69
+ | `/api/v0/chat/history_messages` | GET | 拉取历史消息(普通 JSON,非 SSE) | ❌ |
70
+ | `/api/v0/chat/create_pow_challenge` | POST | PoW 工作量证明挑战 | ❌ |
71
+ | `/api/v0/chat_session/create` | POST | 新建会话 | ❌ |
72
+ | `/api/v0/chat_session/fetch_page` | GET | 会话列表分页 | ❌ |
73
+ | `/api/v0/users/current` | GET | 当前用户信息 | ❌ |
74
+ | `/api/v0/client/settings` | GET | 客户端配置 | ❌ |
75
+
76
+ **注意**:`history_messages` 是普通 JSON 返回,撤回后历史里看到的就是模板话术——这意味着如果用户**刷新页面**,撤回的消息在历史里依然是模板话术(因为服务端已持久化撤回状态)。所以防撤回脚本只能在**当次会话内**生效。
77
+
78
+ ---
79
+
80
+ ## 4. SSE 协议结构
81
+
82
+ ### 4.1 物理格式
83
+
84
+ DeepSeek 的 SSE 流遵循标准 SSE 规范,但分隔符细节需注意:
85
+
86
+ - 事件块之间以 `\n\n` 分隔(连续两个 LF)
87
+ - 每个事件块内部由若干行组成,行分隔符为 `\n`
88
+ - 每行格式为 `字段名: 值`,常见字段:
89
+ - `event:` 事件类型(如 `ready` / `update_session` / `close`)
90
+ - `data:` 数据载荷(JSON 字符串)
91
+ - **没有 `event:` 行的事件块**就是默认事件,前端通过 `data:` 内容判断语义
92
+ - 实际抓包中文本里可见多个 `data:` 紧凑排列(如 `data: {...} data: {...}`),这是因为浏览器 DevTools 在显示时把同一事件块内的多行 data 合并展示了;**真实字节流中仍然是 `\n\n` 分隔**,拦截时按 `\n\n` 切分即可
93
+
94
+ ### 4.2 事件块切分示例
95
+
96
+ 原始字节流片段(示意):
97
+
98
+ ```
99
+ event: ready
100
+ data: {"request_message_id":1,"response_message_id":3,"model_type":"default"}
101
+
102
+ event: update_session
103
+ data: {"updated_at":1785308167.534395}
104
+
105
+ data: {"v":{"response":{"message_id":3,...,"status":"WIP",...}}}
106
+
107
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"嗯"}
108
+
109
+ data: {"v":","}
110
+ ```
111
+
112
+ 按 `\n\n` 切分会得到 5 个事件块。
113
+
114
+ ### 4.3 三类事件类型
115
+
116
+ | 类别 | 标识 | 作用 |
117
+ |---|---|---|
118
+ | 控制事件 | `event: ready` / `event: update_session` / `event: close` | 流生命周期管理 |
119
+ | 全量初始化 | `data: {"v":{"response":{...}}}` | 流起始时推送完整 response 对象 |
120
+ | JSON Patch | `data: {"p":"路径","o":"操作","v":值}` 或 `data: {"v":[{...patch...}]}` | 增量更新 response 对象 |
121
+
122
+ ---
123
+
124
+ ## 5. 数据模型:response 对象
125
+
126
+ SSE 流操作的核心对象是 `response`,结构如下(来自事件 2 的初始化):
127
+
128
+ ```jsonc
129
+ {
130
+ "message_id": 3, // 该 AI 回复的唯一 ID
131
+ "parent_id": 1, // 父消息(用户提问)ID
132
+ "model": "", // 模型名(前端配置注入)
133
+ "role": "ASSISTANT", // 角色
134
+ "thinking_enabled": true, // 是否启用深度思考
135
+ "ban_edit": false, // 禁止编辑
136
+ "ban_regenerate": false, // 禁止重新生成(撤回时被改为 true)
137
+ "status": "WIP", // 状态:WIP / FINISHED / CONTENT_FILTER
138
+ "incomplete_message": null,
139
+ "accumulated_token_usage": 0, // 累计 token 消耗
140
+ "feedback": null,
141
+ "inserted_at": 1785308167.527009,
142
+ "search_enabled": false, // 是否启用联网搜索
143
+ "fragments": [ // 内容片段数组(核心)
144
+ {
145
+ "id": 2,
146
+ "type": "THINK", // THINK / RESPONSE / TIP / TEMPLATE_RESPONSE
147
+ "content": "嗯",
148
+ "elapsed_secs": null, // 思考耗时
149
+ "references": [],
150
+ "stage_id": 1
151
+ }
152
+ ],
153
+ "conversation_mode": "DEFAULT",
154
+ "has_pending_fragment": false,
155
+ "auto_continue": false,
156
+ "search_triggered": false
157
+ }
158
+ ```
159
+
160
+ ### 5.1 fragments 类型枚举
161
+
162
+ | type | 含义 | 何时出现 |
163
+ |---|---|---|
164
+ | `THINK` | 深度思考过程 | `thinking_enabled:true` 时,作为第一个 fragment |
165
+ | `RESPONSE` | 正文回复 | 思考结束后追加 |
166
+ | `TIP` | 提示横幅(如「AI 生成,仅供参考」) | 流末尾追加 |
167
+ | `TEMPLATE_RESPONSE` | **撤回模板话术** | **仅在撤回事件中出现**,替换原有 fragments |
168
+
169
+ ### 5.2 状态机
170
+
171
+ ```
172
+ ┌─────────┐
173
+ │ WIP │ ← 初始
174
+ └────┬────┘
175
+ │ 流式推送中
176
+
177
+ ┌─────────┐
178
+ │FINISHED │ ← 正常完成(事件 6724)
179
+ └────┬────┘
180
+ │ 触发审查
181
+
182
+ ┌──────────────┐
183
+ │CONTENT_FILTER│ ← 撤回(事件 6725)
184
+ └──────────────┘
185
+ ```
186
+
187
+ **关键观察**:撤回事件是在 `FINISHED` **之后**才到达的。这意味着撤回不是「生成中途被掐断」,而是「生成完成后追加的二次修改」。
188
+
189
+ ---
190
+
191
+ ## 6. JSON Patch 协议
192
+
193
+ DeepSeek 用一种类 JSON Patch 的协议增量更新 `response` 对象。每个 `data:` 行是一个 patch。
194
+
195
+ ### 6.1 单 patch 格式
196
+
197
+ ```jsonc
198
+ {
199
+ "p": "response/fragments/-1/content", // 路径,-1 表示数组末尾
200
+ "o": "APPEND", // 操作:APPEND / SET / BATCH 等
201
+ "v": "要追加的文本" // 值
202
+ }
203
+ ```
204
+
205
+ | 字段 | 含义 |
206
+ |---|---|
207
+ | `p` | 目标路径,用 `/` 分隔;`-1` 代表数组末元素 |
208
+ | `o` | 操作类型,可省略(默认 APPEND) |
209
+ | `v` | 值;可以是字符串、数字、对象、数组 |
210
+
211
+ ### 6.2 简写格式
212
+
213
+ ```jsonc
214
+ { "v": "一段文字" }
215
+ ```
216
+
217
+ 当只有 `v` 字段时,等价于对「上一次 patch 的同一路径」继续 APPEND。这是为了流式推送 token 时减少包体大小——每个 token 一个事件,路径不重复发送。
218
+
219
+ ### 6.3 BATCH 操作
220
+
221
+ ```jsonc
222
+ {
223
+ "p": "response",
224
+ "o": "BATCH",
225
+ "v": [
226
+ { "p": "accumulated_token_usage", "v": 43946 },
227
+ { "p": "quasi_status", "v": "FINISHED" }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ 一次应用多个 patch。`v` 是 patch 数组,每个元素本身就是一个 patch(但路径相对于 `p` 指定的根)。
233
+
234
+ ### 6.4 典型流程示例
235
+
236
+ 完整生成一条回复的 patch 序列:
237
+
238
+ ```
239
+ 1. {"v":{"response":{完整对象,fragments:[{THINK:""}]}}} // 初始化
240
+ 2. {"p":"response/fragments/-1/content","o":"APPEND","v":"嗯"} // 首个思考 token
241
+ 3. {"v":","} // 简写,继续追加到同一 content
242
+ 4. {"v":"用户"}
243
+ ...(数千个 token 增量)...
244
+ 5. {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":2.247} // 思考耗时
245
+ 6. {"p":"response/fragments","o":"APPEND","v":[{id:3,type:"RESPONSE",content:"收到"}]} // 追加正文片段
246
+ 7. {"p":"response/fragments/-1/content","o":"APPEND","v":"!"} // 正文首 token
247
+ ...(正文 token 流)...
248
+ 8. {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":311},{"p":"quasi_status","v":"FINISHED"}]} // 完成
249
+ 9. {"p":"response/status","o":"SET","v":"FINISHED"}
250
+ ```
251
+
252
+ ---
253
+
254
+ ## 7. 撤回事件详细剖析
255
+
256
+ ### 7.1 原始事件
257
+
258
+ 来自 `2222.har.txt` 中 `regenerate` 响应的事件 6725:
259
+
260
+ ```json
261
+ {
262
+ "v": [
263
+ { "p": "ban_regenerate", "v": true },
264
+ { "p": "status", "v": "CONTENT_FILTER" },
265
+ { "p": "fragments", "v": [
266
+ { "id": 5, "type": "TEMPLATE_RESPONSE", "content": "你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。" }
267
+ ]},
268
+ { "p": "quasi_status", "v": "CONTENT_FILTER" }
269
+ ]
270
+ }
271
+ ```
272
+
273
+ 注意:这个事件**没有 `p` 和 `o` 顶层字段**,只有一个 `v` 数组。前端代码会识别这种格式,把 `v` 当作相对 `response` 的 BATCH 处理。
274
+
275
+ ### 7.2 撤回事件做了什么
276
+
277
+ 按 patch 顺序应用到 `response` 对象:
278
+
279
+ | 序号 | patch | 效果 |
280
+ |---|---|---|
281
+ | 1 | `ban_regenerate = true` | **禁用「重新生成」按钮** |
282
+ | 2 | `status = "CONTENT_FILTER"` | 标记消息为审查过滤状态 |
283
+ | 3 | `fragments = [{TEMPLATE_RESPONSE, "你好..."}]` | **整体替换 fragments 数组**,原有 THINK+RESPONSE+TIP 全部丢失 |
284
+ | 4 | `quasi_status = "CONTENT_FILTER"` | 同步更新准状态 |
285
+
286
+ ### 7.3 撤回事件的识别特征
287
+
288
+ 撤回事件块的字节内容**必然同时包含**以下两个特征字符串:
289
+
290
+ - `"CONTENT_FILTER"` —— 状态标记
291
+ - `"TEMPLATE_RESPONSE"` —— 模板片段类型
292
+
293
+ 而且这两个字符串在**正常流式事件中永远不会出现**(正常片段类型只有 `THINK` / `RESPONSE` / `TIP`,正常状态只有 `WIP` / `FINISHED`)。
294
+
295
+ 这是**最稳定**的拦截特征:不依赖具体话术内容,即使 DeepSeek 修改模板文案也不影响识别。
296
+
297
+ ### 7.4 撤回事件的位置
298
+
299
+ 撤回事件**永远在流末尾**,紧邻 `event: update_session` 和 `event: close` 之前。这是因为:
300
+
301
+ - 必须先把原文流式推送完,才能判断是否撤回
302
+ - `FINISHED` 状态(事件 6724)先于 `CONTENT_FILTER`(事件 6725)到达
303
+ - 撤回事件之后只剩会话时间戳更新和连接关闭
304
+
305
+ ---
306
+
307
+ ## 8. 拦截方案
308
+
309
+ ### 8.1 方案对比
310
+
311
+ | 方案 | 拦截点 | 稳定性 | 适用性 |
312
+ |---|---|---|---|
313
+ | DOM 关键词匹配 | 前端渲染后 | ❌ 易失效(依赖 class 名) | 旧版脚本 v7.x |
314
+ | SSE 事件过滤 | 网络层 | ✅ 稳定 | **推荐**(v8.0) |
315
+
316
+ ### 8.2 实现要点
317
+
318
+ DeepSeek 前端(基于 `main.f7c9852789.js` 分析)使用 **axios + XHR** 处理 SSE,不是 `fetch` 也不是 `EventSource`:
319
+
320
+ ```javascript
321
+ // 简化后的前端核心逻辑
322
+ xhr.onDownloadProgress = (e) => {
323
+ parser.parse(xhr.responseText, { logId });
324
+ };
325
+ ```
326
+
327
+ 因此必须劫持 `XMLHttpRequest.prototype.responseText` 的 getter,在每次读取时增量解析 SSE 事件并丢弃撤回事件。
328
+
329
+ ### 8.3 拦截算法
330
+
331
+ ```
332
+ 输入:xhr.responseText(持续增长的字符串)
333
+ 输出:过滤后的 responseText(撤回事件被丢弃)
334
+
335
+ 状态变量:
336
+ filteredOutput = "" // 已过滤的累积输出
337
+ pendingChunk = "" // 跨 read 的不完整事件块
338
+ consumedLen = 0 // 已消费的原始长度
339
+
340
+ 每次 getter 被调用:
341
+ 1. original = 原始 responseText
342
+ 2. newPart = original.slice(consumedLen)
343
+ 3. consumedLen = original.length
344
+ 4. combined = pendingChunk + newPart
345
+ 5. 按 "\n\n" 切分 combined
346
+ - 完整事件块:
347
+ if 包含 "CONTENT_FILTER" 或 "TEMPLATE_RESPONSE": 丢弃
348
+ else: 追加到 filteredOutput
349
+ - 不完整尾部:存入 pendingChunk
350
+ 6. readyState=4 时 flush 剩余 pendingChunk
351
+ 7. 返回 filteredOutput
352
+ ```
353
+
354
+ ### 8.4 fetch 双保险
355
+
356
+ 虽然当前前端用 XHR,但为应对未来改用 `fetch + ReadableStream`,应同时劫持 `window.fetch`,用 `TransformStream` 同样过滤事件块。
357
+
358
+ ### 8.5 拦截后的效果
359
+
360
+ - 前端拿到的 SSE 流**不含撤回事件**
361
+ - `response.status` 保持 `FINISHED`(撤回前的最后状态)
362
+ - `response.fragments` 保持原有 `THINK + RESPONSE + TIP`
363
+ - `ban_regenerate` 保持 `false`,**重新生成按钮可用**
364
+ - 用户看到完整的原始回复
365
+
366
+ ---
367
+
368
+ ## 9. 局限性与边界情况
369
+
370
+ ### 9.1 已知局限
371
+
372
+ | 局限 | 说明 |
373
+ |---|---|
374
+ | 历史消息无法恢复 | `history_messages` 返回的是服务端已持久化的撤回后状态,刷新页面后撤回消息仍是模板话术 |
375
+ | 仅当次会话生效 | 防撤回只在 SSE 流到达前端的瞬间生效,不能事后补救 |
376
+ | 依赖 `CONTENT_FILTER`/`TEMPLATE_RESPONSE` 标识 | 若 DeepSeek 改协议(如只用 `ban_regenerate` 不改状态),需重新分析 |
377
+
378
+ ### 9.2 不会触发误拦截的情况
379
+
380
+ - `accumulated_token_usage` 等数字字段不会包含撤回标识
381
+ - `THINK` / `RESPONSE` / `TIP` 与 `CONTENT_FILTER` / `TEMPLATE_RESPONSE` 字符串无重叠
382
+ - 用户消息中即使包含 "CONTENT_FILTER" 字面量也不会被误拦——因为用户消息走 REQUEST fragment,不会被服务端标 `TEMPLATE_RESPONSE`
383
+
384
+ ### 9.3 兼容性
385
+
386
+ - **XHR 拦截**:适用于当前 DeepSeek 前端
387
+ - **fetch 拦截**:面向未来,当 DeepSeek 改用 fetch 时自动生效
388
+ - **EventSource**:DeepSeek 不用 EventSource(因为 EventSource 不支持 POST 和自定义 header),无需处理
389
+
390
+ ---
391
+
392
+ ## 10. 参考资料
393
+
394
+ ### 10.1 抓包文件
395
+
396
+ - `chat.deepseek.com11.har.txt`:正常会话,含 completion/regenerate/edit_message/resume_stream/history_messages
397
+ - `2222.har.txt`:含撤回事件的 regenerate 响应(关键证据)
398
+ - `chat.deepseek.com/` 目录:前端静态资源与 API 响应存档
399
+
400
+ ### 10.2 关键前端文件
401
+
402
+ - `fe-static.deepseek.com/chat/static/main.f7c9852789.js` 第 41620 行附近:SSE 请求路由与 onDownloadProgress 处理逻辑
403
+
404
+ ### 10.3 相关 SSE 标准
405
+
406
+ - [Server-Sent Events - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
407
+ - [Using Server-Sent Events - HTML Spec](https://html.spec.whatwg.org/multipage/server-sent-events.html)
408
+
409
+ ---
410
+
411
+ ## 附录 A:完整撤回事件序列(来自 2222.har 事件 6723-6727)
412
+
413
+ ```
414
+ event 6723:
415
+ data: {"p":"response/fragments","v":[{"id":4,"type":"TIP","content":"本回答由 AI 生成,内容仅供参考,请仔细甄别","style":"WARNING","hide_on_wip":true}]}
416
+
417
+ event 6724:
418
+ data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":43946},{"p":"quasi_status","v":"FINISHED"}]}
419
+
420
+ event 6725 (撤回事件):
421
+ data: {"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"}]}
422
+
423
+ event 6726:
424
+ event: update_session
425
+ data: {"updated_at":1785308262.511232}
426
+
427
+ event 6727:
428
+ event: close
429
+ data: {"click_behavior":"none","auto_resume":false}
430
+ ```
431
+
432
+ ## 附录 B:正常完成事件序列(对比)
433
+
434
+ 来自 `chat.deepseek.com11.har.txt` 中 `completion` 响应末尾:
435
+
436
+ ```
437
+ data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":311},{"p":"quasi_status","v":"FINISHED"}]}
438
+
439
+ data: {"p":"response/status","o":"SET","v":"FINISHED"}
440
+
441
+ event: update_session
442
+ data: {"updated_at":1785307062.969684}
443
+
444
+ event: close
445
+ data: {"click_behavior":"none","auto_resume":false}
446
+ ```
447
+
448
+ **对比结论**:正常完成时最后一个数据事件是 `status=FINISHED`,撤回时则多出一个含 `CONTENT_FILTER` 的 BATCH 事件。这是唯一差异。
internal/chathistory/store.go CHANGED
@@ -57,6 +57,7 @@ type Entry struct {
57
  ElapsedMs int64 `json:"elapsed_ms,omitempty"`
58
  FinishReason string `json:"finish_reason,omitempty"`
59
  Usage map[string]any `json:"usage,omitempty"`
 
60
  }
61
 
62
  type Message struct {
@@ -64,6 +65,22 @@ type Message struct {
64
  Content string `json:"content"`
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  type SummaryEntry struct {
68
  ID string `json:"id"`
69
  Revision int64 `json:"revision"`
@@ -101,6 +118,7 @@ type StartParams struct {
101
  Messages []Message
102
  HistoryText string
103
  FinalPrompt string
 
104
  }
105
 
106
  type UpdateParams struct {
@@ -281,6 +299,7 @@ func (s *Store) Start(params StartParams) (Entry, error) {
281
  Messages: cloneMessages(params.Messages),
282
  HistoryText: params.HistoryText,
283
  FinalPrompt: strings.TrimSpace(params.FinalPrompt),
 
284
  }
285
  s.details[entry.ID] = entry
286
  s.markDetailDirtyLocked(entry.ID)
@@ -786,6 +805,7 @@ func cloneFile(in File) File {
786
  func cloneEntry(item Entry) Entry {
787
  item.Usage = cloneMap(item.Usage)
788
  item.Messages = cloneMessages(item.Messages)
 
789
  return item
790
  }
791
 
@@ -808,3 +828,12 @@ func cloneMessages(messages []Message) []Message {
808
  copy(out, messages)
809
  return out
810
  }
 
 
 
 
 
 
 
 
 
 
57
  ElapsedMs int64 `json:"elapsed_ms,omitempty"`
58
  FinishReason string `json:"finish_reason,omitempty"`
59
  Usage map[string]any `json:"usage,omitempty"`
60
+ FileInfos []FileInfo `json:"file_infos,omitempty"`
61
  }
62
 
63
  type Message struct {
 
65
  Content string `json:"content"`
66
  }
67
 
68
+ // FileInfo 记录上传文件的元信息(审核状态、模型类型、签名路径等)。
69
+ // 无论 file_audit.strict 是否开启,都会无条件透出到 chat history,
70
+ // 便于事后排查审核未通过 / 撤回等问题。
71
+ type FileInfo struct {
72
+ ID string `json:"id"`
73
+ Filename string `json:"filename,omitempty"`
74
+ Bytes int64 `json:"bytes,omitempty"`
75
+ Status string `json:"status,omitempty"`
76
+ Purpose string `json:"purpose,omitempty"`
77
+ IsImage bool `json:"is_image,omitempty"`
78
+ AuditResult string `json:"audit_result,omitempty"`
79
+ ModelKind string `json:"model_kind,omitempty"`
80
+ SignedPath string `json:"signed_path,omitempty"`
81
+ Source string `json:"source,omitempty"` // current_input_history / current_input_tools / attachment / inline
82
+ }
83
+
84
  type SummaryEntry struct {
85
  ID string `json:"id"`
86
  Revision int64 `json:"revision"`
 
118
  Messages []Message
119
  HistoryText string
120
  FinalPrompt string
121
+ FileInfos []FileInfo
122
  }
123
 
124
  type UpdateParams struct {
 
299
  Messages: cloneMessages(params.Messages),
300
  HistoryText: params.HistoryText,
301
  FinalPrompt: strings.TrimSpace(params.FinalPrompt),
302
+ FileInfos: cloneFileInfos(params.FileInfos),
303
  }
304
  s.details[entry.ID] = entry
305
  s.markDetailDirtyLocked(entry.ID)
 
805
  func cloneEntry(item Entry) Entry {
806
  item.Usage = cloneMap(item.Usage)
807
  item.Messages = cloneMessages(item.Messages)
808
+ item.FileInfos = cloneFileInfos(item.FileInfos)
809
  return item
810
  }
811
 
 
828
  copy(out, messages)
829
  return out
830
  }
831
+
832
+ func cloneFileInfos(infos []FileInfo) []FileInfo {
833
+ if len(infos) == 0 {
834
+ return nil
835
+ }
836
+ out := make([]FileInfo, len(infos))
837
+ copy(out, infos)
838
+ return out
839
+ }
internal/config/account_identity.go ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "crypto/sha1"
5
+ "encoding/base64"
6
+ "encoding/hex"
7
+ "strings"
8
+ )
9
+
10
+ // AndroidAccountDeviceID 返回用于登录请求体 device_id 字段的值。
11
+ //
12
+ // 优先级:
13
+ // 1. 用户在 Account.DeviceID 中显式填入(从自己手机抓包得到,最贴近真实设备)
14
+ // 2. 基于账号标识稳定生成的伪随机 Base64 blob(前缀 'B',64 字节随机部分)
15
+ //
16
+ // 关键约束:同一账号多次登录必须返回相同的 device_id,否则会被服务端识别为异常。
17
+ // 多账号之间必须互不相同,否则会被识别为"多账号共用设备"批量风控。
18
+ //
19
+ // 注意:生成值仅为格式合规的伪随机字符串,无法通过真实数美 SDK 校验;但对于
20
+ // "把同 device_id 用于多个账号"这种最危险的行为,已能起到隔离作用。如有条件,
21
+ // 仍建议用户从自己手机抓包填入真实 device_id。
22
+ func AndroidAccountDeviceID(a Account) string {
23
+ if v := strings.TrimSpace(a.DeviceID); v != "" {
24
+ return v
25
+ }
26
+ return stableDeviceIDBlob(a.Identifier())
27
+ }
28
+
29
+ // AndroidAccountRangersID 返回用于 x-rangers-id 请求头的值。
30
+ //
31
+ // 优先级同上:用户填入 > 基于账号稳定生成的 19 位数字串(火山 Rangers 雪花 ID 形态)。
32
+ func AndroidAccountRangersID(a Account) string {
33
+ if v := strings.TrimSpace(a.RangersID); v != "" {
34
+ return v
35
+ }
36
+ return stableRangersID(a.Identifier())
37
+ }
38
+
39
+ // stableDeviceIDBlob 基于账号标识生成 88 字符 Base64 blob。
40
+ // 真实抓包样本形如 "Bl8iWjqVX4Dk5499zJlNaIJhA8NxytolmrlcsH4kGovL2RPyDp7DznKfuKWUZeP6mzQNlbfqoeQjbEp4/qVdLBw=="
41
+ // (前缀 B + 64 字节随机 base64,约 88 字符)。本函数用 sha1 派生 20 字节,再 base64 编码。
42
+ // 仍能保证同账号稳定、跨账号不同;格式上与真实 blob 一致。
43
+ func stableDeviceIDBlob(identifier string) string {
44
+ if identifier == "" {
45
+ identifier = "anonymous"
46
+ }
47
+ // 用账号标识派生 64 字节伪随机内容:把 sha1(identifier) 重复扩展到 64 字节。
48
+ seed := sha1.Sum([]byte("ds2api:device_id:" + identifier))
49
+ buf := make([]byte, 64)
50
+ for i := range buf {
51
+ buf[i] = seed[i%len(seed)]
52
+ }
53
+ return "B" + base64.StdEncoding.EncodeToString(buf)
54
+ }
55
+
56
+ // stableRangersID 基于账号标识生成 19 位数字串(火山 Rangers ID 形态,如 "7657846390059450882")。
57
+ // 用 sha1 派生并截断到 19 位十进制。
58
+ func stableRangersID(identifier string) string {
59
+ if identifier == "" {
60
+ identifier = "anonymous"
61
+ }
62
+ seed := sha1.Sum([]byte("ds2api:rangers_id:" + identifier))
63
+ hexStr := hex.EncodeToString(seed[:]) // 40 hex chars
64
+ // 取前 19 位十进制数字(hex 字符 0-9 直接用,a-f 映射为 0-5)
65
+ var b strings.Builder
66
+ for i := 0; i < 19 && i < len(hexStr); i++ {
67
+ c := hexStr[i]
68
+ switch {
69
+ case c >= '0' && c <= '9':
70
+ b.WriteByte(c)
71
+ default:
72
+ // a-f → '0'..'5'
73
+ b.WriteByte('0' + (c - 'a'))
74
+ }
75
+ }
76
+ // 不足 19 位时用 hex 后续位补齐
77
+ for b.Len() < 19 {
78
+ b.WriteByte('0')
79
+ }
80
+ out := b.String()
81
+ if len(out) > 19 {
82
+ out = out[:19]
83
+ }
84
+ return out
85
+ }
internal/config/codec.go CHANGED
@@ -45,6 +45,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
45
  if c.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 {
46
  m["current_input_file"] = c.CurrentInputFile
47
  }
 
 
 
48
  if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
49
  m["thinking_injection"] = c.ThinkingInjection
50
  }
@@ -127,6 +130,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
127
  if err := json.Unmarshal(v, &c.CurrentInputFile); err != nil {
128
  return fmt.Errorf("invalid field %q: %w", k, err)
129
  }
 
 
 
 
130
  case "thinking_injection":
131
  if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
132
  return fmt.Errorf("invalid field %q: %w", k, err)
@@ -174,6 +181,9 @@ func (c Config) Clone() Config {
174
  Enabled: cloneBoolPtr(c.CurrentInputFile.Enabled),
175
  MinChars: c.CurrentInputFile.MinChars,
176
  },
 
 
 
177
  ThinkingInjection: ThinkingInjectionConfig{
178
  Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
179
  Prompt: c.ThinkingInjection.Prompt,
 
45
  if c.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 {
46
  m["current_input_file"] = c.CurrentInputFile
47
  }
48
+ if c.FileAudit.Strict != nil {
49
+ m["file_audit"] = c.FileAudit
50
+ }
51
  if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
52
  m["thinking_injection"] = c.ThinkingInjection
53
  }
 
130
  if err := json.Unmarshal(v, &c.CurrentInputFile); err != nil {
131
  return fmt.Errorf("invalid field %q: %w", k, err)
132
  }
133
+ case "file_audit":
134
+ if err := json.Unmarshal(v, &c.FileAudit); err != nil {
135
+ return fmt.Errorf("invalid field %q: %w", k, err)
136
+ }
137
  case "thinking_injection":
138
  if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
139
  return fmt.Errorf("invalid field %q: %w", k, err)
 
181
  Enabled: cloneBoolPtr(c.CurrentInputFile.Enabled),
182
  MinChars: c.CurrentInputFile.MinChars,
183
  },
184
+ FileAudit: FileAuditConfig{
185
+ Strict: cloneBoolPtr(c.FileAudit.Strict),
186
+ },
187
  ThinkingInjection: ThinkingInjectionConfig{
188
  Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
189
  Prompt: c.ThinkingInjection.Prompt,
internal/config/config.go CHANGED
@@ -19,6 +19,7 @@ type Config struct {
19
  Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
20
  AutoDelete AutoDeleteConfig `json:"auto_delete"`
21
  CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
 
22
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
23
  Vercel VercelConfig `json:"vercel,omitempty"`
24
  Platform PlatformConfig `json:"platform,omitempty"`
@@ -28,15 +29,17 @@ type Config struct {
28
  }
29
 
30
  type Account struct {
31
- Name string `json:"name,omitempty"`
32
- Remark string `json:"remark,omitempty"`
33
- Email string `json:"email,omitempty"`
34
- Mobile string `json:"mobile,omitempty"`
35
- Password string `json:"password,omitempty"`
36
- Token string `json:"token,omitempty"`
37
- ProxyID string `json:"proxy_id,omitempty"`
38
- Role string `json:"role,omitempty"` // "normal" (default) or "standby"
39
- Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
 
 
40
  }
41
 
42
  type APIKey struct {
@@ -201,6 +204,15 @@ type CurrentInputFileConfig struct {
201
  MinChars int `json:"min_chars,omitempty"`
202
  }
203
 
 
 
 
 
 
 
 
 
 
204
  type ThinkingInjectionConfig struct {
205
  Enabled *bool `json:"enabled,omitempty"`
206
  Prompt string `json:"prompt,omitempty"`
 
19
  Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
20
  AutoDelete AutoDeleteConfig `json:"auto_delete"`
21
  CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
22
+ FileAudit FileAuditConfig `json:"file_audit,omitempty"`
23
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
24
  Vercel VercelConfig `json:"vercel,omitempty"`
25
  Platform PlatformConfig `json:"platform,omitempty"`
 
29
  }
30
 
31
  type Account struct {
32
+ Name string `json:"name,omitempty"`
33
+ Remark string `json:"remark,omitempty"`
34
+ Email string `json:"email,omitempty"`
35
+ Mobile string `json:"mobile,omitempty"`
36
+ Password string `json:"password,omitempty"`
37
+ Token string `json:"token,omitempty"`
38
+ ProxyID string `json:"proxy_id,omitempty"`
39
+ Role string `json:"role,omitempty"` // "normal" (default) or "standby"
40
+ Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
41
+ DeviceID string `json:"device_id,omitempty"` // 可选:用户从自己手机抓包填入的数美 device_id(Base64 blob)。未填则基于账号稳定生成。
42
+ RangersID string `json:"rangers_id,omitempty"` // 可选:用户从自己手机抓包填入的火山 x-rangers-id。未填则基于账号稳定生成。
43
  }
44
 
45
  type APIKey struct {
 
204
  MinChars int `json:"min_chars,omitempty"`
205
  }
206
 
207
+ // FileAuditConfig 控制文件上传后的审核校验行为。
208
+ // 默认关闭:与历史行为一致,文件 status=SUCCESS 即视为就绪(不阻塞等待审核结果)。
209
+ // 开启 strict 后:轮询 fetch_files 时除 status=SUCCESS 外,还要求 audit_result=pass
210
+ // 才视为就绪;若审核失败(reject 等)会返回错误。
211
+ // 无论是否开启,文件审核状态都会记录到 chat history 元信息(便于观察)。
212
+ type FileAuditConfig struct {
213
+ Strict *bool `json:"strict,omitempty"`
214
+ }
215
+
216
  type ThinkingInjectionConfig struct {
217
  Enabled *bool `json:"enabled,omitempty"`
218
  Prompt string `json:"prompt,omitempty"`
internal/config/store_accessors.go CHANGED
@@ -162,6 +162,18 @@ func (s *Store) CurrentInputFileMinChars() int {
162
  return s.cfg.CurrentInputFile.MinChars
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  func (s *Store) ThinkingInjectionEnabled() bool {
166
  s.mu.RLock()
167
  defer s.mu.RUnlock()
 
162
  return s.cfg.CurrentInputFile.MinChars
163
  }
164
 
165
+ // FileAuditStrictEnabled 返回是否启用文件审核严格模式。
166
+ // 默认关闭(与历史行为一致):文件 status=SUCCESS 即视为就绪。
167
+ // 开启后:还要求 audit_result=pass 才视为就绪。
168
+ func (s *Store) FileAuditStrictEnabled() bool {
169
+ s.mu.RLock()
170
+ defer s.mu.RUnlock()
171
+ if s.cfg.FileAudit.Strict == nil {
172
+ return false
173
+ }
174
+ return *s.cfg.FileAudit.Strict
175
+ }
176
+
177
  func (s *Store) ThinkingInjectionEnabled() bool {
178
  s.mu.RLock()
179
  defer s.mu.RUnlock()
internal/deepseek/client/client_auth.go CHANGED
@@ -2,7 +2,9 @@ package client
2
 
3
  import (
4
  "context"
 
5
  dsprotocol "ds2api/internal/deepseek/protocol"
 
6
  "errors"
7
  "fmt"
8
  "net/http"
@@ -20,6 +22,9 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
20
  payload := map[string]any{
21
  "password": strings.TrimSpace(acc.Password),
22
  }
 
 
 
23
  if platform == "web" {
24
  deviceID, err := shumei.GetDeviceID(ctx)
25
  if err != nil {
@@ -29,7 +34,9 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
29
  payload["device_id"] = deviceID
30
  payload["os"] = "web"
31
  } else {
32
- payload["device_id"] = "android_device"
 
 
33
  payload["os"] = "android"
34
  }
35
  if email := strings.TrimSpace(acc.Email); email != "" {
@@ -41,7 +48,7 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
41
  } else {
42
  return "", errors.New("missing email/mobile")
43
  }
44
- resp, err := c.postJSON(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekLoginURL, dsprotocol.BaseHeaders, payload)
45
  if err != nil {
46
  return "", err
47
  }
@@ -70,8 +77,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
70
  attempts := 0
71
  refreshed := false
72
  for attempts < maxAttempts {
73
- headers := c.authHeaders(a.DeepSeekToken)
74
- resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
 
75
  if err != nil {
76
  config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
77
  attempts++
@@ -121,7 +129,7 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
121
  lastFailureKind := FailureUnknown
122
  lastFailureMessage := ""
123
  for attempts < maxAttempts {
124
- headers := c.authHeaders(a.DeepSeekToken)
125
  resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath})
126
  if err != nil {
127
  config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath)
@@ -170,13 +178,36 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
170
  return "", errors.New("get pow failed")
171
  }
172
 
 
 
 
 
173
  func (c *Client) authHeaders(token string) map[string]string {
174
- headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  for k, v := range dsprotocol.BaseHeaders {
176
  headers[k] = v
177
  }
178
- headers["authorization"] = "Bearer " + token
179
-
 
180
  if dsprotocol.IsWebPlatform() {
181
  for k, v := range dsprotocol.WebExtraHeaders() {
182
  headers[k] = v
@@ -186,10 +217,43 @@ func (c *Client) authHeaders(token string) map[string]string {
186
  headers[k] = v
187
  }
188
  }
 
 
 
189
  }
190
  return headers
191
  }
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
194
  msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
195
  if status == http.StatusUnauthorized || status == http.StatusForbidden {
 
2
 
3
  import (
4
  "context"
5
+ "crypto/sha1"
6
  dsprotocol "ds2api/internal/deepseek/protocol"
7
+ "encoding/hex"
8
  "errors"
9
  "fmt"
10
  "net/http"
 
22
  payload := map[string]any{
23
  "password": strings.TrimSpace(acc.Password),
24
  }
25
+ // 登录请求头需要带 x-rangers-id(HAR entry 51 显示登录请求也带这个头)。
26
+ // 但登录请求不带 Authorization(用户尚未拿到 token)。
27
+ loginHeaders := c.authHeadersWithRangers("", config.AndroidAccountRangersID(acc))
28
  if platform == "web" {
29
  deviceID, err := shumei.GetDeviceID(ctx)
30
  if err != nil {
 
34
  payload["device_id"] = deviceID
35
  payload["os"] = "web"
36
  } else {
37
+ // Android:使用账号级 device_id(用户自填 > 基于账号稳定生成)。
38
+ // 不再硬编码 "android_device",避免多账号共用同一 device_id 触发批量风控。
39
+ payload["device_id"] = config.AndroidAccountDeviceID(acc)
40
  payload["os"] = "android"
41
  }
42
  if email := strings.TrimSpace(acc.Email); email != "" {
 
48
  } else {
49
  return "", errors.New("missing email/mobile")
50
  }
51
+ resp, err := c.postJSON(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekLoginURL, loginHeaders, payload)
52
  if err != nil {
53
  return "", err
54
  }
 
77
  attempts := 0
78
  refreshed := false
79
  for attempts < maxAttempts {
80
+ headers := c.authHeadersWithAuth(a)
81
+ // Android 真实客户端一致:发送空 body,由服务端默认创建 agent=chat 会话。
82
+ resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreateSessionURL, headers, nil)
83
  if err != nil {
84
  config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
85
  attempts++
 
129
  lastFailureKind := FailureUnknown
130
  lastFailureMessage := ""
131
  for attempts < maxAttempts {
132
+ headers := c.authHeadersWithAuth(a)
133
  resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath})
134
  if err != nil {
135
  config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath)
 
178
  return "", errors.New("get pow failed")
179
  }
180
 
181
+ // authHeaders 构造带 Authorization 的请求头。直通模式(仅 token,无账号)下,
182
+ // Android 平台用 token 哈希派生稳定的 x-rangers-id(同 token 始终同 ID,避免直通
183
+ // 用户被识别为"无 rangers-id 异常")。托管账号模式应优先使用 authHeadersWithAuth,
184
+ // 由账号级 RangersID 提供(用户自填或基于账号标识稳定生成)。
185
  func (c *Client) authHeaders(token string) map[string]string {
186
+ rangersID := ""
187
+ if !dsprotocol.IsWebPlatform() && strings.TrimSpace(token) != "" {
188
+ rangersID = stableRangersIDFromToken(token)
189
+ }
190
+ return c.authHeadersWithRangers(token, rangersID)
191
+ }
192
+
193
+ // authHeadersWithAuth 为托管账号请求构造请求头,注入账号级 x-rangers-id。
194
+ // Android 平台必须携带 x-rangers-id(HAR 抓包显示几乎所有请求都带)。
195
+ func (c *Client) authHeadersWithAuth(a *auth.RequestAuth) map[string]string {
196
+ if a == nil {
197
+ return c.authHeadersWithRangers("", "")
198
+ }
199
+ rangersID := config.AndroidAccountRangersID(a.Account)
200
+ return c.authHeadersWithRangers(a.DeepSeekToken, rangersID)
201
+ }
202
+
203
+ func (c *Client) authHeadersWithRangers(token, rangersID string) map[string]string {
204
+ headers := make(map[string]string, len(dsprotocol.BaseHeaders)+2)
205
  for k, v := range dsprotocol.BaseHeaders {
206
  headers[k] = v
207
  }
208
+ if token != "" {
209
+ headers["authorization"] = "Bearer " + token
210
+ }
211
  if dsprotocol.IsWebPlatform() {
212
  for k, v := range dsprotocol.WebExtraHeaders() {
213
  headers[k] = v
 
217
  headers[k] = v
218
  }
219
  }
220
+ } else if rangersID != "" {
221
+ // Android:HAR 显示几乎所有请求都带 x-rangers-id,从 BaseHeaders 移除后改为按账号注入。
222
+ headers["x-rangers-id"] = rangersID
223
  }
224
  return headers
225
  }
226
 
227
+ // stableRangersIDFromToken 用 DeepSeek token 哈希派生稳定的 19 位数字 x-rangers-id。
228
+ // 用于直通模式(仅 token,无账号)下保证同 token 始终同 rangers-id。
229
+ func stableRangersIDFromToken(token string) string {
230
+ if strings.TrimSpace(token) == "" {
231
+ return ""
232
+ }
233
+ // 复用 config 包的稳定生成函数,但用 token 作为标识符(而非账号标识)。
234
+ // 这里直接用 sha1 实现,避免循环依赖 config 包。
235
+ sum := sha1.Sum([]byte("ds2api:rangers_id:direct:" + token))
236
+ hexStr := hex.EncodeToString(sum[:])
237
+ var b strings.Builder
238
+ for i := 0; i < 19 && i < len(hexStr); i++ {
239
+ c := hexStr[i]
240
+ switch {
241
+ case c >= '0' && c <= '9':
242
+ b.WriteByte(c)
243
+ default:
244
+ b.WriteByte('0' + (c - 'a'))
245
+ }
246
+ }
247
+ for b.Len() < 19 {
248
+ b.WriteByte('0')
249
+ }
250
+ out := b.String()
251
+ if len(out) > 19 {
252
+ out = out[:19]
253
+ }
254
+ return out
255
+ }
256
+
257
  func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
258
  msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
259
  if status == http.StatusUnauthorized || status == http.StatusForbidden {
internal/deepseek/client/client_completion.go CHANGED
@@ -15,7 +15,7 @@ import (
15
  func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) {
16
  _ = maxAttempts
17
  clients := c.requestClientsForAuth(ctx, a)
18
- headers := c.authHeaders(a.DeepSeekToken)
19
  headers["x-ds-pow-response"] = powResp
20
 
21
  if dsprotocol.IsWebPlatform() {
 
15
  func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) {
16
  _ = maxAttempts
17
  clients := c.requestClientsForAuth(ctx, a)
18
+ headers := c.authHeadersWithAuth(a)
19
  headers["x-ds-pow-response"] = powResp
20
 
21
  if dsprotocol.IsWebPlatform() {
internal/deepseek/client/client_continue.go CHANGED
@@ -54,7 +54,7 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
54
  return nil, errors.New("missing continue identifiers")
55
  }
56
  clients := c.requestClientsForAuth(ctx, a)
57
- headers := c.authHeaders(a.DeepSeekToken)
58
  headers["x-ds-pow-response"] = powResp
59
  if dsprotocol.IsWebPlatform() {
60
  for k, v := range dsprotocol.WebExtraHeaders() {
 
54
  return nil, errors.New("missing continue identifiers")
55
  }
56
  clients := c.requestClientsForAuth(ctx, a)
57
+ headers := c.authHeadersWithAuth(a)
58
  headers["x-ds-pow-response"] = powResp
59
  if dsprotocol.IsWebPlatform() {
60
  for k, v := range dsprotocol.WebExtraHeaders() {
internal/deepseek/client/client_file_status.go CHANGED
@@ -76,7 +76,7 @@ func (c *Client) FetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fil
76
  }
77
  clients := c.requestClientsForAuth(ctx, a)
78
  reqURL := dsprotocol.DeepSeekFetchFilesURL + "?file_ids=" + url.QueryEscape(fileID)
79
- headers := c.authHeaders(a.DeepSeekToken)
80
 
81
  resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
82
  if err != nil {
@@ -142,12 +142,15 @@ func buildUploadFileResultFromMap(m map[string]any, targetID string) *UploadFile
142
  return nil
143
  }
144
  result := &UploadFileResult{
145
- ID: fileID,
146
- Filename: firstNonEmptyString(m, "name", "filename", "file_name"),
147
- Status: firstNonEmptyString(m, "status", "file_status"),
148
- Purpose: firstNonEmptyString(m, "purpose"),
149
- IsImage: firstBool(m, "is_image", "isImage"),
150
- Bytes: firstPositiveInt64(m, "bytes", "size", "file_size"),
 
 
 
151
  }
152
  if result.Status == "" {
153
  result.Status = "uploaded"
@@ -175,6 +178,15 @@ func mergeUploadFileResults(dst, src *UploadFileResult) {
175
  dst.Purpose = strings.TrimSpace(src.Purpose)
176
  }
177
  dst.IsImage = src.IsImage
 
 
 
 
 
 
 
 
 
178
  if len(src.Raw) > 0 {
179
  dst.Raw = src.Raw
180
  }
@@ -191,3 +203,26 @@ func isReadyUploadFileStatus(status string) bool {
191
  return false
192
  }
193
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  }
77
  clients := c.requestClientsForAuth(ctx, a)
78
  reqURL := dsprotocol.DeepSeekFetchFilesURL + "?file_ids=" + url.QueryEscape(fileID)
79
+ headers := c.authHeadersWithAuth(a)
80
 
81
  resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
82
  if err != nil {
 
142
  return nil
143
  }
144
  result := &UploadFileResult{
145
+ ID: fileID,
146
+ Filename: firstNonEmptyString(m, "name", "filename", "file_name"),
147
+ Status: firstNonEmptyString(m, "status", "file_status"),
148
+ Purpose: firstNonEmptyString(m, "purpose"),
149
+ IsImage: firstBool(m, "is_image", "isImage"),
150
+ Bytes: firstPositiveInt64(m, "bytes", "size", "file_size"),
151
+ AuditResult: firstNonEmptyString(m, "audit_result", "auditResult"),
152
+ ModelKind: firstNonEmptyString(m, "model_kind", "modelKind"),
153
+ SignedPath: firstNonEmptyString(m, "signed_path", "signedPath"),
154
  }
155
  if result.Status == "" {
156
  result.Status = "uploaded"
 
178
  dst.Purpose = strings.TrimSpace(src.Purpose)
179
  }
180
  dst.IsImage = src.IsImage
181
+ if strings.TrimSpace(src.AuditResult) != "" {
182
+ dst.AuditResult = strings.TrimSpace(src.AuditResult)
183
+ }
184
+ if strings.TrimSpace(src.ModelKind) != "" {
185
+ dst.ModelKind = strings.TrimSpace(src.ModelKind)
186
+ }
187
+ if strings.TrimSpace(src.SignedPath) != "" {
188
+ dst.SignedPath = strings.TrimSpace(src.SignedPath)
189
+ }
190
  if len(src.Raw) > 0 {
191
  dst.Raw = src.Raw
192
  }
 
203
  return false
204
  }
205
  }
206
+
207
+ // isPassedAuditResult 判断审核结果是否为"通过"。
208
+ // HAR 实测:审核通过为 "pass";上传瞬间为 "unknown"(待审)。
209
+ // 任何 reject / denied / blocked 等都视为未通过。
210
+ func isPassedAuditResult(auditResult string) bool {
211
+ switch strings.ToLower(strings.TrimSpace(auditResult)) {
212
+ case "pass", "passed", "ok", "approved":
213
+ return true
214
+ default:
215
+ return false
216
+ }
217
+ }
218
+
219
+ // isFailedAuditResult 判断审核结果是否为"明确失败"。
220
+ // 用于在 strict 模式下立即返回错误,而不是继续等待。
221
+ func isFailedAuditResult(auditResult string) bool {
222
+ switch strings.ToLower(strings.TrimSpace(auditResult)) {
223
+ case "reject", "rejected", "deny", "denied", "block", "blocked", "fail", "failed":
224
+ return true
225
+ default:
226
+ return false
227
+ }
228
+ }
internal/deepseek/client/client_http_json.go CHANGED
@@ -23,12 +23,18 @@ func (c *Client) postJSON(ctx context.Context, doer trans.Doer, fallback trans.D
23
  }
24
 
25
  func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, payload any) (map[string]any, int, error) {
26
- b, err := json.Marshal(payload)
27
- if err != nil {
28
- return nil, 0, err
 
 
 
 
 
 
29
  }
30
  headers = c.jsonHeaders(headers)
31
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
32
  if err != nil {
33
  return nil, 0, err
34
  }
@@ -38,7 +44,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallba
38
  resp, err := doer.Do(req)
39
  if err != nil {
40
  config.Logger.Warn("[deepseek] fingerprint request failed, fallback to std transport", "url", url, "error", err)
41
- req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
42
  if reqErr != nil {
43
  return nil, 0, reqErr
44
  }
 
23
  }
24
 
25
  func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, payload any) (map[string]any, int, error) {
26
+ // payload == nil 表示发送空 body(与 Android 真实客户端 create_session 行为一致:
27
+ // Content-Length: 0,无 JSON body)。其他值正常序列化为 JSON。
28
+ var body []byte
29
+ if payload != nil {
30
+ b, err := json.Marshal(payload)
31
+ if err != nil {
32
+ return nil, 0, err
33
+ }
34
+ body = b
35
  }
36
  headers = c.jsonHeaders(headers)
37
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
38
  if err != nil {
39
  return nil, 0, err
40
  }
 
44
  resp, err := doer.Do(req)
45
  if err != nil {
46
  config.Logger.Warn("[deepseek] fingerprint request failed, fallback to std transport", "url", url, "error", err)
47
+ req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
48
  if reqErr != nil {
49
  return nil, 0, reqErr
50
  }
internal/deepseek/client/client_session.go CHANGED
@@ -47,7 +47,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
47
  refreshed := false
48
 
49
  for attempts < maxAttempts {
50
- headers := c.authHeaders(a.DeepSeekToken)
51
 
52
  // 构建请求 URL
53
  reqURL := dsprotocol.DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
@@ -195,7 +195,7 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
195
  // FetchSessionPage 获取会话列表(支持分页)
196
  func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
197
  clients := c.requestClientsForAuth(ctx, a)
198
- headers := c.authHeaders(a.DeepSeekToken)
199
 
200
  // 构建请求 URL
201
  params := url.Values{}
 
47
  refreshed := false
48
 
49
  for attempts < maxAttempts {
50
+ headers := c.authHeadersWithAuth(a)
51
 
52
  // 构建请求 URL
53
  reqURL := dsprotocol.DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
 
195
  // FetchSessionPage 获取会话列表(支持分页)
196
  func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
197
  clients := c.requestClientsForAuth(ctx, a)
198
+ headers := c.authHeadersWithAuth(a)
199
 
200
  // 构建请求 URL
201
  params := url.Values{}
internal/deepseek/client/client_session_delete.go CHANGED
@@ -38,7 +38,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
38
  refreshed := false
39
 
40
  for attempts < maxAttempts {
41
- headers := c.authHeaders(a.DeepSeekToken)
42
 
43
  payload := map[string]any{
44
  "chat_session_id": sessionID,
@@ -118,7 +118,7 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
118
  // DeleteAllSessions 删除所有会话(谨慎使用)
119
  func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
120
  clients := c.requestClientsForAuth(ctx, a)
121
- headers := c.authHeaders(a.DeepSeekToken)
122
  payload := map[string]any{}
123
 
124
  resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteAllSessionsURL, headers, payload)
 
38
  refreshed := false
39
 
40
  for attempts < maxAttempts {
41
+ headers := c.authHeadersWithAuth(a)
42
 
43
  payload := map[string]any{
44
  "chat_session_id": sessionID,
 
118
  // DeleteAllSessions 删除所有会话(谨慎使用)
119
  func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
120
  clients := c.requestClientsForAuth(ctx, a)
121
+ headers := c.authHeadersWithAuth(a)
122
  payload := map[string]any{}
123
 
124
  resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteAllSessionsURL, headers, payload)
internal/deepseek/client/client_upload.go CHANGED
@@ -28,15 +28,18 @@ type UploadFileRequest struct {
28
  }
29
 
30
  type UploadFileResult struct {
31
- ID string
32
- Filename string
33
- Bytes int64
34
- Status string
35
- Purpose string
36
- AccountID string
37
- IsImage bool
38
- Raw map[string]any
39
- RawHeaders http.Header
 
 
 
40
  }
41
 
42
  func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req UploadFileRequest, maxAttempts int) (*UploadFileResult, error) {
@@ -56,6 +59,11 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
56
  }
57
  purpose := strings.TrimSpace(req.Purpose)
58
  modelType := strings.ToLower(strings.TrimSpace(req.ModelType))
 
 
 
 
 
59
  body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data)
60
  if err != nil {
61
  return nil, err
@@ -65,31 +73,18 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
65
  "content_type": contentType,
66
  "purpose": purpose,
67
  "bytes": len(req.Data),
68
- }
69
- if modelType != "" {
70
- capturePayload["model_type"] = modelType
71
  }
72
  captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
73
  attempts := 0
74
  refreshed := false
75
- powHeader := ""
76
  lastFailureKind := FailureUnknown
77
  lastFailureMessage := ""
78
  for attempts < maxAttempts {
79
  clients := c.requestClientsForAuth(ctx, a)
80
- if strings.TrimSpace(powHeader) == "" {
81
- powHeader, err = c.GetPowForTarget(ctx, a, dsprotocol.DeepSeekUploadTargetPath, maxAttempts)
82
- if err != nil {
83
- return nil, err
84
- }
85
- clients = c.requestClientsForAuth(ctx, a)
86
- }
87
- headers := c.authHeaders(a.DeepSeekToken)
88
  headers["Content-Type"] = contentTypeHeader
89
- if modelType != "" {
90
- headers["x-model-type"] = modelType
91
- }
92
- headers["x-ds-pow-response"] = powHeader
93
  headers["x-file-size"] = strconv.Itoa(len(req.Data))
94
  headers["x-thinking-enabled"] = "1"
95
  resp, err := c.doUpload(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekUploadFileURL, headers, body)
@@ -103,7 +98,6 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
103
  payloadBytes, readErr := readResponseBody(resp)
104
  _ = resp.Body.Close()
105
  if readErr != nil {
106
- powHeader = ""
107
  attempts++
108
  continue
109
  }
@@ -139,7 +133,6 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
139
  return result, nil
140
  }
141
  config.Logger.Warn("[upload_file] failed", "status", resp.StatusCode, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID, "filename", filename)
142
- powHeader = ""
143
  lastFailureMessage = failureMessage(msg, bizMsg, "upload file failed")
144
  if isTokenInvalid(resp.StatusCode, code, bizCode, msg, bizMsg) || isAuthIndicativeBizFailure(msg, bizMsg) {
145
  lastFailureKind = authFailureKind(a.UseConfigToken)
@@ -254,6 +247,15 @@ func extractUploadFileResult(resp map[string]any) *UploadFileResult {
254
  if result.Bytes == 0 {
255
  result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size")
256
  }
 
 
 
 
 
 
 
 
 
257
  }
258
  return result
259
  }
 
28
  }
29
 
30
  type UploadFileResult struct {
31
+ ID string
32
+ Filename string
33
+ Bytes int64
34
+ Status string
35
+ Purpose string
36
+ AccountID string
37
+ IsImage bool
38
+ AuditResult string // 审核结果:unknown / pass / reject 等(HAR 实测字段 audit_result)
39
+ ModelKind string // VISION / NORMAL(HAR 实测字段 model_kind)
40
+ SignedPath string // 文件签名下载路径(异步处理完成后下发)
41
+ Raw map[string]any
42
+ RawHeaders http.Header
43
  }
44
 
45
  func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req UploadFileRequest, maxAttempts int) (*UploadFileResult, error) {
 
59
  }
60
  purpose := strings.TrimSpace(req.Purpose)
61
  modelType := strings.ToLower(strings.TrimSpace(req.ModelType))
62
+ // 与 Android 真实客户端一致:upload_file 始终携带 x-model-type(默认 default),
63
+ // 且不申请/携带 PoW(仅 completion 需要 PoW)。
64
+ if modelType == "" {
65
+ modelType = "default"
66
+ }
67
  body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data)
68
  if err != nil {
69
  return nil, err
 
73
  "content_type": contentType,
74
  "purpose": purpose,
75
  "bytes": len(req.Data),
76
+ "model_type": modelType,
 
 
77
  }
78
  captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
79
  attempts := 0
80
  refreshed := false
 
81
  lastFailureKind := FailureUnknown
82
  lastFailureMessage := ""
83
  for attempts < maxAttempts {
84
  clients := c.requestClientsForAuth(ctx, a)
85
+ headers := c.authHeadersWithAuth(a)
 
 
 
 
 
 
 
86
  headers["Content-Type"] = contentTypeHeader
87
+ headers["x-model-type"] = modelType
 
 
 
88
  headers["x-file-size"] = strconv.Itoa(len(req.Data))
89
  headers["x-thinking-enabled"] = "1"
90
  resp, err := c.doUpload(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekUploadFileURL, headers, body)
 
98
  payloadBytes, readErr := readResponseBody(resp)
99
  _ = resp.Body.Close()
100
  if readErr != nil {
 
101
  attempts++
102
  continue
103
  }
 
133
  return result, nil
134
  }
135
  config.Logger.Warn("[upload_file] failed", "status", resp.StatusCode, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID, "filename", filename)
 
136
  lastFailureMessage = failureMessage(msg, bizMsg, "upload file failed")
137
  if isTokenInvalid(resp.StatusCode, code, bizCode, msg, bizMsg) || isAuthIndicativeBizFailure(msg, bizMsg) {
138
  lastFailureKind = authFailureKind(a.UseConfigToken)
 
247
  if result.Bytes == 0 {
248
  result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size")
249
  }
250
+ if result.AuditResult == "" {
251
+ result.AuditResult = firstNonEmptyString(m, "audit_result", "auditResult")
252
+ }
253
+ if result.ModelKind == "" {
254
+ result.ModelKind = firstNonEmptyString(m, "model_kind", "modelKind")
255
+ }
256
+ if result.SignedPath == "" {
257
+ result.SignedPath = firstNonEmptyString(m, "signed_path", "signedPath")
258
+ }
259
  }
260
  return result
261
  }
internal/deepseek/client/client_upload_test.go CHANGED
@@ -3,9 +3,6 @@ package client
3
  import (
4
  "context"
5
  dsprotocol "ds2api/internal/deepseek/protocol"
6
- "encoding/base64"
7
- "encoding/hex"
8
- "encoding/json"
9
  "errors"
10
  "io"
11
  "net/http"
@@ -14,7 +11,6 @@ import (
14
  "time"
15
 
16
  "ds2api/internal/auth"
17
- powpkg "ds2api/pow"
18
  )
19
 
20
  func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) {
@@ -100,12 +96,9 @@ func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) {
100
  }
101
  }
102
 
103
- func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
104
- challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42"))
105
- powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + dsprotocol.DeepSeekUploadTargetPath + `"}}}}`
106
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
107
  var seenPow string
108
- var seenTargetPath string
109
  var seenContentType string
110
  var seenFileSize string
111
  var seenModelType string
@@ -117,9 +110,6 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
117
  bodyBytes, _ := io.ReadAll(req.Body)
118
  switch call {
119
  case 1:
120
- seenTargetPath = string(bodyBytes)
121
- return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil
122
- case 2:
123
  seenPow = req.Header.Get("x-ds-pow-response")
124
  seenContentType = req.Header.Get("Content-Type")
125
  seenFileSize = req.Header.Get("x-file-size")
@@ -149,22 +139,9 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
149
  if result.ID != "file_789" {
150
  t.Fatalf("expected uploaded file id file_789, got %#v", result)
151
  }
152
- if !strings.Contains(seenTargetPath, `"target_path":"`+dsprotocol.DeepSeekUploadTargetPath+`"`) {
153
- t.Fatalf("expected upload target_path in pow request, got %q", seenTargetPath)
154
- }
155
- if strings.TrimSpace(seenPow) == "" {
156
- t.Fatal("expected x-ds-pow-response header")
157
- }
158
- rawPow, err := base64.StdEncoding.DecodeString(seenPow)
159
- if err != nil {
160
- t.Fatalf("decode pow header failed: %v", err)
161
- }
162
- var powHeader map[string]any
163
- if err := json.Unmarshal(rawPow, &powHeader); err != nil {
164
- t.Fatalf("unmarshal pow header failed: %v", err)
165
- }
166
- if powHeader["target_path"] != dsprotocol.DeepSeekUploadTargetPath {
167
- t.Fatalf("expected pow target_path %q, got %#v", dsprotocol.DeepSeekUploadTargetPath, powHeader["target_path"])
168
  }
169
  if seenFileSize != "5" {
170
  t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
@@ -180,13 +157,45 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
180
  }
181
  }
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
184
  oldSleep := fileReadySleep
185
  fileReadySleep = func(time.Duration) {}
186
  defer func() { fileReadySleep = oldSleep }()
187
 
188
- challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42"))
189
- powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + dsprotocol.DeepSeekUploadTargetPath + `"}}}}`
190
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}}}}`
191
  pendingFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}]}}}`
192
  processedFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":true}]}}}`
@@ -197,14 +206,12 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
197
  call++
198
  switch call {
199
  case 1:
200
- bodyBytes, _ := io.ReadAll(req.Body)
201
- if !strings.Contains(string(bodyBytes), `"target_path":"`+dsprotocol.DeepSeekUploadTargetPath+`"`) {
202
- t.Fatalf("expected pow target path request, got %s", string(bodyBytes))
203
  }
204
- return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil
205
- case 2:
206
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
207
- case 3, 4:
208
  if req.Method != http.MethodGet {
209
  t.Fatalf("expected GET fetch request, got %s", req.Method)
210
  }
@@ -215,7 +222,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
215
  t.Fatalf("expected file_ids=file_789, got %q", got)
216
  }
217
  respBody := pendingFetchResponse
218
- if call == 4 {
219
  respBody = processedFetchResponse
220
  }
221
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(respBody)), Request: req}, nil
@@ -243,7 +250,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
243
  if result.Status != "processed" {
244
  t.Fatalf("expected final status processed, got %#v", result.Status)
245
  }
246
- if call != 4 {
247
- t.Fatalf("expected 4 requests, got %d", call)
248
  }
249
  }
 
3
  import (
4
  "context"
5
  dsprotocol "ds2api/internal/deepseek/protocol"
 
 
 
6
  "errors"
7
  "io"
8
  "net/http"
 
11
  "time"
12
 
13
  "ds2api/internal/auth"
 
14
  )
15
 
16
  func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) {
 
96
  }
97
  }
98
 
99
+ func TestUploadFileUsesMultipartHeadersWithoutPow(t *testing.T) {
 
 
100
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
101
  var seenPow string
 
102
  var seenContentType string
103
  var seenFileSize string
104
  var seenModelType string
 
110
  bodyBytes, _ := io.ReadAll(req.Body)
111
  switch call {
112
  case 1:
 
 
 
113
  seenPow = req.Header.Get("x-ds-pow-response")
114
  seenContentType = req.Header.Get("Content-Type")
115
  seenFileSize = req.Header.Get("x-file-size")
 
139
  if result.ID != "file_789" {
140
  t.Fatalf("expected uploaded file id file_789, got %#v", result)
141
  }
142
+ // Android 真实客户端一致:upload 不携带 PoW。
143
+ if seenPow != "" {
144
+ t.Fatalf("expected no x-ds-pow-response header, got %q", seenPow)
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
  if seenFileSize != "5" {
147
  t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
 
157
  }
158
  }
159
 
160
+ func TestUploadFileDefaultsModelTypeToDefaultWhenAbsent(t *testing.T) {
161
+ uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
162
+ var seenModelType string
163
+ call := 0
164
+ client := &Client{
165
+ regular: doerFunc(func(req *http.Request) (*http.Response, error) {
166
+ call++
167
+ switch call {
168
+ case 1:
169
+ seenModelType = req.Header.Get("x-model-type")
170
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
171
+ default:
172
+ t.Fatalf("unexpected request count %d", call)
173
+ return nil, nil
174
+ }
175
+ }),
176
+ fallback: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return nil, nil })},
177
+ maxRetries: 1,
178
+ }
179
+ _, err := client.UploadFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token", TriedAccounts: map[string]bool{}}, UploadFileRequest{
180
+ Filename: "demo.txt",
181
+ ContentType: "text/plain",
182
+ Purpose: "assistants",
183
+ Data: []byte("hello"),
184
+ }, 1)
185
+ if err != nil {
186
+ t.Fatalf("UploadFile error: %v", err)
187
+ }
188
+ // 上层未传 ModelType 时,应与 Android 客户端一致默认填 default。
189
+ if seenModelType != "default" {
190
+ t.Fatalf("expected x-model-type=default, got %q", seenModelType)
191
+ }
192
+ }
193
+
194
  func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
195
  oldSleep := fileReadySleep
196
  fileReadySleep = func(time.Duration) {}
197
  defer func() { fileReadySleep = oldSleep }()
198
 
 
 
199
  uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}}}}`
200
  pendingFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}]}}}`
201
  processedFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":true}]}}}`
 
206
  call++
207
  switch call {
208
  case 1:
209
+ // upload 不再申请 PoW,第一个请求就是 upload 本身。
210
+ if req.Header.Get("x-ds-pow-response") != "" {
211
+ t.Fatalf("upload should not carry PoW header, got %q", req.Header.Get("x-ds-pow-response"))
212
  }
 
 
213
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
214
+ case 2, 3:
215
  if req.Method != http.MethodGet {
216
  t.Fatalf("expected GET fetch request, got %s", req.Method)
217
  }
 
222
  t.Fatalf("expected file_ids=file_789, got %q", got)
223
  }
224
  respBody := pendingFetchResponse
225
+ if call == 3 {
226
  respBody = processedFetchResponse
227
  }
228
  return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(respBody)), Request: req}, nil
 
250
  if result.Status != "processed" {
251
  t.Fatalf("expected final status processed, got %#v", result.Status)
252
  }
253
+ if call != 3 {
254
+ t.Fatalf("expected 3 requests (upload + 2 fetch), got %d", call)
255
  }
256
  }
internal/deepseek/protocol/constants_shared.json CHANGED
@@ -13,7 +13,6 @@
13
  "accept-encoding": "gzip",
14
  "accept-charset": "UTF-8",
15
  "x-client-bundle-id": "com.deepseek.chat",
16
- "x-rangers-id": "7639874692114816004",
17
  "x-client-timezone-offset": "28800"
18
  },
19
  "skip_contains_patterns": [
 
13
  "accept-encoding": "gzip",
14
  "accept-charset": "UTF-8",
15
  "x-client-bundle-id": "com.deepseek.chat",
 
16
  "x-client-timezone-offset": "28800"
17
  },
18
  "skip_contains_patterns": [
internal/httpapi/admin/accounts/handler_accounts_crud.go CHANGED
@@ -59,18 +59,23 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
59
  testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
60
  token := strings.TrimSpace(acc.Token)
61
  items = append(items, map[string]any{
62
- "identifier": acc.Identifier(),
63
- "name": acc.Name,
64
- "remark": acc.Remark,
65
- "email": acc.Email,
66
- "mobile": acc.Mobile,
67
- "proxy_id": acc.ProxyID,
68
- "has_password": acc.Password != "",
69
- "has_token": token != "",
70
- "token_preview": maskSecretPreview(token),
71
- "test_status": testStatus,
72
- "role": acc.Role,
73
- "banned": acc.Banned,
 
 
 
 
 
74
  })
75
  }
76
  writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
@@ -123,6 +128,8 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
123
  }
124
  name, nameOK := fieldStringOptional(req, "name")
125
  remark, remarkOK := fieldStringOptional(req, "remark")
 
 
126
 
127
  err := h.Store.Update(func(c *config.Config) error {
128
  for i, acc := range c.Accounts {
@@ -135,6 +142,12 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
135
  if remarkOK {
136
  c.Accounts[i].Remark = remark
137
  }
 
 
 
 
 
 
138
  return nil
139
  }
140
  return newRequestError("账号不存在")
 
59
  testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
60
  token := strings.TrimSpace(acc.Token)
61
  items = append(items, map[string]any{
62
+ "identifier": acc.Identifier(),
63
+ "name": acc.Name,
64
+ "remark": acc.Remark,
65
+ "email": acc.Email,
66
+ "mobile": acc.Mobile,
67
+ "proxy_id": acc.ProxyID,
68
+ "has_password": acc.Password != "",
69
+ "has_token": token != "",
70
+ "token_preview": maskSecretPreview(token),
71
+ "test_status": testStatus,
72
+ "role": acc.Role,
73
+ "banned": acc.Banned,
74
+ "device_id": acc.DeviceID,
75
+ "device_id_preview": maskSecretPreview(acc.DeviceID),
76
+ "rangers_id": acc.RangersID,
77
+ "device_id_generated": acc.DeviceID == "",
78
+ "rangers_id_generated": acc.RangersID == "",
79
  })
80
  }
81
  writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
 
128
  }
129
  name, nameOK := fieldStringOptional(req, "name")
130
  remark, remarkOK := fieldStringOptional(req, "remark")
131
+ deviceID, deviceIDOK := fieldStringOptional(req, "device_id")
132
+ rangersID, rangersIDOK := fieldStringOptional(req, "rangers_id")
133
 
134
  err := h.Store.Update(func(c *config.Config) error {
135
  for i, acc := range c.Accounts {
 
142
  if remarkOK {
143
  c.Accounts[i].Remark = remark
144
  }
145
+ if deviceIDOK {
146
+ c.Accounts[i].DeviceID = deviceID
147
+ }
148
+ if rangersIDOK {
149
+ c.Accounts[i].RangersID = rangersID
150
+ }
151
  return nil
152
  }
153
  return newRequestError("账号不存在")
internal/httpapi/admin/shared/helpers.go CHANGED
@@ -166,13 +166,15 @@ func toAccount(m map[string]any) config.Account {
166
  role = "" // default to normal (empty string treated as normal)
167
  }
168
  return config.Account{
169
- Name: fieldString(m, "name"),
170
- Remark: fieldString(m, "remark"),
171
- Email: email,
172
- Mobile: mobile,
173
- Password: fieldString(m, "password"),
174
- ProxyID: fieldString(m, "proxy_id"),
175
- Role: role,
 
 
176
  }
177
  }
178
 
@@ -333,6 +335,8 @@ func normalizeAccountForStorage(acc config.Account) config.Account {
333
  if acc.Role != "normal" && acc.Role != "standby" {
334
  acc.Role = ""
335
  }
 
 
336
  return acc
337
  }
338
 
 
166
  role = "" // default to normal (empty string treated as normal)
167
  }
168
  return config.Account{
169
+ Name: fieldString(m, "name"),
170
+ Remark: fieldString(m, "remark"),
171
+ Email: email,
172
+ Mobile: mobile,
173
+ Password: fieldString(m, "password"),
174
+ ProxyID: fieldString(m, "proxy_id"),
175
+ Role: role,
176
+ DeviceID: fieldString(m, "device_id"),
177
+ RangersID: fieldString(m, "rangers_id"),
178
  }
179
  }
180
 
 
335
  if acc.Role != "normal" && acc.Role != "standby" {
336
  acc.Role = ""
337
  }
338
+ acc.DeviceID = strings.TrimSpace(acc.DeviceID)
339
+ acc.RangersID = strings.TrimSpace(acc.RangersID)
340
  return acc
341
  }
342
 
internal/httpapi/openai/chat/chat_history.go CHANGED
@@ -44,6 +44,7 @@ func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.Request
44
  Messages: extractAllMessages(stdReq.Messages),
45
  HistoryText: stdReq.HistoryText,
46
  FinalPrompt: stdReq.FinalPrompt,
 
47
  })
48
  startParams := chathistory.StartParams{
49
  CallerID: strings.TrimSpace(a.CallerID),
@@ -55,6 +56,7 @@ func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.Request
55
  Messages: extractAllMessages(stdReq.Messages),
56
  HistoryText: stdReq.HistoryText,
57
  FinalPrompt: stdReq.FinalPrompt,
 
58
  }
59
  session := &chatHistorySession{
60
  store: store,
 
44
  Messages: extractAllMessages(stdReq.Messages),
45
  HistoryText: stdReq.HistoryText,
46
  FinalPrompt: stdReq.FinalPrompt,
47
+ FileInfos: stdReq.FileInfos,
48
  })
49
  startParams := chathistory.StartParams{
50
  CallerID: strings.TrimSpace(a.CallerID),
 
56
  Messages: extractAllMessages(stdReq.Messages),
57
  HistoryText: stdReq.HistoryText,
58
  FinalPrompt: stdReq.FinalPrompt,
59
+ FileInfos: stdReq.FileInfos,
60
  }
61
  session := &chatHistorySession{
62
  store: store,
internal/httpapi/openai/history/current_input_file.go CHANGED
@@ -9,6 +9,7 @@ import (
9
  "unicode/utf8"
10
 
11
  "ds2api/internal/auth"
 
12
  "ds2api/internal/config"
13
  dsclient "ds2api/internal/deepseek/client"
14
  "ds2api/internal/httpapi/openai/shared"
@@ -79,6 +80,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
79
  historyID := ""
80
  if historyResult != nil {
81
  historyID = strings.TrimSpace(historyResult.ID)
 
82
  }
83
 
84
  toolsID := ""
@@ -95,6 +97,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
95
  }
96
  if toolsResult != nil {
97
  toolsID = strings.TrimSpace(toolsResult.ID)
 
98
  }
99
  }
100
 
@@ -184,6 +187,7 @@ func (s Service) ReuploadAppliedCurrentInputFile(ctx context.Context, a *auth.Re
184
  newHistoryID := ""
185
  if newHistory != nil {
186
  newHistoryID = strings.TrimSpace(newHistory.ID)
 
187
  }
188
 
189
  newToolsID := ""
@@ -200,6 +204,7 @@ func (s Service) ReuploadAppliedCurrentInputFile(ctx context.Context, a *auth.Re
200
  }
201
  if newTools != nil {
202
  newToolsID = strings.TrimSpace(newTools.ID)
 
203
  }
204
  }
205
 
@@ -328,3 +333,23 @@ func replaceGeneratedCurrentInputRefs(existing []string, oldHistoryID, oldToolsI
328
  }
329
  return prependUniqueRefFileIDs(filtered, newHistoryID, newToolsID)
330
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  "unicode/utf8"
10
 
11
  "ds2api/internal/auth"
12
+ "ds2api/internal/chathistory"
13
  "ds2api/internal/config"
14
  dsclient "ds2api/internal/deepseek/client"
15
  "ds2api/internal/httpapi/openai/shared"
 
80
  historyID := ""
81
  if historyResult != nil {
82
  historyID = strings.TrimSpace(historyResult.ID)
83
+ stdReq.FileInfos = append(stdReq.FileInfos, uploadResultToFileInfo(historyResult, "current_input_history"))
84
  }
85
 
86
  toolsID := ""
 
97
  }
98
  if toolsResult != nil {
99
  toolsID = strings.TrimSpace(toolsResult.ID)
100
+ stdReq.FileInfos = append(stdReq.FileInfos, uploadResultToFileInfo(toolsResult, "current_input_tools"))
101
  }
102
  }
103
 
 
187
  newHistoryID := ""
188
  if newHistory != nil {
189
  newHistoryID = strings.TrimSpace(newHistory.ID)
190
+ stdReq.FileInfos = append(stdReq.FileInfos, uploadResultToFileInfo(newHistory, "current_input_history_reupload"))
191
  }
192
 
193
  newToolsID := ""
 
204
  }
205
  if newTools != nil {
206
  newToolsID = strings.TrimSpace(newTools.ID)
207
+ stdReq.FileInfos = append(stdReq.FileInfos, uploadResultToFileInfo(newTools, "current_input_tools_reupload"))
208
  }
209
  }
210
 
 
333
  }
334
  return prependUniqueRefFileIDs(filtered, newHistoryID, newToolsID)
335
  }
336
+
337
+ // uploadResultToFileInfo 把 DeepSeek 上传结果转换为 chat history 持久化的文件元信息。
338
+ // 无论 file_audit.strict 是否开启都会记录,便于事后排查审核未通过 / 撤回等问题。
339
+ func uploadResultToFileInfo(result *dsclient.UploadFileResult, source string) chathistory.FileInfo {
340
+ if result == nil {
341
+ return chathistory.FileInfo{Source: source}
342
+ }
343
+ return chathistory.FileInfo{
344
+ ID: strings.TrimSpace(result.ID),
345
+ Filename: strings.TrimSpace(result.Filename),
346
+ Bytes: result.Bytes,
347
+ Status: strings.TrimSpace(result.Status),
348
+ Purpose: strings.TrimSpace(result.Purpose),
349
+ IsImage: result.IsImage,
350
+ AuditResult: strings.TrimSpace(result.AuditResult),
351
+ ModelKind: strings.TrimSpace(result.ModelKind),
352
+ SignedPath: strings.TrimSpace(result.SignedPath),
353
+ Source: source,
354
+ }
355
+ }
internal/promptcompat/standard_request.go CHANGED
@@ -1,6 +1,9 @@
1
  package promptcompat
2
 
3
- import "ds2api/internal/config"
 
 
 
4
 
5
  type StandardRequest struct {
6
  Surface string
@@ -24,6 +27,7 @@ type StandardRequest struct {
24
  RefFileIDs []string
25
  RefFileTokens int
26
  PassThrough map[string]any
 
27
  }
28
 
29
  type ToolChoiceMode string
 
1
  package promptcompat
2
 
3
+ import (
4
+ "ds2api/internal/chathistory"
5
+ "ds2api/internal/config"
6
+ )
7
 
8
  type StandardRequest struct {
9
  Surface string
 
27
  RefFileIDs []string
28
  RefFileTokens int
29
  PassThrough map[string]any
30
+ FileInfos []chathistory.FileInfo
31
  }
32
 
33
  type ToolChoiceMode string
internal/responsehistory/session.go CHANGED
@@ -48,6 +48,7 @@ func Start(params StartParams) *Session {
48
  Messages: ExtractAllMessages(params.Standard.Messages),
49
  HistoryText: params.Standard.HistoryText,
50
  FinalPrompt: params.Standard.FinalPrompt,
 
51
  }
52
  entry, err := params.Store.Start(startParams)
53
  session := &Session{
 
48
  Messages: ExtractAllMessages(params.Standard.Messages),
49
  HistoryText: params.Standard.HistoryText,
50
  FinalPrompt: params.Standard.FinalPrompt,
51
+ FileInfos: params.Standard.FileInfos,
52
  }
53
  entry, err := params.Store.Start(startParams)
54
  session := &Session{
webui/src/features/chatHistory/ChatHistoryDetail.jsx CHANGED
@@ -232,6 +232,66 @@ function MetaGrid({ selectedItem, t }) {
232
  )
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  export default function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
236
  if (!selectedItem) return null
237
  const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
@@ -275,6 +335,8 @@ export default function DetailConversation({ selectedItem, t, viewMode, detailSc
275
 
276
  <MetaGrid selectedItem={selectedItem} t={t} />
277
 
 
 
278
  <button
279
  type="button"
280
  onClick={() => detailScrollRef.current?.scrollTo({ top: detailScrollRef.current?.scrollHeight || 0, behavior: 'smooth' })}
 
232
  )
233
  }
234
 
235
+ function formatFileInfoBytes(bytes) {
236
+ if (!bytes || bytes <= 0) return '-'
237
+ if (bytes < 1024) return `${bytes} B`
238
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
239
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
240
+ }
241
+
242
+ function FileInfosView({ item, t }) {
243
+ const infos = item?.file_infos
244
+ if (!Array.isArray(infos) || infos.length === 0) return null
245
+ return (
246
+ <div className="max-w-4xl mx-auto rounded-xl border border-border bg-background/70 p-4 space-y-3">
247
+ <div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">{t('chatHistory.fileInfosTitle')}</div>
248
+ <div className="space-y-2">
249
+ {infos.map((info, idx) => (
250
+ <div key={`${info.id || 'file'}-${idx}`} className="rounded-lg border border-border bg-card px-3 py-2 space-y-1.5">
251
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
252
+ <span className="font-mono text-foreground break-all">{info.id || '-'}</span>
253
+ {info.source && <span className="rounded bg-secondary px-1.5 py-0.5 text-muted-foreground">{info.source}</span>}
254
+ {info.is_image && <span className="rounded bg-secondary px-1.5 py-0.5 text-muted-foreground">{t('chatHistory.fileInfoIsImage')}</span>}
255
+ </div>
256
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-1 text-[11px]">
257
+ <div>
258
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoFilename')}: </span>
259
+ <span className="text-foreground break-all">{info.filename || '-'}</span>
260
+ </div>
261
+ <div>
262
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoSize')}: </span>
263
+ <span className="text-foreground">{formatFileInfoBytes(info.bytes)}</span>
264
+ </div>
265
+ <div>
266
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoStatus')}: </span>
267
+ <span className="text-foreground">{info.status || '-'}</span>
268
+ </div>
269
+ <div>
270
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoAudit')}: </span>
271
+ <span className={clsx('font-medium', info.audit_result && info.audit_result.toLowerCase() === 'pass' ? 'text-emerald-600 dark:text-emerald-400' : (info.audit_result && info.audit_result.toLowerCase() !== 'unknown' ? 'text-destructive' : 'text-foreground'))}>{info.audit_result || '-'}</span>
272
+ </div>
273
+ <div>
274
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoModelKind')}: </span>
275
+ <span className="text-foreground">{info.model_kind || '-'}</span>
276
+ </div>
277
+ <div>
278
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoPurpose')}: </span>
279
+ <span className="text-foreground">{info.purpose || '-'}</span>
280
+ </div>
281
+ </div>
282
+ {info.signed_path && (
283
+ <div className="text-[11px]">
284
+ <span className="text-muted-foreground">{t('chatHistory.fileInfoSignedPath')}: </span>
285
+ <span className="font-mono text-foreground break-all">{info.signed_path}</span>
286
+ </div>
287
+ )}
288
+ </div>
289
+ ))}
290
+ </div>
291
+ </div>
292
+ )
293
+ }
294
+
295
  export default function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
296
  if (!selectedItem) return null
297
  const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
 
335
 
336
  <MetaGrid selectedItem={selectedItem} t={t} />
337
 
338
+ <FileInfosView item={selectedItem} t={t} />
339
+
340
  <button
341
  type="button"
342
  onClick={() => detailScrollRef.current?.scrollTo({ top: detailScrollRef.current?.scrollHeight || 0, behavior: 'smooth' })}
webui/src/locales/en.json CHANGED
@@ -339,6 +339,18 @@
339
  "metaCaller": "Caller fingerprint",
340
  "metaTime": "Completed at",
341
  "metaUnknown": "Unknown",
 
 
 
 
 
 
 
 
 
 
 
 
342
  "backToTop": "Back to top",
343
  "backToBottom": "Jump to bottom",
344
  "streamMode": "Streaming",
 
339
  "metaCaller": "Caller fingerprint",
340
  "metaTime": "Completed at",
341
  "metaUnknown": "Unknown",
342
+ "fileInfosTitle": "File metadata",
343
+ "fileInfosEmpty": "No uploaded files for this turn",
344
+ "fileInfoID": "File ID",
345
+ "fileInfoFilename": "Filename",
346
+ "fileInfoSize": "Size",
347
+ "fileInfoStatus": "Status",
348
+ "fileInfoAudit": "Audit result",
349
+ "fileInfoModelKind": "Model kind",
350
+ "fileInfoSource": "Source",
351
+ "fileInfoSignedPath": "Signed path",
352
+ "fileInfoPurpose": "Purpose",
353
+ "fileInfoIsImage": "Image",
354
  "backToTop": "Back to top",
355
  "backToBottom": "Jump to bottom",
356
  "streamMode": "Streaming",
webui/src/locales/zh.json CHANGED
@@ -339,6 +339,18 @@
339
  "metaCaller": "调用方指纹",
340
  "metaTime": "完成时间",
341
  "metaUnknown": "未知",
 
 
 
 
 
 
 
 
 
 
 
 
342
  "backToTop": "回到顶部",
343
  "backToBottom": "跳到底部",
344
  "streamMode": "流式",
 
339
  "metaCaller": "调用方指纹",
340
  "metaTime": "完成时间",
341
  "metaUnknown": "未知",
342
+ "fileInfosTitle": "文件元信息",
343
+ "fileInfosEmpty": "本轮无上传文件",
344
+ "fileInfoID": "文件 ID",
345
+ "fileInfoFilename": "文件名",
346
+ "fileInfoSize": "大小",
347
+ "fileInfoStatus": "状态",
348
+ "fileInfoAudit": "审核结果",
349
+ "fileInfoModelKind": "模型类型",
350
+ "fileInfoSource": "来源",
351
+ "fileInfoSignedPath": "签名路径",
352
+ "fileInfoPurpose": "用途",
353
+ "fileInfoIsImage": "图片",
354
  "backToTop": "回到顶部",
355
  "backToBottom": "跳到底部",
356
  "streamMode": "流式",