luckfun233 commited on
Commit
90df7df
·
1 Parent(s): 0aefde8

feat(shumei): enhance device ID generation and add options for UID

Browse files

- Introduced DeviceOptions struct to allow specifying a UID for device ID generation.
- Updated GetDeviceID and GetDeviceIDWithOptions functions to support the new options.
- Improved fingerprint data construction to align with browser SDK.
- Added comprehensive tests for device ID generation and fingerprint data handling.

fix(sse): refine handling of tool fragments in SSE parsing

- Updated ParseSSEChunkForContentDetailed to correctly identify tool fragments and prevent their content from leaking into the main response.
- Enhanced fragment type resolution to ensure proper handling of nested paths and status checks.
- Added tests to verify the correct behavior of tool fragments and their impact on stream parsing.

test(sse): add tests for search mode and fragment handling

- Implemented tests for various scenarios in the search mode, ensuring that tool fragments do not affect the main content.
- Verified that the response status behaves correctly in both fragment and response contexts.

.gitignore CHANGED
@@ -76,3 +76,4 @@ chat.deepseek.com/
76
  chat.deepseek.com3.har
77
  chat.deepseek.com_2026_07_14_20_56_28.har
78
  *.har
 
 
76
  chat.deepseek.com3.har
77
  chat.deepseek.com_2026_07_14_20_56_28.har
78
  *.har
79
+ chat.deepseek.com (1)/
docs/shumei-device-id-research.md CHANGED
@@ -487,3 +487,118 @@ HIF 是 DeepSeek Web端特有的反爬/指纹验证机制,通过轮询两个
487
  5. **TLS 指纹匹配**:Web 模式必须使用 Chrome TLS 指纹且允许 HTTP/2 协商
488
 
489
  6. **device_id 前缀**:服务端返回的 deviceId 需要加 "B" 前缀才是最终的 device_id 值
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  5. **TLS 指纹匹配**:Web 模式必须使用 Chrome TLS 指纹且允许 HTTP/2 协商
488
 
489
  6. **device_id 前缀**:服务端返回的 deviceId 需要加 "B" 前缀才是最终的 device_id 值
490
+
491
+ > ⚠️ 以上第 1~6 点中的加密协议细节已在 2026-09 风控升级中失效,请以第十五节为准。
492
+
493
+ ---
494
+
495
+ ## 十五、2026-09 风控升级:新版 SDK(protocol=279)
496
+
497
+ > 研究时间:2026-09-25
498
+ > 触发原因:旧协议(-1/4)上报持续返回 `1902`,登录返回 `RISK_DEVICE_DETECTED`(biz_code=11)
499
+
500
+ ### 15.1 变化概览
501
+
502
+ | 项目 | 旧版(ConfusionInfo) | 新版(2026-09) |
503
+ |------|----------------------|----------------|
504
+ | 加载地址 | `https://cdn.deepseek.com/static/chat/fp-1.min.js` | `https://chat.deepseek.com/static/fp.min.js`(338KB) |
505
+ | 配置表 | 可下载的 `ConfusionInfo` 静态表 | 无配置表,构造 payload 时就地加密,字段名直接是混淆名 |
506
+ | protocol | `-1`(白名单) / `4`(默认) | `279`(0x117) |
507
+ | 字段名 | 明文名(ua/canvas/…)→ 混淆名(wr/mi/…) | 直接用混淆名(`xo`/`vr`/…),无映射表 |
508
+ | 完整性字段 | `tn`(明文名)→ 映射为 `nb` | `er`(DES 加密后的 tn) |
509
+ | `ep` 编码 | Base64 | Base64(不变) |
510
+ | 数据体编码 | hex | hex(不变) |
511
+ | tn 算法 | `md5(sorted key=value 拼接)` | `md5(按键排序后 **仅拼接值**,数字 ×10000)` |
512
+
513
+ ### 15.2 新版 payload 字段表
514
+
515
+ 基础字段(构造时固定写入):
516
+
517
+ | 混淆名 | 明文 | DES 密钥 |
518
+ |--------|------|----------|
519
+ | protocol | 279(数字,明文) | — |
520
+ | cm | organization | `bek5y8ck` |
521
+ | og | appId | `17vnhxoy` |
522
+ | ki | `web` | `mbmwo0og` |
523
+ | version | `3.0.0`(明文) | — |
524
+ | ek | sdkver `3.0.0` | `41uq54np` |
525
+ | kd | 服务端下发的 SMID(首次为空,明文) | — |
526
+ | xy | `all`(rtype) | `hefiudnj` |
527
+ | smid | 本地 smidV2(明文) | — |
528
+ | yp | subVersion `1.0.0` | `8dm48zka` |
529
+ | yi | 页面加载至今毫秒数 | `ncdmk4ad` |
530
+ | er | tn 校验值 | `2j48yowr` |
531
+
532
+ 指纹采集字段:
533
+
534
+ | 混淆名 | 含义 | DES 密钥 |
535
+ |--------|------|----------|
536
+ | xo | userAgent | `4m15b11j` |
537
+ | tt | platform | `kn6oyjo4` |
538
+ | lp | clientSize | `6wvopcsc` |
539
+ | vx | res(分辨率) | `q5g3kuj3` |
540
+ | vr | canvas | `jl8yq352` |
541
+ | sc | plugins | `nndx05dv` |
542
+ | eb | referer | `d9fd217d` |
543
+ | wl | url | `j54lcv3c` |
544
+ | ic | timezone | `ihaoil6z` |
545
+ | fu | status(Flash/cookie/DevTools 位串) | `a3l1z50h` |
546
+ | aw / jt | 随机 UID(UUID v4) | `8kzyjl46` / `qikfxkpr` |
547
+ | hk / xv | 采集时间戳 | `2emikljv` / `uve4ek1b` |
548
+
549
+ 明细字段(明文,直接参与 tn 与上报):`cdp`、`maxTouchPoints`、`connectionRtt`、`cpucount`、`battery`、`incognito`、`ag`、`ah`、`bq`、`cq`、`dn`、`documentExist`、`fe`、`ff`、`fj`、`fk`、`gc`、`gy`、`hb`、`hz`、`je`、`jk`、`jo`、`kf`、`kj`、`ks`、`kv`、`kw`、`ld`、`lr`、`mu`、`ov`、`po`、`pp`、`qa`、`qg`、`qm`、`qx`、`rq`、`t`、`collectTime`、`tn`(document 能力列表)、`ts`、`uo`、`up`、`uq`、`uu`、`uy`、`vn`、`vy`、`xd`、`ym`、`zl`、`zx`。
550
+
551
+ ### 15.3 tn(er)算法 — 与旧版完全不同
552
+
553
+ ```
554
+ er = DES-ECB('2j48yowr', md5(concat))
555
+ concat = 按键名排序后,把每个字段值按 JS `'' + value` 规则转字符串并直接相连
556
+ ```
557
+
558
+ 值转换规则(必须严格复刻,否则服务端判定数据不完整 → 1902):
559
+
560
+ | 值类型 | 转换结果 |
561
+ |--------|----------|
562
+ | 数字 | `String(v * 10000)`(注意:只在"字段直接值"这一层乘) |
563
+ | 字符串 | 原样 |
564
+ | 布尔 | `true` / `false` |
565
+ | 嵌套对象 | `[object Object]`(**不递归**) |
566
+ | 数组 | 元素用 `,` 连接(null/undefined → 空串) |
567
+ | undefined | 字面量 `undefined`(且该字段不会出现在 JSON 里) |
568
+
569
+ ### 15.4 加密流水线(新版)
570
+
571
+ ```
572
+ uid -> md5(uid)[:16] = priId (AES 密钥)
573
+ -> RSA(PKCS#1 v1.5) 加密 uid = ep(Base64)
574
+ -> 构造指纹对象(逐字段 DES-ECB + Base64,零填充;字段已对齐时不补块)
575
+ -> tn = md5(值拼接);er = DES('2j48yowr', tn)
576
+ -> JSON.stringify -> gzip -> btoa(Base64 字符串)
577
+ -> AES-CBC(priId, IV='0102030405060708', ZeroPadding,已对齐不补块)
578
+ -> hex 编码 = data
579
+ -> POST /deviceprofile/v4 {appId, organization, ep, data, os:'web', encode:5, compress:2}
580
+ -> code=1100 => device_id = "B" + detail.deviceId
581
+ ```
582
+
583
+ ### 15.5 验证方法(可复现)
584
+
585
+ 1. **沙箱复跑真实 SDK**:把 `fp.min.js` 放进 Node `vm` 沙箱(最小 DOM stub + 固定 `Math.random`),劫持 `XMLHttpRequest.send` 截获请求体;再用 `priId = md5(uid)[:16]` 反向 AES 解密,得到明文 payload。
586
+ 2. **tn 对齐**:劫持沙箱内 `Array.prototype.join` 抓取 tn 拼接串,与解密后的字段逐段比对(本次对齐结果:80 个字段,仅 1 个 undefined 字段差异,随后定位到 `yu`)。
587
+ 3. **盲破解��钥**:用 SDK 字符串池里的 8 位小写字母数字串对每个密文字段做 DES 解密 + 可打印过滤,唯一命中即为该字段密钥。
588
+ 4. **服务端交叉验证**:把沙箱生成的 payload 直接 POST 到 `fp-it-acc.portal101.cn/deviceprofile/v4`,返回 `1100` 即格式正确。
589
+ 5. **回归保护**:真实 SDK 产物的黄金向量已固化在 `internal/shumei/device_id_test.go`
590
+ (`TestDesFieldGoldenVectors` / `TestComputeTNJSSemantics` / `TestPayloadRoundTrip`)。
591
+
592
+ ### 15.6 项目实现
593
+
594
+ | 文件 | 职责 |
595
+ |------|------|
596
+ | `internal/shumei/confusion.go` | 新字段表 + 每字段 DES 密钥 + `desField()` |
597
+ | `internal/shumei/crypto.go` | JS 语义的 `computeTN()`、RSA(base64)、gzip、AES-CBC |
598
+ | `internal/shumei/device_id.go` | 指纹构造、tn/er、`GetDeviceID()` |
599
+ | `internal/shumei/api.go` | deviceprofile 上报与 1902 重试 |
600
+ | `internal/deepseek/client/client_auth.go` | 登录时签发/复用 device_id,风控失败自动重签 |
601
+
602
+ 登录侧行为:`device_id` 不再使用本地伪造值(旧实现的伪 Blob 会被判定为
603
+ `RISK_DEVICE_DETECTED`);改为向数美申请真实签发的 SMID 并写入账号配置复用,
604
+ 同一账号后续登录保持设备身份稳定。
docs/web-endpoint-upgrade.md CHANGED
@@ -11,11 +11,15 @@
11
  | User-Agent | `DeepSeek/2.1.2 Android/32` | Chrome 137 浏览器UA |
12
  | TLS指纹 | Safari | Chrome (HTTP/2) |
13
  | x-client-platform | `android` | `web` |
14
- | device_id | `"android_device"` | 数美SDK加密字符串 |
15
  | x-hif-dliq | 无 | 动态轮询获取 |
16
  | x-hif-leim | 无 | 动态轮询获取 |
17
  | 浏览器特有头 | 无 | sec-ch-ua, origin, referer等 |
18
 
 
 
 
 
19
  ### 架构设计
20
 
21
  ```
@@ -109,7 +113,7 @@
109
  │
110
  5. DES-ECB逐字段加密+字段名混淆
111
  │
112
- 6. 计算tn = md5(sorted key=value) ──→ 完整性校验
113
  │
114
  7. JSON → Gzip压缩
115
  │
@@ -122,6 +126,9 @@
122
  11. getDeviceId() = "B" + deviceId
123
  ```
124
 
 
 
 
125
  ### 3.3 关键配置参数
126
 
127
  | 参数 | 值 | 说明 |
@@ -161,9 +168,13 @@
161
 
162
  ### 3.5 DES字段混淆
163
 
164
- 每个指纹字段使用独立的8字节DES密钥进行ECB模式加密,然后Base64编码,最后字段名映射为混淆名称。
 
 
 
 
165
 
166
- **混淆映射表(部分)**:
167
 
168
  | 原字段 | DES密钥 | 混淆名 |
