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 +1 -0
- docs/shumei-device-id-research.md +115 -0
- docs/web-endpoint-upgrade.md +23 -9
- internal/config/account_identity.go +1 -38
- internal/config/store.go +17 -0
- internal/deepseek/client/client_auth.go +48 -15
- internal/shumei/api.go +58 -41
- internal/shumei/confusion.go +63 -43
- internal/shumei/crypto.go +91 -36
- internal/shumei/device_id.go +229 -58
- internal/shumei/device_id_test.go +174 -0
- internal/sse/parser.go +56 -5
- internal/sse/parser_edge_test.go +13 -13
- internal/sse/search_mode_test.go +170 -0
|
@@ -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)/
|
|
@@ -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 |
+
同一账号后续登录保持设备身份稳定。
|
|
@@ -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 |
|
| 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(
|
| 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(
|
|
|
|
|
|
|
| 207 |
```
|
| 208 |
-
对
|
|
|
|
| 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(
|
| 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 |
切换后**立即生效**,无需重启服务。
|
|
@@ -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 |
-
// 优先级
|
| 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 {
|
|
@@ -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()
|
|
@@ -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":
|
|
|
|
|
|
|
| 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
|
|
@@ -10,8 +10,8 @@ import (
|
|
| 10 |
"time"
|
| 11 |
)
|
| 12 |
|
| 13 |
-
// callDeviceAPI
|
| 14 |
-
//
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
-
|
| 60 |
-
|
| 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
|
| 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
|
| 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
|
|
@@ -1,51 +1,71 @@
|
|
| 1 |
package shumei
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
//
|
| 34 |
-
//
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
}
|
| 48 |
-
out[entry.obfuscated] = encrypted
|
| 49 |
}
|
| 50 |
-
return
|
| 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 |
}
|
|
@@ -15,10 +15,13 @@ import (
|
|
| 15 |
"encoding/pem"
|
| 16 |
"fmt"
|
| 17 |
"sort"
|
|
|
|
| 18 |
"strings"
|
| 19 |
)
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
| 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
|
| 32 |
-
//
|
| 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
|
| 51 |
}
|
| 52 |
|
| 53 |
-
//
|
| 54 |
-
|
| 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
|
| 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 |
-
|
| 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
|
| 74 |
-
//
|
| 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 |
-
|
| 87 |
-
mode.CryptBlocks(ciphertext, plaintext)
|
| 88 |
return ciphertext, nil
|
| 89 |
}
|
| 90 |
|
| 91 |
-
//
|
| 92 |
-
//
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
-
b.WriteString(k)
|
| 105 |
-
b.WriteByte('=')
|
| 106 |
-
b.WriteString(data[k])
|
| 107 |
}
|
| 108 |
-
|
| 109 |
-
return hex.EncodeToString(hash[:])
|
| 110 |
}
|
| 111 |
|
| 112 |
-
// gzipCompress
|
| 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 |
+
}
|
|
@@ -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 |
-
//
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
func GetDeviceID(ctx context.Context) (string, error) {
|
| 26 |
-
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
priId
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
ep, err := rsaEncryptUID(uid)
|
| 35 |
if err != nil {
|
| 36 |
return "", fmt.Errorf("rsa encrypt: %w", err)
|
| 37 |
}
|
| 38 |
|
| 39 |
-
//
|
| 40 |
-
|
| 41 |
|
| 42 |
-
//
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
//
|
| 46 |
-
|
| 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 |
-
//
|
| 57 |
deviceID, err := callDeviceAPI(ctx, ep, data)
|
| 58 |
if err != nil {
|
| 59 |
-
return "",
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
-
|
| 62 |
return "B" + deviceID, nil
|
| 63 |
}
|
| 64 |
|
| 65 |
-
// buildFingerprintData
|
| 66 |
-
//
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
now := time.Now()
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
}
|
| 90 |
|
| 91 |
-
//
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
-
// encryptPayload
|
| 99 |
-
//
|
| 100 |
-
|
| 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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
if s, ok := itemV.(string); ok && strings.EqualFold(strings.TrimSpace(s), "FINISHED") {
|
| 406 |
return nil, true
|
| 407 |
}
|
| 408 |
continue
|
| 409 |
}
|
| 410 |
-
if shouldSkipPath(
|
| 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 != "" {
|
|
@@ -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 |
}
|
|
@@ -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 |
+
}
|