169
  |--------|---------|--------|
@@ -201,11 +212,14 @@
201
  - 输入:gzip压缩后的JSON
202
  - 输出:hex字符串
203
 
204
- #### 完整性校验(tn字段)
205
  ```
206
- tn = md5(sorted_key_value_string(data))
 
 
207
  ```
208
- 对所有key排序后拼接`key+value`为字符串,取MD5。
 
209
 
210
  ### 3.7 API请求格式
211
 
@@ -216,7 +230,7 @@ POST https://fp-it-acc.portal101.cn/deviceprofile/v4
216
  {
217
  "appId": "default",
218
  "organization": "P9usCUBauxft8eAmUXaZ",
219
- "ep": "RSA加密的uid(hex)",
220
  "data": "AES加密后的hex字符串",
221
  "os": "web",
222
  "encode": 5,
@@ -371,7 +385,7 @@ func NewWithDialContextAndPlatform(...) *Client {
371
  ### 6.1 设置面板切换
372
 
373
  访问 WebUI 设置页面,在"接口平台"选项中选择:
374
- - **Android端(默认)**:使用Safari TLS指纹 + HTTP/2,device_id为固定值
375
  - **Web端(Chrome浏览器)**:使用Chrome TLS指纹+HTTP/2,动态生成数美device_id,轮询HIF头部
376
 
377
  切换后**立即生效**,无需重启服务。
 
11
  | User-Agent | `DeepSeek/2.1.2 Android/32` | Chrome 137 浏览器UA |
12
  | TLS指纹 | Safari | Chrome (HTTP/2) |
13
  | x-client-platform | `android` | `web` |
14
+ | device_id | 数美签发的真实 device_id(与 Web 端同一套签发逻辑) | 数美签发的真实 device_id |
15
  | x-hif-dliq | 无 | 动态轮询获取 |
16
  | x-hif-leim | 无 | 动态轮询获取 |
17
  | 浏览器特有头 | 无 | sec-ch-ua, origin, referer等 |
18
 
19
+ > 2026-09 更新:数美风控升级后,两端登录都改为向数美申请真实签发的 device_id
20
+ > (本地伪造值会被判定为 `RISK_DEVICE_DETECTED`)。协议细节见
21
+ > `docs/shumei-device-id-research.md` 第十五节。
22
+
23
  ### 架构设计
24
 
25
  ```
 
113
  │
114
  5. DES-ECB逐字段加密+字段名混淆
115
  │
116
+ 6. 计算tn = md5(排序后值拼接) ──→ 完整性校验(新版写入 er 字段)
117
  │
118
  7. JSON → Gzip压缩
119
  │
 
126
  11. getDeviceId() = "B" + deviceId
127
  ```
128
 
129
+ > 2026-09 风控升级后 protocol=279、字段名与 tn 算法均有变化,
130
+ > 完整的新版流水线见 `docs/shumei-device-id-research.md` 第 15.4 节。
131
+
132
  ### 3.3 关键配置参数
133
 
134
  | 参数 | 值 | 说明 |
 
168
 
169
  ### 3.5 DES字段混淆
170
 
171
+ 每个指纹字段使用独立的8字节DES密钥进行ECB模式加密,然后Base64编码。
172
+
173
+ > ⚠️ 下表是旧版 ConfusionInfo 的"明文名 → 混淆名"映射,已在 2026-09 升级中废弃。
174
+ > 新版直接使用混淆字段名(`xo`/`vr`/`lp`/…),密钥表见
175
+ > `docs/shumei-device-id-research.md` 第 15.2 节与 `internal/shumei/confusion.go`。
176
 
177
+ **旧版混淆映射表(历史记录)**:
178
 
179
  | 原字段 | DES密钥 | 混淆名 |
180
  |--------|---------|--------|
 
212
  - 输入:gzip压缩后的JSON
213
  - 输出:hex字符串
214
 
215
+ #### 完整性校验(tn字段 / 新版为 er)
216
  ```
217
+ 旧版:tn = md5(sorted key+value 拼接)
218
+ 新版(2026-09,protocol=279):er = DES('2j48yowr', md5(按键排序后仅拼接值))
219
+ 数字值 ×10000;嵌套对象记 "[object Object]";数组用逗号连接;undefined 记 "undefined"
220
  ```
221
+ 对字段按键名排序后拼接为字符串,取 MD5(新版再 DES 加密写入 `er` 字段)。
222
+ 旧协议上报会得到 `1902`,详见 `docs/shumei-device-id-research.md` 第十五节。
223
 
224
  ### 3.7 API请求格式
225
 
 
230
  {
231
  "appId": "default",
232
  "organization": "P9usCUBauxft8eAmUXaZ",
233
+ "ep": "RSA加密的uid(Base64)",
234
  "data": "AES加密后的hex字符串",
235
  "os": "web",
236
  "encode": 5,
 
385
  ### 6.1 设置面板切换
386
 
387
  访问 WebUI 设置页面,在"接口平台"选项中选择:
388
+ - **Android端(默认)**:使用Safari TLS指纹 + HTTP/2,device_id 由数美签发(按账号复用)
389
  - **Web端(Chrome浏览器)**:使用Chrome TLS指纹+HTTP/2,动态生成数美device_id,轮询HIF头部
390
 
391
  切换后**立即生效**,无需重启服务。
internal/config/account_identity.go CHANGED
@@ -2,33 +2,13 @@ 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
@@ -36,23 +16,6 @@ func AndroidAccountRangersID(a Account) string {
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 {
 
2
 
3
  import (
4
  "crypto/sha1"
 
5
  "encoding/hex"
6
  "strings"
7
  )
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // AndroidAccountRangersID 返回用于 x-rangers-id 请求头的值。
10
  //
11
+ // 优先级:用户显式填入 > 基于账号稳定生成的 19 位数字串(火山 Rangers 雪花 ID 形态)。
12
  func AndroidAccountRangersID(a Account) string {
13
  if v := strings.TrimSpace(a.RangersID); v != "" {
14
  return v
 
16
  return stableRangersID(a.Identifier())
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  // stableRangersID 基于账号标识生成 19 位数字串(火山 Rangers ID 形态,如 "7657846390059450882")。
20
  // 用 sha1 派生并截断到 19 位十进制。
21
  func stableRangersID(identifier string) string {
internal/config/store.go CHANGED
@@ -247,6 +247,23 @@ func (s *Store) UpdateAccountToken(identifier, token string) error {
247
  return s.saveLocked()
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  func (s *Store) Replace(cfg Config) error {
251
  s.mu.Lock()
252
  defer s.mu.Unlock()
 
247
  return s.saveLocked()
248
  }
249
 
250
+ // UpdateAccountDeviceID 持久化账号最新签发的数美 device_id,后续登录复用以保持
251
+ // 设备身份稳定(与浏览器端复用 SMID 的行为一致)。
252
+ func (s *Store) UpdateAccountDeviceID(identifier, deviceID string) error {
253
+ identifier = strings.TrimSpace(identifier)
254
+ if identifier == "" {
255
+ return errors.New("empty account identifier")
256
+ }
257
+ s.mu.Lock()
258
+ defer s.mu.Unlock()
259
+ idx, ok := s.findAccountIndexLocked(identifier)
260
+ if !ok {
261
+ return errors.New("account not found")
262
+ }
263
+ s.cfg.Accounts[idx].DeviceID = deviceID
264
+ return s.saveLocked()
265
+ }
266
+
267
  func (s *Store) Replace(cfg Config) error {
268
  s.mu.Lock()
269
  defer s.mu.Unlock()
internal/deepseek/client/client_auth.go CHANGED
@@ -16,29 +16,42 @@ import (
16
  "ds2api/internal/shumei"
17
  )
18
 
 
 
 
 
 
 
19
  func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
 
 
 
 
20
  clients := c.requestClientsForAccount(acc)
21
  platform := c.Store.PlatformMode()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
31
- config.Logger.Warn("[login] shumei device_id failed, using fallback", "error", err)
32
- deviceID = "web_device"
33
- }
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 != "" {
43
  payload["email"] = email
44
  } else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
@@ -58,6 +71,11 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
58
  }
59
  data, _ := resp["data"].(map[string]any)
60
  if intFrom(data["biz_code"]) != 0 {
 
 
 
 
 
61
  return "", fmt.Errorf("login failed: %v", data["biz_msg"])
62
  }
63
  bizData, _ := data["biz_data"].(map[string]any)
@@ -69,6 +87,21 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
69
  return token, nil
70
  }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) {
73
  if maxAttempts <= 0 {
74
  maxAttempts = c.maxRetries
 
16
  "ds2api/internal/shumei"
17
  )
18
 
19
+ // Login 用账号密码换取 DeepSeek token。
20
+ //
21
+ // device_id 是登录风控的关键:数美服务端会校验该值是否为自己签发的 SMID,
22
+ // 占位/伪造值会得到 RISK_DEVICE_DETECTED。因此这里向数美设备画像接口申请
23
+ // 真实签发的 device_id(与浏览器端 SDK 同协议),并落盘复用;若服务端仍判定
24
+ // 风险,则重新签发一次再试。
25
  func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
26
+ return c.loginWithDevice(ctx, acc, false)
27
+ }
28
+
29
+ func (c *Client) loginWithDevice(ctx context.Context, acc config.Account, regenerated bool) (string, error) {
30
  clients := c.requestClientsForAccount(acc)
31
  platform := c.Store.PlatformMode()
32
+ deviceID := strings.TrimSpace(acc.DeviceID)
33
+ if deviceID == "" || regenerated {
34
+ issued, err := c.issueDeviceID(ctx, acc)
35
+ if err != nil {
36
+ return "", fmt.Errorf("issue device id: %w", err)
37
+ }
38
+ deviceID = issued
39
+ acc.DeviceID = issued
40
+ if c.Store != nil && acc.Identifier() != "" {
41
+ if err := c.Store.UpdateAccountDeviceID(acc.Identifier(), issued); err != nil {
42
+ config.Logger.Warn("[login] persist device_id failed", "account", acc.Identifier(), "error", err)
43
+ }
44
+ }
45
+ }
46
+
47
  payload := map[string]any{
48
+ "password": strings.TrimSpace(acc.Password),
49
+ "device_id": deviceID,
50
+ "os": platform,
51
  }
52
  // 登录请求头需要带 x-rangers-id(HAR entry 51 显示登录请求也带这个头)。
53
  // 但登录请求不带 Authorization(用户尚未拿到 token)。
54
  loginHeaders := c.authHeadersWithRangers("", config.AndroidAccountRangersID(acc))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  if email := strings.TrimSpace(acc.Email); email != "" {
56
  payload["email"] = email
57
  } else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
 
71
  }
72
  data, _ := resp["data"].(map[string]any)
73
  if intFrom(data["biz_code"]) != 0 {
74
+ bizMsg, _ := data["biz_msg"].(string)
75
+ if isRiskDeviceFailure(bizMsg) && !regenerated {
76
+ config.Logger.Warn("[login] device flagged as risky, reissuing device_id", "account", acc.Identifier())
77
+ return c.loginWithDevice(ctx, acc, true)
78
+ }
79
  return "", fmt.Errorf("login failed: %v", data["biz_msg"])
80
  }
81
  bizData, _ := data["biz_data"].(map[string]any)
 
87
  return token, nil
88
  }
89
 
90
+ // issueDeviceID 向数美申请一个真实签发的设备画像 ID(形如 "B...")。
91
+ func (c *Client) issueDeviceID(ctx context.Context, acc config.Account) (string, error) {
92
+ id, err := shumei.GetDeviceID(ctx)
93
+ if err != nil {
94
+ return "", err
95
+ }
96
+ config.Logger.Info("[login] issued shumei device_id", "account", acc.Identifier())
97
+ return id, nil
98
+ }
99
+
100
+ // isRiskDeviceFailure 判断登录失败是否由设备风控判定引起。
101
+ func isRiskDeviceFailure(bizMsg string) bool {
102
+ return strings.Contains(strings.ToUpper(strings.TrimSpace(bizMsg)), "RISK_DEVICE")
103
+ }
104
+
105
  func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) {
106
  if maxAttempts <= 0 {
107
  maxAttempts = c.maxRetries
internal/shumei/api.go CHANGED
@@ -10,8 +10,8 @@ import (
10
  "time"
11
  )
12
 
13
- // callDeviceAPI POSTs the encrypted payload to the Shumei device profile API
14
- // and returns the deviceId from the response.
15
  func callDeviceAPI(ctx context.Context, ep, data string) (string, error) {
16
  payload := map[string]any{
17
  "appId": appId,
@@ -28,51 +28,69 @@ func callDeviceAPI(ctx context.Context, ep, data string) (string, error) {
28
  }
29
 
30
  url := "https://" + apiHost + apiPath
31
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
32
- if err != nil {
33
- return "", fmt.Errorf("create request: %w", err)
34
- }
35
- req.Header.Set("Content-Type", "application/json")
36
- req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
37
- req.Header.Set("Origin", "https://chat.deepseek.com")
38
- req.Header.Set("Referer", "https://chat.deepseek.com/")
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- resp, err := httpDoer.Do(req)
41
- if err != nil {
42
- return "", fmt.Errorf("http post: %w", err)
43
- }
44
- defer func() { _ = resp.Body.Close() }()
 
 
 
 
 
 
 
 
45
 
46
- respBody, err := io.ReadAll(resp.Body)
47
- if err != nil {
48
- return "", fmt.Errorf("read response: %w", err)
49
- }
50
 
51
- if resp.StatusCode != http.StatusOK {
52
- return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
53
- }
 
 
 
 
 
 
54
 
55
- var result map[string]any
56
- if err := json.Unmarshal(respBody, &result); err != nil {
57
- return "", fmt.Errorf("parse response: %w", err)
 
 
 
58
  }
59
-
60
- code, _ := result["code"].(float64)
61
- if int(code) != 1100 {
62
- return "", fmt.Errorf("api error code %v: %s", result["code"], string(respBody))
63
  }
64
-
65
- detail, _ := result["detail"].(map[string]any)
66
- deviceID, _ := detail["deviceId"].(string)
67
- if deviceID == "" {
68
- return "", fmt.Errorf("missing deviceId in response: %s", string(respBody))
69
- }
70
-
71
- return deviceID, nil
72
  }
73
 
74
- // httpDoer is the HTTP client used for Shumei API calls.
75
- // Initialized in init() using the project's Chrome TLS transport.
76
  var httpDoer httpDoerInterface
77
 
78
  type httpDoerInterface interface {
@@ -83,8 +101,7 @@ func init() {
83
  httpDoer = &http.Client{Timeout: defaultHTTPTimeout}
84
  }
85
 
86
- // SetHTTPDoer allows overriding the HTTP client (e.g. for testing or
87
- // to use the project's Chrome TLS transport).
88
  func SetHTTPDoer(d httpDoerInterface) {
89
  if d != nil {
90
  httpDoer = d
 
10
  "time"
11
  )
12
 
13
+ // callDeviceAPI 把加密后的画像上报到数美 deviceprofile/v4,返回服务端签发的
14
+ // deviceId(SMID)。code=1902 表示需要重试,这里按 SDK 的语义重建请求重试。
15
  func callDeviceAPI(ctx context.Context, ep, data string) (string, error) {
16
  payload := map[string]any{
17
  "appId": appId,
 
28
  }
29
 
30
  url := "https://" + apiHost + apiPath
31
+ var lastErr error
32
+ for attempt := 0; attempt < 3; attempt++ {
33
+ if attempt > 0 {
34
+ select {
35
+ case <-ctx.Done():
36
+ return "", ctx.Err()
37
+ case <-time.After(time.Duration(attempt) * 300 * time.Millisecond):
38
+ }
39
+ }
40
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
41
+ if err != nil {
42
+ return "", fmt.Errorf("create request: %w", err)
43
+ }
44
+ req.Header.Set("Content-Type", "application/json;charset=UTF-8")
45
+ req.Header.Set("User-Agent", webUserAgent)
46
+ req.Header.Set("Accept", "*/*")
47
+ req.Header.Set("Accept-Encoding", "gzip, deflate")
48
+ req.Header.Set("Origin", "https://chat.deepseek.com")
49
+ req.Header.Set("Referer", "https://"+apiHost+"/")
50
 
51
+ resp, err := httpDoer.Do(req)
52
+ if err != nil {
53
+ lastErr = fmt.Errorf("http post: %w", err)
54
+ continue
55
+ }
56
+ respBody, readErr := io.ReadAll(resp.Body)
57
+ _ = resp.Body.Close()
58
+ if readErr != nil {
59
+ return "", fmt.Errorf("read response: %w", readErr)
60
+ }
61
+ if resp.StatusCode != http.StatusOK {
62
+ return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
63
+ }
64
 
65
+ var result map[string]any
66
+ if err := json.Unmarshal(respBody, &result); err != nil {
67
+ return "", fmt.Errorf("parse response: %w", err)
68
+ }
69
 
70
+ code, _ := result["code"].(float64)
71
+ if int(code) == 1902 {
72
+ // 文档定义的"重试"信号,重新上报同一画像。
73
+ lastErr = fmt.Errorf("api error code %v: %s", result["code"], string(respBody))
74
+ continue
75
+ }
76
+ if int(code) != 1100 {
77
+ return "", fmt.Errorf("api error code %v: %s", result["code"], string(respBody))
78
+ }
79
 
80
+ detail, _ := result["detail"].(map[string]any)
81
+ deviceID, _ := detail["deviceId"].(string)
82
+ if deviceID == "" {
83
+ return "", fmt.Errorf("missing deviceId in response: %s", string(respBody))
84
+ }
85
+ return deviceID, nil
86
  }
87
+ if lastErr != nil {
88
+ return "", lastErr
 
 
89
  }
90
+ return "", fmt.Errorf("call api: no successful response")
 
 
 
 
 
 
 
91
  }
92
 
93
+ // httpDoer 是数美接口使用的 HTTP 客户端。
 
94
  var httpDoer httpDoerInterface
95
 
96
  type httpDoerInterface interface {
 
101
  httpDoer = &http.Client{Timeout: defaultHTTPTimeout}
102
  }
103
 
104
+ // SetHTTPDoer 允许替换 HTTP 客户端(例如换成项目内带 Chrome TLS 指纹的实现)。
 
105
  func SetHTTPDoer(d httpDoerInterface) {
106
  if d != nil {
107
  httpDoer = d
internal/shumei/confusion.go CHANGED
@@ -1,51 +1,71 @@
1
  package shumei
2
 
3
- // confusionEntry maps an original field name to its DES key and obfuscated name.
4
- type confusionEntry struct {
5
- desKey string
6
- obfuscated string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
 
9
- // confusionMapping is Configuration A for organization P9usCUBauxft8eAmUXaZ.
10
- var confusionMapping = map[string]confusionEntry{
11
- "appId": {"q8zb1bs1", "vw"},
12
- "canvas": {"qy1d6fmu", "mi"},
13
- "organization": {"ht432iov", "tj"},
14
- "os": {"i7g8jtql", "vh"},
15
- "platform": {"mcbmtg5y", "ye"},
16
- "plugins": {"j916a142", "vc"},
17
- "referer": {"rf149ntc", "hv"},
18
- "res": {"rbtd2cl6", "pi"},
19
- "sdkver": {"rw74ssux", "xh"},
20
- "status": {"4xx5u0ww", "uu"},
21
- "subVersion": {"9wg0vhb9", "xi"},
22
- "svm": {"mqcala0h", "nu"},
23
- "time": {"8e224y3f", "gw"},
24
- "timezone": {"3w9lg8pr", "oi"},
25
- "rtype": {"rx7ob4gc", "na"},
26
- "tn": {"ilnts67v", "nb"},
27
- "trees": {"al8x9zt7", "hc"},
28
- "ua": {"z5jc9qyp", "wr"},
29
- "url": {"6tbl1wpw", "no"},
30
- "vpw": {"xzvnu5jt", "st"},
31
  }
32
 
33
- // applyConfusion encrypts each field value with its DES key using ECB mode,
34
- // base64-encodes the result, and renames the field to its obfuscated name.
35
- // Fields not in the mapping are dropped.
36
- func applyConfusion(data map[string]string) map[string]string {
37
- out := make(map[string]string, len(data))
38
- for field, value := range data {
39
- entry, ok := confusionMapping[field]
40
- if !ok {
41
- continue
42
- }
43
- encrypted, err := desEncryptECB(entry.desKey, value)
44
- if err != nil {
45
- // Skip fields that fail to encrypt
46
- continue
47
- }
48
- out[entry.obfuscated] = encrypted
49
  }
50
- return out
51
  }
 
1
  package shumei
2
 
3
+ import "encoding/base64"
4
+
5
+ // 新版数美 SDK(fp.min.js,sdkver 3.0.0)不再使用可下载的 ConfusionInfo 配置表,
6
+ // 而是在构造 payload 时就地按字段做 DES 加密。这里保存每个字段对应的 DES 密钥,
7
+ // 全部由对真实 SDK 产物的逆向验证得到(见 docs/shumei-device-id-research.md)。
8
+ const (
9
+ // protocolVersion 是新版 SDK 写给服务端的协议号(旧版为 -1 / 4)。
10
+ protocolVersion = 279
11
+ )
12
+
13
+ // fieldDESKey 保存 payload 各字段的 DES 密钥。未列出的字段为明文。
14
+ var fieldDESKey = map[string]string{
15
+ // 基础字段
16
+ "cm": "bek5y8ck", // organization
17
+ "og": "17vnhxoy", // appId
18
+ "ki": "mbmwo0og", // os = web
19
+ "ek": "41uq54np", // sdkver
20
+ "xy": "hefiudnj", // rtype = all
21
+ "yp": "8dm48zka", // subVersion
22
+ "yi": "ncdmk4ad", // 页面加载至今的毫秒数
23
+ "er": "2j48yowr", // 完整性校验值(tn)
24
+ // 指纹采集字段
25
+ "tt": "kn6oyjo4", // platform
26
+ "lp": "6wvopcsc", // clientSize
27
+ "vx": "q5g3kuj3", // res
28
+ "fu": "a3l1z50h", // status
29
+ "vr": "jl8yq352", // canvas
30
+ "sc": "nndx05dv", // plugins
31
+ "eb": "d9fd217d", // referer
32
+ "xo": "4m15b11j", // userAgent
33
+ "wl": "j54lcv3c", // url
34
+ "ic": "ihaoil6z", // timezone
35
+ "aw": "8kzyjl46", // 随机 UID
36
+ "hk": "2emikljv", // 采集时间戳
37
+ "jt": "qikfxkpr", // 随机 UID
38
+ "xv": "uve4ek1b", // 采集时间戳
39
  }
40
 
41
+ // desField 用字段对应的密钥做 DES-ECB 加密并 base64 编码;字段无密钥时返回原值。
42
+ // 加密失败(密钥异常)时降级为原文,避免整条链路失败。
43
+ func desField(field, value string) string {
44
+ key, ok := fieldDESKey[field]
45
+ if !ok {
46
+ return value
47
+ }
48
+ encrypted, err := desEncryptECB(key, value)
49
+ if err != nil {
50
+ return value
51
+ }
52
+ return encrypted
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
+ // desEncryptECB 用 8 字节密钥对明文做 DES-ECB 加密(零填充到 8 字节整数倍,
56
+ // 已对齐时不额外补块,与 SDK 的 ZeroPadding 行为一致),返回 base64 字符串。
57
+ func desEncryptECB(key, plaintext string) (string, error) {
58
+ block, err := newDESCipher(key)
59
+ if err != nil {
60
+ return "", err
61
+ }
62
+ src := []byte(plaintext)
63
+ if rem := len(src) % desBlockSize; rem != 0 {
64
+ src = append(src, make([]byte, desBlockSize-rem)...)
65
+ }
66
+ dst := make([]byte, len(src))
67
+ for i := 0; i < len(src); i += desBlockSize {
68
+ block.Encrypt(dst[i:i+desBlockSize], src[i:i+desBlockSize])
 
 
69
  }
70
+ return base64.StdEncoding.EncodeToString(dst), nil
71
  }
internal/shumei/crypto.go CHANGED
@@ -15,10 +15,13 @@ import (
15
  "encoding/pem"
16
  "fmt"
17
  "sort"
 
18
  "strings"
19
  )
20
 
21
- // generateUUID returns a random UUID v4 string.
 
 
22
  func generateUUID() string {
23
  b := make([]byte, 16)
24
  _, _ = rand.Read(b)
@@ -28,8 +31,8 @@ func generateUUID() string {
28
  b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
29
  }
30
 
31
- // rsaEncryptUID encrypts uid with the Shumei RSA public key using PKCS#1 v1.5
32
- // and returns the result as a hex-encoded string.
33
  func rsaEncryptUID(uid string) (string, error) {
34
  block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + rsaPublicKey + "\n-----END PUBLIC KEY-----"))
35
  if block == nil {
@@ -47,69 +50,115 @@ func rsaEncryptUID(uid string) (string, error) {
47
  if err != nil {
48
  return "", fmt.Errorf("RSA encrypt: %w", err)
49
  }
50
- return hex.EncodeToString(ciphertext), nil
51
  }
52
 
53
- // desEncryptECB encrypts plaintext with DES in ECB mode using the given key
54
- // (8-byte string). The plaintext is zero-padded to a multiple of 8 bytes.
55
- // Returns the base64-encoded ciphertext.
56
- func desEncryptECB(key, plaintext string) (string, error) {
57
  block, err := des.NewCipher([]byte(key))
58
  if err != nil {
59
- return "", fmt.Errorf("des new cipher: %w", err)
60
- }
61
- src := []byte(plaintext)
62
- // Zero-pad to block size
63
- if rem := len(src) % des.BlockSize; rem != 0 {
64
- src = append(src, make([]byte, des.BlockSize-rem)...)
65
  }
66
- dst := make([]byte, len(src))
67
- for i := 0; i < len(src); i += des.BlockSize {
68
- block.Encrypt(dst[i:i+des.BlockSize], src[i:i+des.BlockSize])
69
- }
70
- return base64.StdEncoding.EncodeToString(dst), nil
71
  }
72
 
73
- // aesEncryptCBC encrypts plaintext with AES-CBC using the given key and IV,
74
- // applying zero-padding to a multiple of the block size.
75
  func aesEncryptCBC(key, iv, plaintext []byte) ([]byte, error) {
76
  block, err := aes.NewCipher(key)
77
  if err != nil {
78
  return nil, fmt.Errorf("aes new cipher: %w", err)
79
  }
80
- // Zero-pad to block size
81
  bs := aes.BlockSize
82
  if rem := len(plaintext) % bs; rem != 0 {
83
  plaintext = append(plaintext, make([]byte, bs-rem)...)
84
  }
85
  ciphertext := make([]byte, len(plaintext))
86
- mode := cipher.NewCBCEncrypter(block, iv)
87
- mode.CryptBlocks(ciphertext, plaintext)
88
  return ciphertext, nil
89
  }
90
 
91
- // computeTN computes the integrity check: MD5 of sorted key=value pairs
92
- // joined by "&".
93
- func computeTN(data map[string]string) string {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  keys := make([]string, 0, len(data))
95
  for k := range data {
96
  keys = append(keys, k)
97
  }
98
  sort.Strings(keys)
99
  var b strings.Builder
100
- for i, k := range keys {
101
- if i > 0 {
102
- b.WriteByte('&')
 
 
 
 
 
 
 
 
 
103
  }
104
- b.WriteString(k)
105
- b.WriteByte('=')
106
- b.WriteString(data[k])
107
  }
108
- hash := md5.Sum([]byte(b.String()))
109
- return hex.EncodeToString(hash[:])
110
  }
111
 
112
- // gzipCompress compresses data with gzip.
113
  func gzipCompress(data []byte) ([]byte, error) {
114
  var buf bytes.Buffer
115
  w := gzip.NewWriter(&buf)
@@ -122,3 +171,9 @@ func gzipCompress(data []byte) ([]byte, error) {
122
  }
123
  return buf.Bytes(), nil
124
  }
 
 
 
 
 
 
 
15
  "encoding/pem"
16
  "fmt"
17
  "sort"
18
+ "strconv"
19
  "strings"
20
  )
21
 
22
+ const desBlockSize = 8
23
+
24
+ // generateUUID 生成 UUID v4 字符串。
25
  func generateUUID() string {
26
  b := make([]byte, 16)
27
  _, _ = rand.Read(b)
 
31
  b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
32
  }
33
 
34
+ // rsaEncryptUID 用数美 RSA 公钥以 PKCS#1 v1.5 加密 uid,返回 base64 字符串
35
+ // (与 SDK 的 JSEncrypt.encrypt 输出一致;服务端按 base64 解码 ep)。
36
  func rsaEncryptUID(uid string) (string, error) {
37
  block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + rsaPublicKey + "\n-----END PUBLIC KEY-----"))
38
  if block == nil {
 
50
  if err != nil {
51
  return "", fmt.Errorf("RSA encrypt: %w", err)
52
  }
53
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
54
  }
55
 
56
+ // newDESCipher 构造 DES 分组密码。
57
+ func newDESCipher(key string) (cipher.Block, error) {
 
 
58
  block, err := des.NewCipher([]byte(key))
59
  if err != nil {
60
+ return nil, fmt.Errorf("des new cipher: %w", err)
 
 
 
 
 
61
  }
62
+ return block, nil
 
 
 
 
63
  }
64
 
65
+ // aesEncryptCBC 用给定密钥和 IV 做 AES-CBC 加密(零填充到 16 字节整数倍,
66
+ // 已对齐时不额外补块),返回原始密文字节。
67
  func aesEncryptCBC(key, iv, plaintext []byte) ([]byte, error) {
68
  block, err := aes.NewCipher(key)
69
  if err != nil {
70
  return nil, fmt.Errorf("aes new cipher: %w", err)
71
  }
 
72
  bs := aes.BlockSize
73
  if rem := len(plaintext) % bs; rem != 0 {
74
  plaintext = append(plaintext, make([]byte, bs-rem)...)
75
  }
76
  ciphertext := make([]byte, len(plaintext))
77
+ cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)
 
78
  return ciphertext, nil
79
  }
80
 
81
+ // undefinedValue 表示 JS 中的 undefined:在 tn 拼接里贡献字符串 "undefined",
82
+ // 但不会被序列化进 JSON。
83
+ type undefinedValue struct{}
84
+
85
+ // jsNumberString 按 JavaScript 的 String(number) 规则格式化数字:
86
+ // 整数直接输出十进制(不使用科学计数法)。
87
+ func jsNumberString(v float64) string {
88
+ if v == float64(int64(v)) {
89
+ return strconv.FormatInt(int64(v), 10)
90
+ }
91
+ return strconv.FormatFloat(v, 'f', -1, 64)
92
+ }
93
+
94
+ // jsValueString 复刻 JS 中 `” + value` 的字符串化行为:
95
+ // 数字不加倍率(倍率只在对象直接值那一层生效)、布尔转 true/false、
96
+ // 嵌套对象为 "[object Object]"、数组用逗号连接(null/undefined 视为空串)。
97
+ func jsValueString(v any) string {
98
+ switch x := v.(type) {
99
+ case nil:
100
+ return "null"
101
+ case undefinedValue:
102
+ return "undefined"
103
+ case string:
104
+ return x
105
+ case bool:
106
+ if x {
107
+ return "true"
108
+ }
109
+ return "false"
110
+ case int:
111
+ return strconv.Itoa(x)
112
+ case int64:
113
+ return strconv.FormatInt(x, 10)
114
+ case float64:
115
+ return jsNumberString(x)
116
+ case map[string]any:
117
+ return "[object Object]"
118
+ case []any:
119
+ parts := make([]string, 0, len(x))
120
+ for _, item := range x {
121
+ if item == nil || item == (any)(undefinedValue{}) {
122
+ parts = append(parts, "")
123
+ continue
124
+ }
125
+ parts = append(parts, jsValueString(item))
126
+ }
127
+ return strings.Join(parts, ",")
128
+ case []string:
129
+ return strings.Join(x, ",")
130
+ default:
131
+ return fmt.Sprint(v)
132
+ }
133
+ }
134
+
135
+ // computeTN 复刻 SDK 的完整性校验:按键名排序后拼接所有字段值,
136
+ // 数字值乘以 0x2710(10000),拼接结果取 MD5。字段名与分隔符均不参与。
137
+ func computeTN(data map[string]any) string {
138
  keys := make([]string, 0, len(data))
139
  for k := range data {
140
  keys = append(keys, k)
141
  }
142
  sort.Strings(keys)
143
  var b strings.Builder
144
+ for _, k := range keys {
145
+ switch v := data[k].(type) {
146
+ case bool:
147
+ b.WriteString(jsValueString(v))
148
+ case int:
149
+ b.WriteString(strconv.FormatInt(int64(v)*0x2710, 10))
150
+ case int64:
151
+ b.WriteString(strconv.FormatInt(v*0x2710, 10))
152
+ case float64:
153
+ b.WriteString(jsNumberString(v * 0x2710))
154
+ default:
155
+ b.WriteString(jsValueString(v))
156
  }
 
 
 
157
  }
158
+ return md5hex(b.String())
 
159
  }
160
 
161
+ // gzipCompress 使用 gzip 压缩(对应 SDK 里的 pako.gzip)。
162
  func gzipCompress(data []byte) ([]byte, error) {
163
  var buf bytes.Buffer
164
  w := gzip.NewWriter(&buf)
 
171
  }
172
  return buf.Bytes(), nil
173
  }
174
+
175
+ // md5hex 返回字符串的 MD5 十六进制摘要。
176
+ func md5hex(s string) string {
177
+ h := md5.Sum([]byte(s))
178
+ return hex.EncodeToString(h[:])
179
+ }
internal/shumei/device_id.go CHANGED
@@ -3,9 +3,12 @@ package shumei
3
  import (
4
  "context"
5
  "crypto/md5"
 
6
  "encoding/hex"
7
  "encoding/json"
8
  "fmt"
 
 
9
  "time"
10
  )
11
 
@@ -18,104 +21,272 @@ const (
18
  sdkVersion = "3.0.0"
19
  subVersion = "1.0.0"
20
  aesIV = "0102030405060708"
 
 
21
  )
22
 
23
- // GetDeviceID generates a Shumei device_id for web platform login.
24
- // It returns the device ID prefixed with "B".
 
 
 
 
 
 
25
  func GetDeviceID(ctx context.Context) (string, error) {
26
- // 1. Generate UUID v4
27
- uid := generateUUID()
28
 
29
- // 2. Compute priId = md5(uid)[:16]
30
- hash := md5.Sum([]byte(uid))
31
- priId := hex.EncodeToString(hash[:])[:16]
 
 
 
 
 
 
 
 
 
32
 
33
- // 3. RSA encrypt uid
34
  ep, err := rsaEncryptUID(uid)
35
  if err != nil {
36
  return "", fmt.Errorf("rsa encrypt: %w", err)
37
  }
38
 
39
- // 4. Build fingerprint data
40
- fpData := buildFingerprintData(uid)
41
 
42
- // 5. Apply DES confusion (encrypt + rename fields)
43
- confused := applyConfusion(fpData)
 
 
 
44
 
45
- // 6. Compute tn (integrity check) and add remaining fields
46
- tn := computeTN(confused)
47
- confused["nb"] = tn // "tn" maps to "nb" in confusion
48
- confused["protocol"] = "-1"
49
-
50
- // 7. JSON marshal, gzip, AES-CBC encrypt
51
- data, err := encryptPayload(confused, priId)
52
  if err != nil {
53
  return "", fmt.Errorf("encrypt payload: %w", err)
54
  }
55
 
56
- // 8. Call API
57
  deviceID, err := callDeviceAPI(ctx, ep, data)
58
  if err != nil {
59
- return "", fmt.Errorf("call api: %w", err)
 
 
 
60
  }
61
-
62
  return "B" + deviceID, nil
63
  }
64
 
65
- // buildFingerprintData constructs the fingerprint fields with realistic but
66
- // minimal values suitable for server-side use.
67
- func buildFingerprintData(uid string) map[string]string {
 
 
68
  now := time.Now()
69
- return map[string]string{
70
- "appId": appId,
71
- "organization": organization,
72
- "os": "web",
73
- "sdkver": sdkVersion,
74
- "subVersion": subVersion,
75
- "platform": "Win32",
76
- "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
77
- "timezone": "-480",
78
- "res": "1920_1080_24_1",
79
- "canvas": generateCanvasHash(),
80
- "plugins": "",
81
- "referer": "https://chat.deepseek.com/",
82
- "url": "https://chat.deepseek.com/",
83
- "status": "",
84
- "vpw": uid,
85
- "svm": fmt.Sprintf("%d", now.UnixMilli()),
86
- "trees": uid,
87
- "rtype": "1",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
  }
90
 
91
- // generateCanvasHash returns a deterministic but random-looking MD5 hash
92
- // for the canvas fingerprint field.
93
- func generateCanvasHash() string {
94
- h := md5.Sum([]byte("shumei-canvas-fp-v1"))
95
- return hex.EncodeToString(h[:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
 
98
- // encryptPayload marshals the confused data to JSON, gzip-compresses it,
99
- // then AES-CBC encrypts with the given key and a fixed IV.
100
- // Returns the base64-encoded ciphertext.
101
- func encryptPayload(data map[string]string, priId string) (string, error) {
102
  jsonData, err := json.Marshal(data)
103
  if err != nil {
104
  return "", fmt.Errorf("json marshal: %w", err)
105
  }
106
-
107
  compressed, err := gzipCompress(jsonData)
108
  if err != nil {
109
  return "", fmt.Errorf("gzip compress: %w", err)
110
  }
111
-
112
- // AES-CBC encrypt: key = priId (16 bytes), IV = fixed
113
  key := []byte(priId)
114
  iv := []byte(aesIV)
115
- ciphertext, err := aesEncryptCBC(key, iv, compressed)
116
  if err != nil {
117
  return "", fmt.Errorf("aes encrypt: %w", err)
118
  }
119
-
120
  return hex.EncodeToString(ciphertext), nil
121
  }
 
3
  import (
4
  "context"
5
  "crypto/md5"
6
+ "encoding/base64"
7
  "encoding/hex"
8
  "encoding/json"
9
  "fmt"
10
+ "strconv"
11
+ "strings"
12
  "time"
13
  )
14
 
 
21
  sdkVersion = "3.0.0"
22
  subVersion = "1.0.0"
23
  aesIV = "0102030405060708"
24
+ // 指纹中声明为 Windows Chrome/Edge 浏览器环境(与 web 端 SDK 一致)。
25
+ webUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0"
26
  )
27
 
28
+ // DeviceOptions 用于调整设备画像生成。
29
+ type DeviceOptions struct {
30
+ // UID 指定设备标识来源(UUID 字符串)。留空则随机生成。
31
+ UID string
32
+ }
33
+
34
+ // GetDeviceID 生成一个数美设备画像 ID(服务端签发的 SMID,返回值带 "B" 前缀),
35
+ // 与浏览器端 SDK 的 window.SMSdk.getDeviceId() 语义一致。
36
  func GetDeviceID(ctx context.Context) (string, error) {
37
+ return GetDeviceIDWithOptions(ctx, DeviceOptions{})
38
+ }
39
 
40
+ // GetDeviceIDWithOptions 按新版 SDK(sdkver 3.0.0,protocol=279)的流水线生成设备 ID:
41
+ //
42
+ // uid -> priId/ep(RSA,base64) -> 指纹字段(逐字段 DES) -> tn -> er
43
+ // -> gzip -> base64 -> AES-CBC(priId) -> hex -> POST deviceprofile/v4
44
+ //
45
+ // 返回值形如 "B<deviceId>",可直接作为登录请求体的 device_id。
46
+ func GetDeviceIDWithOptions(ctx context.Context, opts DeviceOptions) (string, error) {
47
+ start := time.Now()
48
+ uid := strings.TrimSpace(opts.UID)
49
+ if uid == "" {
50
+ uid = generateUUID()
51
+ }
52
 
53
+ priId := md5hex(uid)[:16]
54
  ep, err := rsaEncryptUID(uid)
55
  if err != nil {
56
  return "", fmt.Errorf("rsa encrypt: %w", err)
57
  }
58
 
59
+ // 1. 构造指纹字段(tn 在 er 写入之前计算)。
60
+ payload := buildFingerprintData(uid, start)
61
 
62
+ // 2. 完整性校验:按键排序拼接所有值后取 MD5,再用 er 字段的密钥加密。
63
+ tn := computeTN(payload)
64
+ payload["er"] = desField("er", tn)
65
+ // yu 在真实 SDK 里是 undefined:参与 tn,但不进入 JSON。
66
+ delete(payload, "yu")
67
 
68
+ // 3. JSON -> gzip -> base64 -> AES-CBC -> hex。
69
+ data, err := encryptPayload(payload, priId)
 
 
 
 
 
70
  if err != nil {
71
  return "", fmt.Errorf("encrypt payload: %w", err)
72
  }
73
 
74
+ // 4. 上报 deviceprofile/v4。
75
  deviceID, err := callDeviceAPI(ctx, ep, data)
76
  if err != nil {
77
+ return "", err
78
+ }
79
+ if strings.TrimSpace(deviceID) == "" {
80
+ return "", fmt.Errorf("empty device id from api")
81
  }
 
82
  return "B" + deviceID, nil
83
  }
84
 
85
+ // buildFingerprintData 组装与浏览器端一致的指纹字段集合。
86
+ // 字段名、DES 密钥、明文/密文属性均与真实 SDK 产物逐字段比对(见
87
+ // docs/shumei-device-id-research.md)。无法真实采集的浏览器信号使用稳定、
88
+ // 格式合理的替代值,保证 tn 自洽。
89
+ func buildFingerprintData(uid string, start time.Time) map[string]any {
90
  now := time.Now()
91
+ nowMs := now.UnixMilli()
92
+ elapsed := now.Sub(start).Milliseconds()
93
+ if elapsed < 1 {
94
+ elapsed = 1
95
+ }
96
+ ts := strconv.FormatInt(nowMs, 10)
97
+
98
+ // 屏幕/视口等环境值(Windows 1080p 常见取值)。
99
+ const (
100
+ screenWidth = int64(1920)
101
+ screenHeight = int64(1080)
102
+ availHeight = int64(1040)
103
+ innerHeight = int64(943)
104
+ cpuCount = int64(8)
105
+ )
106
+
107
+ data := map[string]any{
108
+ // ---- 基础字段 ----
109
+ "protocol": int64(protocolVersion),
110
+ "cm": desField("cm", organization),
111
+ "og": desField("og", appId),
112
+ "ki": desField("ki", "web"),
113
+ "version": sdkVersion,
114
+ "ek": desField("ek", sdkVersion),
115
+ "kd": "",
116
+ "xy": desField("xy", "all"),
117
+ "smid": localSMID(uid, now),
118
+ "yp": desField("yp", subVersion),
119
+ "yi": desField("yi", strconv.FormatInt(elapsed, 10)),
120
+
121
+ // ---- 环境指纹(DES 加密)----
122
+ "tt": desField("tt", "Win32"),
123
+ "lp": desField("lp", fmt.Sprintf("0_0_%d_%d_%d_%d_%d_%d",
124
+ screenWidth, innerHeight, screenWidth, screenHeight, screenWidth, availHeight)),
125
+ "vx": desField("vx", fmt.Sprintf("%d_%d_24_1", screenWidth, screenHeight)),
126
+ "fu": desField("fu", "0010"),
127
+ "vr": desField("vr", canvasHash(uid)),
128
+ "sc": desField("sc", "-"),
129
+ "eb": desField("eb", "https://chat.deepseek.com/"),
130
+ "xo": desField("xo", webUserAgent),
131
+ "wl": desField("wl", "https://chat.deepseek.com/sign_in"),
132
+ "ic": desField("ic", "-480"),
133
+ "aw": desField("aw", uid),
134
+ "hk": desField("hk", ts),
135
+ "jt": desField("jt", uid),
136
+ "xv": desField("xv", ts),
137
+
138
+ // ---- 环境指纹(明文)----
139
+ "cdp": int64(1),
140
+ "maxTouchPoints": int64(0),
141
+ "connectionRtt": int64(50),
142
+ "cpucount": cpuCount,
143
+ "battery": map[string]any{"charging": int64(1), "level": int64(1)},
144
+ "incognito": incognitoProfile(),
145
+ "ag": jsDateString(now),
146
+ "ah": int64(-1),
147
+ "bq": int64(1),
148
+ "cq": int64(1),
149
+ "dn": int64(0),
150
+ "documentExist": int64(1),
151
+ "fe": int64(1),
152
+ "ff": int64(-1),
153
+ "fj": availHeight,
154
+ "fk": cpuCount,
155
+ "gc": "",
156
+ "gy": int64(0),
157
+ "hb": "",
158
+ "hz": "",
159
+ "je": "",
160
+ "jk": "",
161
+ "jo": int64(0),
162
+ "kf": screenWidth,
163
+ "kj": int64(0),
164
+ "ks": int64(1),
165
+ "kv": browserTimeZone(),
166
+ "kw": "",
167
+ "ld": "",
168
+ "lr": int64(0),
169
+ "mu": int64(0),
170
+ "ov": "",
171
+ "po": "",
172
+ "pp": int64(0),
173
+ "qa": int64(0),
174
+ "qg": md5hex("") + "|" + strings.Repeat("1", 65),
175
+ "qm": map[string]any{"red": "0"},
176
+ "qx": "",
177
+ "rq": []any{},
178
+ "t": nowMs,
179
+ "collectTime": elapsed,
180
+ "tn": domFeatureList(),
181
+ "ts": int64(1),
182
+ "uo": int64(-1),
183
+ "up": int64(0),
184
+ "uq": int64(1),
185
+ "uu": map[string]any{"maxTouchPoints": int64(0), "touchEvent": false, "touchStart": false},
186
+ "uy": "",
187
+ "vn": innerHeight,
188
+ "vy": int64(-1),
189
+ "xd": map[string]any{},
190
+ "ym": []any{},
191
+ "zl": int64(0),
192
+ "zx": screenWidth,
193
+
194
+ // yu 在真实采集里为 undefined:参与 tn(贡献 "undefined"),但不写入 JSON。
195
+ "yu": undefinedValue{},
196
+ }
197
+ return data
198
+ }
199
+
200
+ // incognitoProfile 对应 SDK 的 incognito 检测结果(Edge Chromium 常规窗口)。
201
+ func incognitoProfile() map[string]any {
202
+ return map[string]any{
203
+ "getDirectoryExist": int64(0),
204
+ "getDirectoryIncognito": int64(0),
205
+ "maxTouchPointsExist": int64(1),
206
+ "indexedDBIncognito": int64(0),
207
+ "openDatabaseExist": int64(0),
208
+ "openDatabaseIncognito": int64(1),
209
+ "localStorageExist": int64(1),
210
+ "localStorageIncognito": int64(0),
211
+ "promiseExist": int64(1),
212
+ "promiseAllSettledExist": int64(1),
213
+ "queryUsageAndQuotaIncognito": int64(0),
214
+ "webkitRequestFileSystemIncognito": int64(0),
215
+ "serviceWorkerExist": int64(0),
216
+ "indexedDBExist": int64(0),
217
+ "browserName": "Edge (Chromium)",
218
  }
219
  }
220
 
221
+ // domFeatureList 对应 SDK 采集的 document 能力列表(用于环境一致性判定)。
222
+ func domFeatureList() []any {
223
+ items := []string{
224
+ "createElement", "getElementById", "querySelector", "querySelectorAll",
225
+ "addEventListener", "removeEventListener", "referrer", "cookie", "body",
226
+ "head", "documentElement", "all", "hidden", "visibilityState",
227
+ }
228
+ out := make([]any, 0, len(items))
229
+ for _, item := range items {
230
+ out = append(out, item)
231
+ }
232
+ return out
233
+ }
234
+
235
+ // canvasHash 生成 8 位十六进制 canvas 指纹占位值。真实 SDK 取 canvas 渲染
236
+ // 结果的哈希后 4 字节;服务端只要求格式合理且同设备稳定。
237
+ func canvasHash(uid string) string {
238
+ sum := md5.Sum([]byte("ds2api:canvas:" + uid))
239
+ return hex.EncodeToString(sum[:])[:8]
240
+ }
241
+
242
+ // localSMID 复刻 SDK 的本地 smidV2 生成规则:
243
+ // YYYYMMDDHHmmss + md5(uid) + "00" + md5("smsk_web_"+前缀)[:14] + "0"。
244
+ func localSMID(uid string, now time.Time) string {
245
+ prefix := now.Format("20060102150405") + md5hex(uid) + "00"
246
+ return prefix + md5hex("smsk_web_" + prefix)[:14] + "0"
247
+ }
248
+
249
+ // jsDateString 近似 JS `new Date().toString()` 的输出格式。
250
+ func jsDateString(t time.Time) string {
251
+ _, offset := t.Zone()
252
+ zoneName := t.Format("MST")
253
+ if offset == 8*3600 {
254
+ zoneName = "中国标准时间"
255
+ }
256
+ sign := "+"
257
+ if offset < 0 {
258
+ sign = "-"
259
+ offset = -offset
260
+ }
261
+ return fmt.Sprintf("%s GMT%s%02d%02d (%s)",
262
+ t.Format("Mon Jan 2 2006 15:04:05"), sign, offset/3600, (offset%3600)/60, zoneName)
263
+ }
264
+
265
+ // browserTimeZone 返回 IANA 时区名(浏览器 Intl 解析结果)。
266
+ func browserTimeZone() string {
267
+ name := time.Local.String()
268
+ if name == "" || name == "Local" {
269
+ return "Asia/Shanghai"
270
+ }
271
+ return name
272
  }
273
 
274
+ // encryptPayload 序列化 -> gzip -> base64 -> AES-CBC(priId) -> hex。
275
+ // 对应 SDK:gzip(data) 得到 base64 字符串,再以 UTF-8 字节做 AES 加密并输出 hex。
276
+ func encryptPayload(data map[string]any, priId string) (string, error) {
 
277
  jsonData, err := json.Marshal(data)
278
  if err != nil {
279
  return "", fmt.Errorf("json marshal: %w", err)
280
  }
 
281
  compressed, err := gzipCompress(jsonData)
282
  if err != nil {
283
  return "", fmt.Errorf("gzip compress: %w", err)
284
  }
 
 
285
  key := []byte(priId)
286
  iv := []byte(aesIV)
287
+ ciphertext, err := aesEncryptCBC(key, iv, []byte(base64.StdEncoding.EncodeToString(compressed)))
288
  if err != nil {
289
  return "", fmt.Errorf("aes encrypt: %w", err)
290
  }
 
291
  return hex.EncodeToString(ciphertext), nil
292
  }
internal/shumei/device_id_test.go ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package shumei
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/gzip"
6
+ "crypto/aes"
7
+ "crypto/cipher"
8
+ "crypto/des"
9
+ "encoding/base64"
10
+ "encoding/hex"
11
+ "encoding/json"
12
+ "io"
13
+ "testing"
14
+ "time"
15
+ )
16
+
17
+ // 黄金向量来自真实 SDK(fp.min.js)产物的逐字段逆向比对,
18
+ // 用于锁定 DES 密钥与字段映射,避免回归。
19
+ func TestDesFieldGoldenVectors(t *testing.T) {
20
+ cases := []struct {
21
+ field string
22
+ plain string
23
+ want string
24
+ }{
25
+ {"cm", "P9usCUBauxft8eAmUXaZ", "H+EecD5hztpKzBMQTLWU4d5B+231eLpF"},
26
+ {"og", "default", "RM4Ua7M8TOY="},
27
+ {"ki", "web", "ZkwHo2tCf1Y="},
28
+ {"ek", "3.0.0", "rfWPCUIn1Dw="},
29
+ {"xy", "all", "EV0nWOMsDcA="},
30
+ {"yp", "1.0.0", "FzUZoGkelHs="},
31
+ {"tt", "Win32", "MNf5TGe2bQk="},
32
+ {"sc", "-", "GaOEg6366/A="},
33
+ {"ic", "-480", "trivWIecDMk="},
34
+ {"vr", "84a98c21", "D4W3wiSxij4="},
35
+ {"lp", "0_0___1920_1080_1920_1040", "a1ThDzq91611uNk9jBK0pq789EEbGpu8WjOWze601ys="},
36
+ {"vx", "1920_1080_24_1", "Gv8lUtxPvEg7/3E8t/5tig=="},
37
+ {"aw", "88888888-8888-4888-8888-888888888888", "jagfOZaQI/NY9yo7u8LJotcRkovfvJ8JjagfOZaQI/PWurahikQDkg=="},
38
+ }
39
+ for _, tc := range cases {
40
+ got := desField(tc.field, tc.plain)
41
+ if got != tc.want {
42
+ t.Errorf("desField(%q, %q) = %q, want %q", tc.field, tc.plain, got, tc.want)
43
+ }
44
+ }
45
+ }
46
+
47
+ // 无密钥字段保持明文。
48
+ func TestDesFieldPlainPassthrough(t *testing.T) {
49
+ if got := desField("version", "3.0.0"); got != "3.0.0" {
50
+ t.Fatalf("plain field changed: %q", got)
51
+ }
52
+ }
53
+
54
+ // computeTN 需复刻 JS 语义:数字 ×10000、嵌套对象 "[object Object]"、
55
+ // 数组逗号连接、undefined 贡献 "undefined"、按键名排序。
56
+ func TestComputeTNJSSemantics(t *testing.T) {
57
+ data := map[string]any{
58
+ "a": int64(2), // -> 20000
59
+ "b": "x", // -> x
60
+ "c": true, // -> true
61
+ "d": map[string]any{"k": int64(1)}, // -> [object Object]
62
+ "e": []any{"p", "q"}, // -> p,q
63
+ "f": undefinedValue{}, // -> undefined
64
+ "g": int64(0), // -> 0
65
+ "h": "", // -> 空串
66
+ }
67
+ want := md5hex("20000" + "x" + "true" + "[object Object]" + "p,q" + "undefined" + "0" + "")
68
+ // 排序后键序为 a,b,c,d,e,f,g,h
69
+ if got := computeTN(data); got != want {
70
+ t.Fatalf("computeTN = %s, want %s", got, want)
71
+ }
72
+ }
73
+
74
+ // 端到端自洽:字段集合可被还原,er 解密回 tn,yu 不进入 JSON。
75
+ func TestPayloadRoundTrip(t *testing.T) {
76
+ uid := "11111111-2222-4333-8444-555555555555"
77
+ start := time.Now()
78
+ payload := buildFingerprintData(uid, start)
79
+
80
+ if got, _ := payload["protocol"].(int64); got != protocolVersion {
81
+ t.Fatalf("protocol = %v, want %d", payload["protocol"], protocolVersion)
82
+ }
83
+
84
+ tn := computeTN(payload)
85
+ if len(tn) != 32 {
86
+ t.Fatalf("tn length = %d, want 32", len(tn))
87
+ }
88
+ payload["er"] = desField("er", tn)
89
+ delete(payload, "yu")
90
+
91
+ priId := md5hex(uid)[:16]
92
+ data, err := encryptPayload(payload, priId)
93
+ if err != nil {
94
+ t.Fatalf("encryptPayload: %v", err)
95
+ }
96
+
97
+ // 解密还原 JSON
98
+ raw, err := hex.DecodeString(data)
99
+ if err != nil {
100
+ t.Fatalf("hex decode: %v", err)
101
+ }
102
+ block, err := aes.NewCipher([]byte(priId))
103
+ if err != nil {
104
+ t.Fatalf("aes: %v", err)
105
+ }
106
+ plain := make([]byte, len(raw))
107
+ cipher.NewCBCDecrypter(block, []byte(aesIV)).CryptBlocks(plain, raw)
108
+ b64 := string(bytes.TrimRight(plain, "\x00"))
109
+ compressed, err := base64.StdEncoding.DecodeString(b64)
110
+ if err != nil {
111
+ t.Fatalf("base64 decode: %v", err)
112
+ }
113
+ jsonData, err := gunzipBytes(compressed)
114
+ if err != nil {
115
+ t.Fatalf("gunzip: %v", err)
116
+ }
117
+ var restored map[string]any
118
+ if err := json.Unmarshal(jsonData, &restored); err != nil {
119
+ t.Fatalf("json: %v", err)
120
+ }
121
+
122
+ if _, exists := restored["yu"]; exists {
123
+ t.Fatalf("yu 不应出现在 JSON 中")
124
+ }
125
+ if restored["protocol"].(float64) != float64(protocolVersion) {
126
+ t.Fatalf("restored protocol = %v", restored["protocol"])
127
+ }
128
+ if _, exists := restored["er"]; !exists {
129
+ t.Fatalf("er 缺失")
130
+ }
131
+ // er 应能解回 tn
132
+ gotTN, err := desDecryptBase64("2j48yowr", restored["er"].(string))
133
+ if err != nil {
134
+ t.Fatalf("er decrypt: %v", err)
135
+ }
136
+ if gotTN != tn {
137
+ t.Fatalf("er 解密 = %q, want %q", gotTN, tn)
138
+ }
139
+ // 关键字段存在性
140
+ for _, key := range []string{
141
+ "cm", "og", "ki", "ek", "xy", "yp", "version", "kd", "smid",
142
+ "yi", "er", "tt", "lp", "vx", "fu", "vr", "sc", "eb", "xo", "wl", "ic",
143
+ "aw", "hk", "jt", "xv", "cdp", "battery", "incognito", "t", "collectTime", "tn",
144
+ } {
145
+ if _, exists := restored[key]; !exists {
146
+ t.Errorf("缺少字段 %q", key)
147
+ }
148
+ }
149
+ }
150
+
151
+ func gunzipBytes(data []byte) ([]byte, error) {
152
+ r, err := gzip.NewReader(bytes.NewReader(data))
153
+ if err != nil {
154
+ return nil, err
155
+ }
156
+ defer func() { _ = r.Close() }()
157
+ return io.ReadAll(r)
158
+ }
159
+
160
+ func desDecryptBase64(key, value string) (string, error) {
161
+ raw, err := base64.StdEncoding.DecodeString(value)
162
+ if err != nil {
163
+ return "", err
164
+ }
165
+ block, err := des.NewCipher([]byte(key))
166
+ if err != nil {
167
+ return "", err
168
+ }
169
+ out := make([]byte, len(raw))
170
+ for i := 0; i < len(raw); i += desBlockSize {
171
+ block.Decrypt(out[i:i+desBlockSize], raw[i:i+desBlockSize])
172
+ }
173
+ return string(bytes.TrimRight(out, "\x00")), nil
174
+ }
internal/sse/parser.go CHANGED
@@ -96,6 +96,11 @@ func ParseSSEChunkForContentDetailed(chunk map[string]any, thinkingEnabled bool,
96
  collectDirectFragments(path, chunk, v, &newType, &parts)
97
  updateTypeFromNestedResponse(path, v, &newType)
98
  partType := resolvePartType(path, thinkingEnabled, newType)
 
 
 
 
 
99
  finished := appendChunkValueContent(v, partType, &newType, &parts, path)
100
  if finished {
101
  return nil, nil, true, newType
@@ -168,6 +173,10 @@ func collectDirectFragments(path string, chunk map[string]any, v any, newType *s
168
  *newType = "text"
169
  appendContentPart(parts, content, "text")
170
  default:
 
 
 
 
171
  appendContentPart(parts, content, "text")
172
  }
173
  }
@@ -201,6 +210,10 @@ func updateTypeFromNestedResponse(path string, v any, newType *string) {
201
  *newType = "thinking"
202
  case "RESPONSE":
203
  *newType = "text"
 
 
 
 
204
  }
205
  }
206
  }
@@ -255,7 +268,7 @@ func appendChunkValueContent(v any, partType string, newType *string, parts *[]C
255
  }
256
  appendContentPart(parts, val, partType)
257
  case []any:
258
- pp, finished := extractContentRecursive(val, partType)
259
  if finished {
260
  return true
261
  }
@@ -310,6 +323,10 @@ func appendWrappedFragments(val map[string]any, partType string, newType *string
310
  *newType = "text"
311
  appendContentPart(parts, content, "text")
312
  default:
 
 
 
 
313
  appendContentPart(parts, content, partType)
314
  }
315
  }
@@ -322,12 +339,42 @@ func parseFragmentTypeContent(m map[string]any) (string, string, string) {
322
  }
323
 
324
  func appendContentPart(parts *[]ContentPart, content, kind string) {
325
- if content == "" {
326
  return
327
  }
328
  *parts = append(*parts, ContentPart{Text: content, Type: kind})
329
  }
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  var thinkClosePattern = regexp.MustCompile(`(?i)</\s*think\s*>`)
332
  var thinkOpenPattern = regexp.MustCompile(`(?i)<\s*think\s*>`)
333
 
@@ -389,7 +436,7 @@ func isStatusPath(path string) bool {
389
  return path == "response/status" || path == "status"
390
  }
391
 
392
- func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bool) {
393
  parts := make([]ContentPart, 0, len(items))
394
  for _, it := range items {
395
  m, ok := it.(map[string]any)
@@ -401,13 +448,17 @@ func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bo
401
  if !hasV {
402
  continue
403
  }
404
- if isStatusPath(itemPath) {
 
 
 
 
405
  if s, ok := itemV.(string); ok && strings.EqualFold(strings.TrimSpace(s), "FINISHED") {
406
  return nil, true
407
  }
408
  continue
409
  }
410
- if shouldSkipPath(itemPath) {
411
  continue
412
  }
413
  if content, ok := m["content"].(string); ok && content != "" {
 
96
  collectDirectFragments(path, chunk, v, &newType, &parts)
97
  updateTypeFromNestedResponse(path, v, &newType)
98
  partType := resolvePartType(path, thinkingEnabled, newType)
99
+ // 当前位置是工具卡片片段(TOOL_SEARCH/TOOL_OPEN 等)时,其 content/status
100
+ // 补丁只是前端卡片文案,不产出正文或思考内容。
101
+ if newType == "tool" && isFragmentScopedPath(path) {
102
+ return nil, nil, false, newType
103
+ }
104
  finished := appendChunkValueContent(v, partType, &newType, &parts, path)
105
  if finished {
106
  return nil, nil, true, newType
 
173
  *newType = "text"
174
  appendContentPart(parts, content, "text")
175
  default:
176
+ if isToolFragmentType(typeName) {
177
+ *newType = "tool"
178
+ continue
179
+ }
180
  appendContentPart(parts, content, "text")
181
  }
182
  }
 
210
  *newType = "thinking"
211
  case "RESPONSE":
212
  *newType = "text"
213
+ default:
214
+ if isToolFragmentType(typeName) {
215
+ *newType = "tool"
216
+ }
217
  }
218
  }
219
  }
 
268
  }
269
  appendContentPart(parts, val, partType)
270
  case []any:
271
+ pp, finished := extractContentRecursive(val, partType, path)
272
  if finished {
273
  return true
274
  }
 
323
  *newType = "text"
324
  appendContentPart(parts, content, "text")
325
  default:
326
+ if isToolFragmentType(typeName) {
327
+ *newType = "tool"
328
+ continue
329
+ }
330
  appendContentPart(parts, content, partType)
331
  }
332
  }
 
339
  }
340
 
341
  func appendContentPart(parts *[]ContentPart, content, kind string) {
342
+ if content == "" || kind == "tool" {
343
  return
344
  }
345
  *parts = append(*parts, ContentPart{Text: content, Type: kind})
346
  }
347
 
348
+ // isToolFragmentType 判断片段类型是否为工具卡片(联网搜索、打开网页等)。
349
+ // 观测到的类型:TOOL_SEARCH / TOOL_OPEN(思考流程)、SEARCH(非思考流程)。
350
+ // 这类片段的 content 是前端卡片文案,不属于回答正文。
351
+ func isToolFragmentType(typeName string) bool {
352
+ if typeName == "" {
353
+ return false
354
+ }
355
+ return strings.HasPrefix(typeName, "TOOL") || strings.HasPrefix(typeName, "SEARCH")
356
+ }
357
+
358
+ // isFragmentScopedPath 判断路径是否指向"当前片段"的字段
359
+ // (response/fragments/-N 或 response/fragments/-N/xxx)。
360
+ // "response/fragments" 本身表示追加新片段,必须参与片段类型切换,不算在内。
361
+ func isFragmentScopedPath(path string) bool {
362
+ return strings.HasPrefix(path, "response/fragments/")
363
+ }
364
+
365
+ // resolveNestedPath 把嵌套 patch 的相对路径还原成完整路径。
366
+ // 例如外层 response/fragments/-1 下的 status 实际是
367
+ // response/fragments/-1/status(片段级状态),而不是 response/status。
368
+ func resolveNestedPath(outer, inner string) string {
369
+ if inner == "" {
370
+ return outer
371
+ }
372
+ if outer == "" {
373
+ return inner
374
+ }
375
+ return outer + "/" + inner
376
+ }
377
+
378
  var thinkClosePattern = regexp.MustCompile(`(?i)</\s*think\s*>`)
379
  var thinkOpenPattern = regexp.MustCompile(`(?i)<\s*think\s*>`)
380
 
 
436
  return path == "response/status" || path == "status"
437
  }
438
 
439
+ func extractContentRecursive(items []any, defaultType string, outerPath string) ([]ContentPart, bool) {
440
  parts := make([]ContentPart, 0, len(items))
441
  for _, it := range items {
442
  m, ok := it.(map[string]any)
 
448
  if !hasV {
449
  continue
450
  }
451
+ // 嵌套 patch 的 p 是相对外层路径的,先还原成完整路径再判断:
452
+ // 否则 response/fragments/-1 下的 "status: FINISHED"(片段级状态)
453
+ // 会被误判为 response/status,导致整条流在搜索阶段被提前终止。
454
+ fullPath := resolveNestedPath(outerPath, itemPath)
455
+ if isStatusPath(fullPath) {
456
  if s, ok := itemV.(string); ok && strings.EqualFold(strings.TrimSpace(s), "FINISHED") {
457
  return nil, true
458
  }
459
  continue
460
  }
461
+ if shouldSkipPath(fullPath) {
462
  continue
463
  }
464
  if content, ok := m["content"].(string); ok && content != "" {
internal/sse/parser_edge_test.go CHANGED
@@ -475,7 +475,7 @@ func TestExtractContentRecursiveFinishedStatus(t *testing.T) {
475
  items := []any{
476
  map[string]any{"p": "status", "v": "FINISHED"},
477
  }
478
- parts, finished := extractContentRecursive(items, "text")
479
  if !finished {
480
  t.Fatal("expected finished on status FINISHED")
481
  }
@@ -488,7 +488,7 @@ func TestExtractContentRecursiveSkipsPath(t *testing.T) {
488
  items := []any{
489
  map[string]any{"p": "quasi_status", "v": "data"},
490
  }
491
- parts, finished := extractContentRecursive(items, "text")
492
  if finished {
493
  t.Fatal("expected not finished")
494
  }
@@ -501,7 +501,7 @@ func TestExtractContentRecursiveContentField(t *testing.T) {
501
  items := []any{
502
  map[string]any{"p": "x", "v": "val", "content": "actual content", "type": "RESPONSE"},
503
  }
504
- parts, _ := extractContentRecursive(items, "text")
505
  if len(parts) != 1 || parts[0].Text != "actual content" || parts[0].Type != "text" {
506
  t.Fatalf("unexpected parts: %#v", parts)
507
  }
@@ -511,7 +511,7 @@ func TestExtractContentRecursiveContentFieldThinkType(t *testing.T) {
511
  items := []any{
512
  map[string]any{"p": "x", "v": "val", "content": "think text", "type": "THINK"},
513
  }
514
- parts, _ := extractContentRecursive(items, "text")
515
  if len(parts) != 1 || parts[0].Type != "thinking" {
516
  t.Fatalf("expected thinking type for THINK content, got %#v", parts)
517
  }
@@ -521,7 +521,7 @@ func TestExtractContentRecursiveThinkingPath(t *testing.T) {
521
  items := []any{
522
  map[string]any{"p": "thinking_content", "v": "deep thought"},
523
  }
524
- parts, _ := extractContentRecursive(items, "text")
525
  if len(parts) != 1 || parts[0].Type != "thinking" || parts[0].Text != "deep thought" {
526
  t.Fatalf("unexpected parts for thinking path: %#v", parts)
527
  }
@@ -531,7 +531,7 @@ func TestExtractContentRecursiveContentPath(t *testing.T) {
531
  items := []any{
532
  map[string]any{"p": "content", "v": "text content"},
533
  }
534
- parts, _ := extractContentRecursive(items, "thinking")
535
  if len(parts) != 1 || parts[0].Type != "text" {
536
  t.Fatalf("expected text type for content path, got %#v", parts)
537
  }
@@ -541,7 +541,7 @@ func TestExtractContentRecursiveResponsePath(t *testing.T) {
541
  items := []any{
542
  map[string]any{"p": "response", "v": "text content"},
543
  }
544
- parts, _ := extractContentRecursive(items, "thinking")
545
  if len(parts) != 1 || parts[0].Type != "text" {
546
  t.Fatalf("expected text type for response path, got %#v", parts)
547
  }
@@ -551,7 +551,7 @@ func TestExtractContentRecursiveFragmentsPath(t *testing.T) {
551
  items := []any{
552
  map[string]any{"p": "fragments", "v": "fragment text"},
553
  }
554
- parts, _ := extractContentRecursive(items, "thinking")
555
  if len(parts) != 1 || parts[0].Type != "text" {
556
  t.Fatalf("expected text type for fragments path, got %#v", parts)
557
  }
@@ -568,7 +568,7 @@ func TestExtractContentRecursiveNestedArrayWithTypes(t *testing.T) {
568
  },
569
  },
570
  }
571
- parts, _ := extractContentRecursive(items, "text")
572
  if len(parts) != 3 {
573
  t.Fatalf("expected 3 parts, got %d: %#v", len(parts), parts)
574
  }
@@ -592,7 +592,7 @@ func TestExtractContentRecursiveEmptyContentSkipped(t *testing.T) {
592
  },
593
  },
594
  }
595
- parts, _ := extractContentRecursive(items, "text")
596
  if len(parts) != 0 {
597
  t.Fatalf("expected no parts for empty nested content, got %#v", parts)
598
  }
@@ -602,7 +602,7 @@ func TestExtractContentRecursiveFinishedString(t *testing.T) {
602
  items := []any{
603
  map[string]any{"p": "content", "v": "FINISHED"},
604
  }
605
- parts, _ := extractContentRecursive(items, "text")
606
  // "FINISHED" string value on non-status path should be skipped
607
  if len(parts) != 0 {
608
  t.Fatalf("expected FINISHED string to be skipped, got %#v", parts)
@@ -613,7 +613,7 @@ func TestExtractContentRecursiveNoVField(t *testing.T) {
613
  items := []any{
614
  map[string]any{"p": "content"},
615
  }
616
- parts, _ := extractContentRecursive(items, "text")
617
  if len(parts) != 0 {
618
  t.Fatalf("expected no parts for missing v field, got %#v", parts)
619
  }
@@ -621,7 +621,7 @@ func TestExtractContentRecursiveNoVField(t *testing.T) {
621
 
622
  func TestExtractContentRecursiveNonMapItem(t *testing.T) {
623
  items := []any{"just a string", 42}
624
- parts, _ := extractContentRecursive(items, "text")
625
  if len(parts) != 0 {
626
  t.Fatalf("expected no parts for non-map items, got %#v", parts)
627
  }
 
475
  items := []any{
476
  map[string]any{"p": "status", "v": "FINISHED"},
477
  }
478
+ parts, finished := extractContentRecursive(items, "text", "")
479
  if !finished {
480
  t.Fatal("expected finished on status FINISHED")
481
  }
 
488
  items := []any{
489
  map[string]any{"p": "quasi_status", "v": "data"},
490
  }
491
+ parts, finished := extractContentRecursive(items, "text", "")
492
  if finished {
493
  t.Fatal("expected not finished")
494
  }
 
501
  items := []any{
502
  map[string]any{"p": "x", "v": "val", "content": "actual content", "type": "RESPONSE"},
503
  }
504
+ parts, _ := extractContentRecursive(items, "text", "")
505
  if len(parts) != 1 || parts[0].Text != "actual content" || parts[0].Type != "text" {
506
  t.Fatalf("unexpected parts: %#v", parts)
507
  }
 
511
  items := []any{
512
  map[string]any{"p": "x", "v": "val", "content": "think text", "type": "THINK"},
513
  }
514
+ parts, _ := extractContentRecursive(items, "text", "")
515
  if len(parts) != 1 || parts[0].Type != "thinking" {
516
  t.Fatalf("expected thinking type for THINK content, got %#v", parts)
517
  }
 
521
  items := []any{
522
  map[string]any{"p": "thinking_content", "v": "deep thought"},
523
  }
524
+ parts, _ := extractContentRecursive(items, "text", "")
525
  if len(parts) != 1 || parts[0].Type != "thinking" || parts[0].Text != "deep thought" {
526
  t.Fatalf("unexpected parts for thinking path: %#v", parts)
527
  }
 
531
  items := []any{
532
  map[string]any{"p": "content", "v": "text content"},
533
  }
534
+ parts, _ := extractContentRecursive(items, "thinking", "")
535
  if len(parts) != 1 || parts[0].Type != "text" {
536
  t.Fatalf("expected text type for content path, got %#v", parts)
537
  }
 
541
  items := []any{
542
  map[string]any{"p": "response", "v": "text content"},
543
  }
544
+ parts, _ := extractContentRecursive(items, "thinking", "")
545
  if len(parts) != 1 || parts[0].Type != "text" {
546
  t.Fatalf("expected text type for response path, got %#v", parts)
547
  }
 
551
  items := []any{
552
  map[string]any{"p": "fragments", "v": "fragment text"},
553
  }
554
+ parts, _ := extractContentRecursive(items, "thinking", "")
555
  if len(parts) != 1 || parts[0].Type != "text" {
556
  t.Fatalf("expected text type for fragments path, got %#v", parts)
557
  }
 
568
  },
569
  },
570
  }
571
+ parts, _ := extractContentRecursive(items, "text", "")
572
  if len(parts) != 3 {
573
  t.Fatalf("expected 3 parts, got %d: %#v", len(parts), parts)
574
  }
 
592
  },
593
  },
594
  }
595
+ parts, _ := extractContentRecursive(items, "text", "")
596
  if len(parts) != 0 {
597
  t.Fatalf("expected no parts for empty nested content, got %#v", parts)
598
  }
 
602
  items := []any{
603
  map[string]any{"p": "content", "v": "FINISHED"},
604
  }
605
+ parts, _ := extractContentRecursive(items, "text", "")
606
  // "FINISHED" string value on non-status path should be skipped
607
  if len(parts) != 0 {
608
  t.Fatalf("expected FINISHED string to be skipped, got %#v", parts)
 
613
  items := []any{
614
  map[string]any{"p": "content"},
615
  }
616
+ parts, _ := extractContentRecursive(items, "text", "")
617
  if len(parts) != 0 {
618
  t.Fatalf("expected no parts for missing v field, got %#v", parts)
619
  }
 
621
 
622
  func TestExtractContentRecursiveNonMapItem(t *testing.T) {
623
  items := []any{"just a string", 42}
624
+ parts, _ := extractContentRecursive(items, "text", "")
625
  if len(parts) != 0 {
626
  t.Fatalf("expected no parts for non-map items, got %#v", parts)
627
  }
internal/sse/search_mode_test.go ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package sse
2
+
3
+ import (
4
+ "io"
5
+ "net/http"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // 联网模式(DEEP_SEARCH)的真实流片段:搜索阶段会给"当前片段"发片段级
11
+ // status=FINISHED,并夹带工具卡片文案。这里用最小复现流锁定解析行为。
12
+ const searchModeStream = `event: ready
13
+ data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}
14
+ event: update_session
15
+ data: {"p":"response/conversation_mode","v":"DEEP_SEARCH"}
16
+ data: {"v":{"response":{"message_id":2,"role":"ASSISTANT","thinking_enabled":true,"status":"WIP","fragments":[{"id":2,"type":"THINK","content":"用户想知道","references":[],"stage_id":1}]}}}
17
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"今天的天气"}
18
+ data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"北京 天气"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":true}]}
19
+ data: {"p":"response/fragments/-1/results","o":"SET","v":[{"url":"https://example.com","title":"北京天气","snippet":"多云","cite_index":null}]}
20
+ data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"status","v":"FINISHED"},{"p":"content","v":"搜索到 3 个网页"}]}
21
+ data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":4,"type":"THINK","content":"搜索","stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":true}]}
22
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"到相关信息"}
23
+ data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":5,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://example.com"}}]},{"p":"has_pending_fragment","o":"SET","v":true}]}
24
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"打开页面"}
25
+ data: {"p":"response/fragments","o":"APPEND","v":[{"id":6,"type":"RESPONSE","content":"北京今天","references":[],"stage_id":3}]}
26
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"多云"}
27
+ data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":190},{"p":"quasi_status","v":"FINISHED"}]}
28
+ data: {"p":"response/status","o":"SET","v":"FINISHED"}
29
+ data: [DONE]
30
+ `
31
+
32
+ // 片段级 status=FINISHED 不是流结束信号,不能提前终止解析(否则联网模式正文为空)。
33
+ func TestFragmentBatchStatusDoesNotFinishStream(t *testing.T) {
34
+ chunk := map[string]any{
35
+ "p": "response/fragments/-1",
36
+ "o": "BATCH",
37
+ "v": []any{
38
+ map[string]any{"p": "status", "v": "FINISHED"},
39
+ map[string]any{"p": "content", "v": "搜索到 3 个网页"},
40
+ },
41
+ }
42
+ parts, _, finished, nextType := ParseSSEChunkForContentDetailed(chunk, true, "tool")
43
+ if finished {
44
+ t.Fatal("片段级 FINISHED 被误判为流结束")
45
+ }
46
+ if len(parts) != 0 {
47
+ t.Fatalf("工具卡片片段不应产出内容,got %#v", parts)
48
+ }
49
+ if nextType != "tool" {
50
+ t.Fatalf("nextType = %q, want tool", nextType)
51
+ }
52
+ }
53
+
54
+ // 响应级(response/status)的 FINISHED 仍然是流结束信号。
55
+ func TestResponseBatchStatusStillFinishesStream(t *testing.T) {
56
+ chunk := map[string]any{
57
+ "p": "response",
58
+ "o": "BATCH",
59
+ "v": []any{
60
+ map[string]any{"p": "status", "v": "FINISHED"},
61
+ },
62
+ }
63
+ _, _, finished, _ := ParseSSEChunkForContentDetailed(chunk, true, "text")
64
+ if !finished {
65
+ t.Fatal("response/status=FINISHED 应终止流")
66
+ }
67
+ }
68
+
69
+ // 嵌套 patch 路径需要按外层路径还原:片段下的 status 不等于 response/status。
70
+ func TestExtractContentRecursiveResolvesNestedPath(t *testing.T) {
71
+ items := []any{map[string]any{"p": "status", "v": "FINISHED"}}
72
+
73
+ if _, finished := extractContentRecursive(items, "text", "response/fragments/-1"); finished {
74
+ t.Fatal("片段路径下的 status=FINISHED 不应结束流")
75
+ }
76
+ if _, finished := extractContentRecursive(items, "text", "response"); !finished {
77
+ t.Fatal("response/status=FINISHED 应结束流")
78
+ }
79
+ }
80
+
81
+ // 工具卡片片段(TOOL_*)的文案不进入正文。
82
+ func TestToolFragmentContentNotLeaked(t *testing.T) {
83
+ chunk := map[string]any{
84
+ "p": "response/fragments/-1/content",
85
+ "o": "APPEND",
86
+ "v": "搜索到 3 个网页",
87
+ }
88
+ parts, _, _, nextType := ParseSSEChunkForContentDetailed(chunk, true, "tool")
89
+ if len(parts) != 0 {
90
+ t.Fatalf("工具片段内容不应产出,got %#v", parts)
91
+ }
92
+ if nextType != "tool" {
93
+ t.Fatalf("nextType = %q, want tool", nextType)
94
+ }
95
+ }
96
+
97
+ // 端到端:联网模式的完整流必须保留正文、丢掉工具文案,思考内容不丢。
98
+ func TestCollectStreamSearchMode(t *testing.T) {
99
+ resp := &http.Response{
100
+ StatusCode: http.StatusOK,
101
+ Header: http.Header{},
102
+ Body: io.NopCloser(strings.NewReader(searchModeStream)),
103
+ }
104
+ res := CollectStream(resp, true, true)
105
+
106
+ if res.ContentFilter {
107
+ t.Fatal("不应判定为内容过滤")
108
+ }
109
+ if res.Text != "北京今天多云" {
110
+ t.Fatalf("Text = %q, want %q", res.Text, "北京今天多云")
111
+ }
112
+ if strings.Contains(res.Text, "搜索到") {
113
+ t.Fatalf("工具卡片文案泄漏进正文: %q", res.Text)
114
+ }
115
+ for _, want := range []string{"用户想知道", "今天的天气", "搜索", "到相关信息"} {
116
+ if !strings.Contains(res.Thinking, want) {
117
+ t.Fatalf("思考内容缺少 %q,got %q", want, res.Thinking)
118
+ }
119
+ }
120
+ if strings.Contains(res.Thinking, "打开页面") {
121
+ t.Fatalf("工具卡片文案泄漏进思考: %q", res.Thinking)
122
+ }
123
+ }
124
+
125
+ // 非思考联网流程的搜索片段类型是 SEARCH(而非 TOOL_SEARCH),同样不能进正文。
126
+ func TestSearchFragmentTypeNotLeaked(t *testing.T) {
127
+ const stream = `data: {"v":{"response":{"thinking_enabled":false,"fragments":[{"id":2,"type":"SEARCH","status":"WIP","content":null,"queries":[{"query":"上海天气"}],"results":[]}]}}}
128
+ data: {"p":"response/fragments/-1/results","o":"SET","v":[{"url":"https://example.com","title":"上海天气","snippet":"小雨"}]}
129
+ data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"status","v":"FINISHED"},{"p":"content","v":"搜索到 5 个网页"}]}
130
+ data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"RESPONSE","content":"上海今天","stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]}
131
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"小雨"}
132
+ data: {"p":"response/status","o":"SET","v":"FINISHED"}
133
+ data: [DONE]
134
+ `
135
+ resp := &http.Response{
136
+ StatusCode: http.StatusOK,
137
+ Header: http.Header{},
138
+ Body: io.NopCloser(strings.NewReader(stream)),
139
+ }
140
+ res := CollectStream(resp, false, true)
141
+ if res.Text != "上海今天小雨" {
142
+ t.Fatalf("Text = %q, want %q", res.Text, "上海今天小雨")
143
+ }
144
+ if strings.Contains(res.Text, "搜索到") {
145
+ t.Fatalf("搜索卡片文案泄漏进正文: %q", res.Text)
146
+ }
147
+ }
148
+
149
+ // 普通模式(无工具片段)行为不受影响。
150
+ func TestCollectStreamPlainModeUnchanged(t *testing.T) {
151
+ const stream = `data: {"v":{"response":{"fragments":[{"id":1,"type":"THINK","content":"思考"}]}}}
152
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"中"}
153
+ data: {"p":"response/fragments","o":"APPEND","v":[{"id":2,"type":"RESPONSE","content":"回答"}]}
154
+ data: {"p":"response/fragments/-1/content","o":"APPEND","v":"内容"}
155
+ data: {"p":"response/status","o":"SET","v":"FINISHED"}
156
+ data: [DONE]
157
+ `
158
+ resp := &http.Response{
159
+ StatusCode: http.StatusOK,
160
+ Header: http.Header{},
161
+ Body: io.NopCloser(strings.NewReader(stream)),
162
+ }
163
+ res := CollectStream(resp, true, true)
164
+ if res.Text != "回答内容" {
165
+ t.Fatalf("Text = %q, want %q", res.Text, "回答内容")
166
+ }
167
+ if res.Thinking != "思考中" {
168
+ t.Fatalf("Thinking = %q, want %q", res.Thinking, "思考中")
169
+ }
170
+ }