diff --git a/AGENTS.md b/AGENTS.md index 040e190..dda671c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ ## 5. 部署与生产 生产:SSH 别名 `order+douyu_huya`,目录 `/root/live-hub-py`,跑的就是 `main`。 -**完整清单见 `docs/上线清单-S1-S7部署.md`**,核心步骤: +**当前部署步骤以本节为准,文档索引见 `docs/README.md`**: ```bash # ① 部署前(缺一不可):备份 + 采基线 @@ -120,6 +120,6 @@ docker compose up -d --force-recreate douyu-login | 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** | | 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 | | 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) | -| 迁移 head | `main` = `20261016_0059`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) | +| 迁移 head | `main` = `20261021_0064`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) | | 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` | | 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` | diff --git a/MODIFIED_FILE b/MODIFIED_FILE deleted file mode 100644 index 94eb9a5..0000000 --- a/MODIFIED_FILE +++ /dev/null @@ -1,387 +0,0 @@ -"""wupData 信封构造与补丁工具。 - -生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码; -:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。 -""" - -from __future__ import annotations - -import base64 -import json -import struct -from pathlib import Path - -from .taf_protocol import TafOutputStream -from .wup_protocol import WupRequest - -INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03 -STRING1, STRING4 = 0x06, 0x07 -MAP, LIST = 0x08, 0x09 -STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B -ZERO, SIMPLE_LIST = 0x0C, 0x0D - -PROTOCOL_QURL_TEMPLATE_B64 = ( - "AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm" - "ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz" - "ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi" - "ZGMtMjY2OTItODI2MzkzOTQ3ODc2NjM1NTEzNjUiLCJ0eXBlIjoyLCJ1aWQiOjAsInVzZXJDb250ZXh0IjoiIn02BDUw" - "MDhAA1a0UFF3ZW1BTjlOSGtaS29NcVdNUW5WUkl5cHFNVGFRRU9ybVhyMzd4UVZoUVpxclA1aVVLRVExMXh2RTB2cE1n" - "a2xlajIzbEpGYmFHVW5LUEFhYnhWaUF0TnZyTkxBbXhBQzJyRGp6Qy9JSU0vSWFQeTdTcytvQXZqSmltR2pBS2RnVmpr" - "bUhWV2Q2bEw4cUZScU4zWkJMamt4c2xUQjczaXNvallWcjlrSFhWdUh3SkxoM3YzZgB2AIYAlgALGgYgZWQwZGI4MzM0" - "Y2FkZDIzNmMwMGNhZGY3ZTExYWI1YTUWBzEzLjQuMjImCTEuMC44MDEzODYARgkxMjcuMC4wLjFWBnhpYW9taWYACyoA" - "ARYJTTIxMDJKMlNDJigwMmRmMzk4Nzk3NDMyZWFkZWZjYzEyNzY3MTE5YWQ1ZTgwOTk5Mzg5NgdhbmRyb2lkRg9NMjEw" - "MkoyU0MsMzAsMTFmBDEwODB2BDIxMjCGKDdjNTM4N2UwNTM5YzAyM2MzMWM0ZmYwZTgwN2U3MjU2MTE3Mzg1ZWULMwAA" - "ARdRuGVvRwAAAQREQ0JHY0QyN0liSWEvZnZ0UHhNT2xZdGJUY0M3bWRaUlJ3YzIyc2NnQkFyRTJ5eTZwSUdIQjNMK0tr" - "MzVxVS9iaWc1Qk1TVVVSd3gzeS9wWVpWajRjd20rWEg1dnNrakR2SzNhUlhudEJGcURDQUUvaUVIbGs4ZXJ3VUJZdmJM" - "aXlIb0YrSytQam5GTGJRMmlzSHVhcUtqTHAvWmRETDlxSit3VEVSb3h0ZjFuUzlTZ0l2N3lCaVIyMjd4N3F3RjllUTVu" - "ckNaRitnRnczelVZb2N6Uk9jbHE1aXZDclhRZTVSZ3hOYkp5aWQ3ZkZqTVhYYlNQdHBIZ2p0TVJtdWp6RVRvPVYAZgAL" - "BhB3dXB1ZGJyZXF1ZXN0X3YwHQAABQIAWkEFjJgMqAw=" -) - - -def _read_len_int(d: bytes | bytearray, p: int) -> tuple[int, int]: - dt = d[p] & 0x0F - if dt == ZERO: - return 0, p + 1 - if dt == INT8: - return struct.unpack_from(">b", d, p + 1)[0], p + 2 - if dt == INT16: - return struct.unpack_from(">h", d, p + 1)[0], p + 3 - if dt == INT32: - return struct.unpack_from(">i", d, p + 1)[0], p + 5 - raise ValueError(f"长度int类型异常 {dt:#x}@{p}") - - -def _skip_value(d: bytes | bytearray, p: int, dt: int) -> int: - if dt == ZERO: - return p - if dt == INT8: - return p + 1 - if dt == INT16: - return p + 2 - if dt == INT32: - return p + 4 - if dt == INT64: - return p + 8 - if dt == STRING1: - return p + 1 + d[p] - if dt == STRING4: - return p + 4 + struct.unpack_from(">i", d, p)[0] - if dt == SIMPLE_LIST: - p += 1 - n, p = _read_len_int(d, p) - return p + n - if dt == MAP: - n, p = _read_len_int(d, p) - for _ in range(n): - h = d[p] - p += 1 - kd = h & 0x0F - if kd == STRING1: - p += 1 + d[p] - elif kd == STRING4: - p += 4 + struct.unpack_from(">i", d, p)[0] - else: - raise ValueError(f"map key 类型 {kd:#x}") - vh = d[p] - p += 1 - p = _skip_value(d, p, vh & 0x0F) - return p - if dt == LIST: - n, p = _read_len_int(d, p) - eh = d[p] - p += 1 - edt = eh & 0x0F - for _ in range(n): - p = _skip_value(d, p, edt) - return p - if dt == STRUCT_BEGIN: - while True: - h = d[p] - p += 1 - sdt = h & 0x0F - if sdt == STRUCT_END: - break - p = _skip_value(d, p, sdt) - return p - raise ValueError(f"未知类型 {dt:#x}@{p}") - - -class Envelope: - """WUP 请求信封结构解析器与补丁器。""" - - def __init__(self, raw_bytes: bytes): - self.raw = bytearray(raw_bytes) - self.tag4_span: tuple[int, int] | None = None - self.meta_json_span: tuple[int, int] | None = None - self.uid_off: int | None = None - self.cert_off: int | None = None - self.cert_len: int = 260 - self._parse() - - @classmethod - def load(cls, path: str | Path | None = None) -> Envelope: - """加载显式模板,或使用内嵌协议结构模板。 - - 不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。 - """ - if path: - p = Path(path) - if p.exists(): - return cls._load_from_path(p) - return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64)) - - @classmethod - def build_qr( - cls, - *, - uid: int, - cert_b64: str, - safedeviceid: str, - session: int, - trace_id: str, - device_info: dict[str, str], - ) -> Envelope: - """按当前账号状态编码二维码绑定信封。 - - 生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP - 字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。 - """ - if len(cert_b64) != 260: - raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260") - if len(safedeviceid) != 180: - raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180") - dev = {k: str(v) for k, v in (device_info or {}).items()} - meta = json.dumps( - { - "associationId": 184549392, - "funcName": "", - "group": 0, - "id": 184549392, - "session": int(session), - "step": 0, - "stillLogin": False, - "traceId": str(trace_id), - "type": 2, - "uid": 0, - "userContext": "", - }, - ensure_ascii=False, - separators=(",", ":"), - ) - body = TafOutputStream() - body.write_struct_begin(0) - body.write_struct_begin(0) - body.write_int8(0, 0) - body.write_string(1, "1.0") - body.write_string(2, meta) - body.write_string(3, "5008") - body.write_int8(4, 3) - body.write_string(5, safedeviceid) - body.write_string(6, "") - body.write_string(7, "") - body.write_string(8, "") - body.write_string(9, "") - body.write_struct_end() - - body.write_struct_begin(1) - body.write_string(0, dev.get("hdid", "")) - body.write_string(1, dev.get("app_version", "13.4.22")) - body.write_string(2, dev.get("sdk_version", "1.0.80138")) - body.write_string(3, "") - body.write_string(4, dev.get("ip", "127.0.0.1")) - body.write_string(5, dev.get("vendor", "android")) - body.write_string(6, "") - body.write_struct_end() - - body.write_struct_begin(2) - body.write_int8(0, 1) - body.write_string(1, dev.get("model", "")) - body.write_string(2, dev.get("fingerprint", "")) - body.write_string(3, dev.get("os", "android")) - body.write_string(4, dev.get("screen", "")) - body.write_string(6, dev.get("width", "1080")) - body.write_string(7, dev.get("height", "2120")) - body.write_string(8, dev.get("device_id", "")) - body.write_struct_end() - body.write_uint64(3, int(uid)) - body.write_string(4, cert_b64) - body.write_string(5, "") - body.write_string(6, "") - body.write_struct_end() - - req = TafOutputStream() - req.write_int32(0, int(session)) - wup = WupRequest() - wup.iTimeout = 0 - wup.setRequestId(int(session)) - wup.setServant("huyaudbwebui") - wup.setFunc("default") - wup.newdata["_wup_data"] = body.get_bytes() - wup.newdata["wupudbrequest_v0"] = req.get_bytes() - return cls(wup.encode()) - - @classmethod - def _load_from_path(cls, p: Path) -> Envelope: - if p.suffix == ".json": - j = json.loads(p.read_text("utf-8")) - q = next( - e["data"] for e in j if e.get("type") == "qurl_done" and e.get("data") - ) - return cls(base64.b64decode(q)) - return cls(p.read_bytes()) - - def _parse(self) -> None: - d = self.raw - p = 4 - # WUP header - while p < len(d): - h = d[p] - p += 1 - tag, dt = (h >> 4) & 0x0F, h & 0x0F - if tag == 4 and dt == INT32: - self.tag4_span = (p, p + 4) - p += 4 - elif tag == 7 and dt == SIMPLE_LIST: - p += 1 # 元素类型头 - n, p = _read_len_int(d, p) - self._parse_sbuffer(p, n) - break - else: - p = _skip_value(d, p, dt) - - def _parse_sbuffer(self, start: int, ln: int) -> None: - d = self.raw - p = start - h = d[p] - p += 1 - assert (h & 0x0F) == MAP - cnt, p = _read_len_int(d, p) - for _ in range(cnt): - p += 1 - kln = d[p] - p += 1 - k = bytes(d[p : p + kln]) - p += kln - vh = d[p] - p += 1 - if k == b"_wup_data": - assert (vh & 0x0F) == SIMPLE_LIST - p += 1 - wup_data_len, p = _read_len_int(d, p) - self._parse_wup_data_struct(p, wup_data_len) - p += wup_data_len - else: - p = _skip_value(d, p, vh & 0x0F) - - def _parse_wup_data_struct(self, start: int, ln: int) -> None: - d = self.raw - q = start - assert (d[q] & 0x0F) == STRUCT_BEGIN - q += 1 - while q < start + ln: - hh = d[q] - q += 1 - tag, dt = (hh >> 4) & 0x0F, hh & 0x0F - if tag == 15: - tag = d[q] - q += 1 - if dt == STRUCT_END: - break - if tag == 0 and dt == STRUCT_BEGIN: - while True: - h2 = d[q] - q += 1 - tg2, dt2 = (h2 >> 4) & 0x0F, h2 & 0x0F - if tg2 == 15: - tg2 = d[q] - q += 1 - if dt2 == STRUCT_END: - break - vs = q - q = _skip_value(d, q, dt2) - if tg2 == 2 and dt2 in (STRING1, STRING4): - off = vs + (4 if dt2 == STRING4 else 1) - self.meta_json_span = (off, q) - elif tag == 3 and dt == INT64: - self.uid_off = q - q += 8 - elif tag == 4 and dt == STRING4: - ln_c = struct.unpack_from(">i", d, q)[0] - self.cert_off = q + 4 - self.cert_len = ln_c - q = self.cert_off + ln_c - else: - q = _skip_value(d, q, dt) - if self.uid_off is None or self.cert_off is None: - raise ValueError("未在信封中定位到 t3(uid) 或 t4(cert)") - - @property - def uid(self) -> int: - assert self.uid_off is not None - return struct.unpack_from(">Q", self.raw, self.uid_off)[0] - - def patch_uid(self, uid: int) -> Envelope: - assert self.uid_off is not None - struct.pack_into(">Q", self.raw, self.uid_off, uid) - return self - - @property - def cert_b64(self) -> bytes: - assert self.cert_off is not None - return bytes(self.raw[self.cert_off : self.cert_off + self.cert_len]) - - def patch_cert(self, cert: bytes) -> Envelope: - assert self.cert_off is not None - b64 = base64.b64encode(cert) - if len(b64) != self.cert_len: - raise ValueError( - f"证书b64长度不符: {len(b64)} != 模板 {self.cert_len} (cert {len(cert)}B)" - ) - self.raw[self.cert_off : self.cert_off + self.cert_len] = b64 - return self - - def patch_session(self, session: int) -> Envelope: - """Patch the outer WUP session and its request copy.""" - if self.tag4_span: - struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF) - d = self.raw - i = d.find(b"wupudbrequest_v0") - if i >= 0: - j = i + len(b"wupudbrequest_v0") + 4 - if j + 4 <= len(d): - struct.pack_into(">I", d, j, session & 0xFFFFFFFF) - return self - - def patch_meta(self, session: int, trace_id: str) -> Envelope: - """Replace QR metadata values without carrying the capture's old state. - - The captured envelope keeps a fixed-size JSON string. Keeping the - replacement the same size lets us update only the value bytes and - preserve all TAF length prefixes and offsets. - """ - if self.meta_json_span is None: - raise ValueError("信封缺少元数据 JSON") - start, end = self.meta_json_span - current = bytes(self.raw[start:end]) - try: - meta = json.loads(current.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError("信封元数据 JSON 无法解析") from exc - meta["session"] = int(session) - meta["traceId"] = str(trace_id) - updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode( - "utf-8" - ) - if len(updated) != len(current): - raise ValueError( - f"信封元数据长度变化: {len(updated)} != {len(current)}; " - "请使用固定长度 session/traceId" - ) - self.raw[start:end] = updated - return self - - def wup_b64(self) -> str: - return base64.b64encode(bytes(self.raw)).decode() - -# Integration fixture remains modified for independent rollback verification. diff --git a/README.md b/README.md index 09bebb3..eb6ca9f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ live-hub-py/ ./deploy.sh ``` -访问 `http://localhost:8800`(compose 默认把容器 8800 映到宿主机 `APP_PORT`,缺省 8000)。默认管理员 `admin / admin123`,生产环境务必改掉。 +访问 `http://localhost:8000`(可用 `APP_PORT` 覆盖宿主机端口)。默认管理员 `admin / admin123`,生产环境务必改掉。 本机热更新用 `./dev.sh`(或 `./deploy.sh dev`)。它会拉起绑定 `127.0.0.1:8810` 的开发 Worker,后端自动连该地址;Worker 数据在 `data/yyb-worker-dev/`。`Ctrl+C` 会一并停掉。应用日志按日期写到 `logs/app-YYYY-MM-DD.log`,可用 `./dev.sh logs` 跟踪当天文件。 @@ -194,4 +194,5 @@ docker compose --profile test run --rm test ## 文档 -协议与联调笔记在 `docs/`。当前虎牙 Cookie 行为以 [虎牙 Cookie 路线状态](docs/HUYA_COOKIE路线状态.md) 为准。 +文档导航见 [docs/README.md](docs/README.md)。当前虎牙 Cookie 行为以 +[虎牙 Cookie 路线状态](docs/虎牙/HUYA_COOKIE路线状态.md) 为准。 diff --git a/core/huya/verification/solver.py b/core/huya/verification/solver.py index f10ceeb..f3c53c7 100644 --- a/core/huya/verification/solver.py +++ b/core/huya/verification/solver.py @@ -331,7 +331,8 @@ class HuyaVerificationSolver: if Path(font_path).is_file(): try: return ImageFont.truetype(font_path, size) - except Exception: + except (OSError, ValueError) as exc: + logger.debug(f"[solver] 字体加载失败 {font_path}: {exc}") continue return ImageFont.load_default() @@ -699,7 +700,7 @@ class HuyaVerificationSolver: drag_distance = max(5, int((angle / 360.0) * 260)) track_points = generate_slide_track(drag_distance) act_data = { - "point": int(round(angle)), + "point": round(angle), "travel": format_track(track_points), "code": self.code, "sceneType": self.scene_type, diff --git a/dev.sh b/dev.sh index 4ad60e4..5cd8e03 100755 --- a/dev.sh +++ b/dev.sh @@ -76,7 +76,7 @@ fi # 本地回归测试:现在跑在**真实 MySQL**上(不再用 SQLite)。 # 原因:生产是 MySQL,SQLite 与它有方言差异 —— 曾经出现「ORDER BY ... NULLS FIRST」 -# 本地全绿、生产直接 1064 的事故(见 docs/自动化与资料收集规划.md 附录 C.2)。 +# 本地全绿、生产直接 1064 的事故(见 docs/自动化与资料收集.md)。 # 因此需要:① 先 ./dev.sh 起 docker compose(MySQL 映射到 127.0.0.1:3307); # ② 测试库默认 douyu_login_test,可用 TEST_MYSQL_DATABASE 覆盖。 # 测试库与开发库同名时会被 tests/support/db.py 拒绝运行(防误清)。 diff --git a/docker-compose.yml b/docker-compose.yml index 1c951ee..e5772aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,7 +75,7 @@ services: - MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-} # 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。 - UVICORN_RELOAD=false - # 自动化调度(见 docs/自动化与资料收集规划.md §7)。 + # 自动化调度(见 docs/自动化与资料收集.md)。 # 这个开关只决定「是否起调度线程」;各作业还有自己的开关,默认关闭, # 在 /automation 页面打开。 - AUTOMATION_ENABLED=${AUTOMATION_ENABLED:-false} diff --git a/docs/README.md b/docs/README.md index e6ea6c0..1ba13ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,52 +1,40 @@ # 项目文档索引 -更新时间:2026-10-02 +更新时间:2026-11-06 -## 当前入口 +本文只列当前仍有维护价值、能直接用于开发或排查的文档。已被代码实现取代的 +一次性计划、事故经过和中间抓包记录已删除;需要追查历史时使用 Git 历史。 -- [自动化与资料收集规划](自动化与资料收集规划.md):CK 自动管理、资料收集链接、皮肤台账与半自动工单的分期计划(规划中,未开工)。 -- [皮肤准入映射分析](皮肤准入映射分析.md):C 桶皮肤类到斗鱼商品目录的归一与覆盖分析(923 个快手名 → 78 个斗鱼商品,覆盖率 40.1%)。 -- [虎牙 Cookie 路线状态](HUYA_COOKIE路线状态.md):当前实现、字段状态、验证边界和后续工作。 -- [虎牙网页设备字段溯源](HUYA_WEB_FIELD_TRACE.md):浏览器证据、字段来源和生成算法。 -- [虎牙 Cookie 合并审计与来源矩阵](HUYA_COOKIE合并审计与来源矩阵.md):字段来源约束和合并策略。 -- [虎牙精英宝典联调指南](HUYA精英宝典联调指南.md):工作台联调顺序和只读验证方法。 +## 当前使用 -## 虎牙业务与协议 +- [自动化与资料收集](自动化与资料收集.md):账号巡检、CK 自动重登、资料刷新、订单读取、文件存储的运行规则。 +- [账号全生命周期](设计-账号全生命周期.md):斗鱼账号状态、CK、事件流和自动化边界。 +- [虎牙外部 CK 协作台账](设计-虎牙外部CK协作台账.md):外部订单、CK、业务状态、核账、归档和权限规则。 +- [虎牙 Cookie 路线状态](虎牙/HUYA_COOKIE路线状态.md):当前 Cookie 字段来源、已完成链路和已知边界。 +- [虎牙浏览器兑换](虎牙/HUYA_BROWSER_EXCHANGE.md):人工兑换页面、扩展注入和目标页说明。 +- [虎牙精英宝典联调指南](虎牙/HUYA精英宝典联调指南.md):WSS/HTTP 选路和联调验收流程。 +- [小X分身虎牙 CK 提取](虎牙/分身CK提取.md):ADB、WebView Cookie 与 cred0 解密提取流程。 +- [精英宝典 HAR 提取](虎牙/HAR提取CK与积分中心.md):HAR 中的完整 CK、积分中心和商品结构。 +- [皮肤准入映射分析](皮肤准入映射分析.md):快手皮肤订单到斗鱼商品的覆盖与映射边界。 -- [虎牙精英宝典 9.1 全流程分析](HUYA_精英宝典-9.1-全流程分析.md) -- [虎牙精英宝典后端与抓包对照](HUYA_精英宝典-后端与9.1抓包对照.md) -- [虎牙精英宝典 8.31 Python 重分析](HUYA_精英宝典-8.31-PY重新分析.md) -- [虎牙精英宝典联调指南](HUYA精英宝典联调指南.md) -- [网页 Cookie 缺失字段历史方案](HUYA_CHAT_缺失字段补齐方案.md) -- [虎牙 App 登录总览](HUYA_APP_OVERVIEW.md) -- [虎牙纯 Python 自动登录总结](虎牙纯Python全自动登录-总结与交接.md) +## 协议与逆向证据 -## 虎牙设备与加密逆向 - -- [HDID 算法生成](HUYA_HDID_ALGORITHM_GEN.md) -- [HDID 研究记录](HUYA_HDID_RESEARCH.md) -- [标识符命名规范](HUYA_ID_NOMENCLATURE.md) -- [铸币机终局总结](HUYA_铸币机-终局总结.md) -- [App 登录协议抓包实证](虎牙App密码登录协议-抓包实证.md) -- [App 登录 WUP 明文抓包](虎牙App密码登录-WUP明文抓包.md) -- [App 登录加密算法动态验证](虎牙App登录-加密算法动态验证.md) -- [App 登录 APK 静态分析](虎牙App登录-APK静态分析.md) -- [App 登录逆向笔记](虎牙App登录逆向笔记.md) -- [扫码登录闭环逆向](虎牙扫码登录闭环逆向.md) -- [hyCred 签发侦察笔记](虎牙hyCred签发-侦察笔记.md) - -## 设备注册与运行工具 - -- [dfpReport 生成流程](dfpReport生成流程.md) -- [dfpReport 破解进度](dfpReport破解进度.md) -- [Frida 绕过配方](Frida绕过配方.md) -- [Hook 脚本运行与捕获约定](Hook脚本运行与捕获约定.md) -- [模拟器存活闪退诊断](模拟器存活闪退诊断报告.md) -- [登录系统全流程定案](登录系统全流程定案.md) +- [虎牙 App 协议总览](虎牙/HUYA_APP_OVERVIEW.md):先看这一篇,再按需进入算法或登录细节。 +- [设备标识命名规范](虎牙/HUYA_ID_NOMENCLATURE.md):GUID32、HDID32、DEVID40、MID16、ACTION 的统一口径。 +- [网页设备字段溯源](虎牙/HUYA_WEB_FIELD_TRACE.md):浏览器实测字段来源和生成规则。 +- [Cookie 合并审计与来源矩阵](虎牙/HUYA_COOKIE合并审计与来源矩阵.md):每个字段允许从哪里来。 +- [HDID 研究记录](虎牙/HUYA_HDID_RESEARCH.md):32hex 登录 hdid 的证据边界。 +- [HDID 算法生成路径](虎牙/HUYA_HDID_ALGORITHM_GEN.md):doLaunch、服务端签发和相关实验。 +- [dfpReport 生成流程](虎牙/dfpReport生成流程.md):设备注册链已破解与未破解部分。 +- [App 登录加密算法动态验证](虎牙/虎牙App登录-加密算法动态验证.md):XXTEA/AES/arg7/key 来源。 +- [虎牙证书算法闭环](虎牙/虎牙证书算法闭环-差分实证.md):biz_token 结构、AES 与重铸验证。 +- [扫码登录闭环逆向](虎牙/虎牙扫码登录闭环逆向.md):qrLgn 协议、wupData 和证书边界。 +- [Frida 绕过配方](虎牙/Frida绕过配方.md):当前稳定注入流程与失败方案。 +- [Hook 脚本运行与捕获约定](虎牙/Hook脚本运行与捕获约定.md):脱敏、落盘和复现约定。 ## 鱼翅充值接口 -- [对接信息](鱼翅充值api/对接信息.md) +- [对接说明](鱼翅充值api/对接信息.md):环境变量和接口入口;供应商凭据不入库、不写文档。 - [账户信息查询](鱼翅充值api/账户接口/账户信息查询.md) - [创建订单](鱼翅充值api/订单接口/1.创建订单_必要.md) - [查询订单](鱼翅充值api/订单接口/2.查询订单_必要.md) @@ -54,7 +42,7 @@ ## 文档约定 -1. 带日期的抓包和逆向笔记属于历史证据,结论以对应日期为准。 -2. 当前代码行为和未完成项以“虎牙 Cookie 路线状态”为准。 -3. Cookie、UID、订单号和设备标识进入文档时使用脱敏值;真实数据只保存在本地 evidence 目录。 +1. 代码是当前行为的最终依据;文档只记录代码难以直接表达的协议、边界和排查路径。 +2. 带日期的逆向记录是当时证据,不能覆盖后来代码中的已验证结论。 +3. Cookie、UID、订单号、手机号和设备标识必须脱敏;凭据只放环境变量或权限为 `0600` 的本地文件。 4. 兑换、下单和支付属于写操作,除非明确确认,不纳入自动回归测试。 diff --git a/docs/上线清单-S1-S7部署.md b/docs/上线清单-S1-S7部署.md deleted file mode 100644 index 30f2a70..0000000 --- a/docs/上线清单-S1-S7部署.md +++ /dev/null @@ -1,254 +0,0 @@ -# 上线清单:S1–S7 账号全生命周期(斗鱼) - -> 已在本地的**生产克隆库**上完整演练(2026-10-04)。演练证据见文末。 -> 演练环境:生产库 15,182 账号 / 14,324 持有 CK / 当前版本 `20261003_0040`。 - ---- - -## 0. 先明确现状(部署前必读) - -| | | -|---|---| -| 生产迁移版本 | **`20261003_0040`** —— S1–S7(含 6 个迁移)**一个都没上** | -| `account_events` 表 | **不存在** | -| CK 在哪 | **`login_tasks.cookie`**(旧代码在跑,这是当前的唯一数据源) | -| 账号数 / 持有 CK | 15,182 / 14,324 | -| 整库 | 972 MB(其中 `douyu_tasks` 837 MB) | - -本次上线内容:迁移 `0041 → 0046` + 对应代码。 -**不含 S8(删旧列)** —— 那要等本次稳定运行之后再说。 - ---- - -## 1. 部署前检查(缺一不可) - -```bash -# 1) 确认生产当前版本(应输出 20261003_0040) -docker exec live-hub-py-mysql-1 sh -c \ - 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B douyu_login -e "SELECT version_num FROM alembic_version;"' - -# 2) 备份(务必确认文件非空、且能列出来) -docker exec live-hub-py-mysql-1 sh -c \ - 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick \ - --default-character-set=utf8mb4 --routines --triggers douyu_login' \ - | gzip > /root/backup_$(date +%Y%m%d_%H%M).sql.gz -ls -lh /root/backup_*.sql.gz # 体积应接近 100MB 量级 -gzip -t /root/backup_*.sql.gz && echo "备份完整性 OK" - -# 3) 磁盘(迁移会给 accounts 加列、重建索引) -df -h /var/lib/docker # 至少留 5GB - -# 4) CK 可解密(部署前基线,部署后要对比) -docker exec -i live-hub-py-douyu-login-1 python - <<'PY' -from web.backend.database import SessionLocal -from web.backend.models import LoginTask -db = SessionLocal() -rows = (db.query(LoginTask).filter(LoginTask.cookie.isnot(None), LoginTask.cookie != "") - .order_by(LoginTask.id.desc()).limit(5).all()) -print([len(t.cookie or "") for t in rows]) # 应都是 ~1600,不是 0 -db.close() -PY -``` - -> ⚠️ **第 4 步是本次演练唯一无法在本地做的检查**(本地没有生产的加密密钥)。 -> 已在生产上实测:抽样 5 条 **5/5 解密正常**,长度 1613–1660。 - ---- - -## 2. 部署执行 - -**顺序:先停应用 → 再跑迁移 → 再起新代码。** 不要反过来。 - -```bash -# 1) 停应用(只停 app,数据库别停) -docker compose stop douyu-login - -# 2) 跑迁移(实测 9 秒) -docker compose run --rm -T douyu-login alembic upgrade head **`0044` 是唯一需要留意的**:它会覆盖 `esports_bind_status` / `xpd_bind_status` -> 的旧自由文本取值。`accounts.bind_status` **原值未动**(保留到 S8)。 -> 回滚要回到旧代码,必须先跑第 3 节的反向回填。 - ---- - -## 3. 部署后校验(逐条核对,任一条不过就按第 4 节回滚) - -```bash -docker exec live-hub-py-mysql-1 sh -c \ - 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -t douyu_login' <<'SQL' --- ① 账号数不变(应为 15182) -SELECT COUNT(*) AS accounts FROM accounts; - --- ② CK 已搬到账号侧(应为 14323,等于旧侧去重账号数) -SELECT COUNT(*) AS account_side FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''; -SELECT COUNT(DISTINCT account_id) AS login_task_side FROM login_tasks - WHERE cookie IS NOT NULL AND cookie <> ''; - --- ③ 精英绑定(预期 bound≈410 / pending≈75 / unbound 14 / 其余 unknown) -SELECT role_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC; - --- ④ 电竞(预期 bound 360 / pending 66 / unbound 26;unbound 就是本次修的映射) -SELECT esports_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC; - --- ⑤ esports_opened 救回(应为 4) -SELECT COUNT(*) FROM accounts WHERE esports_opened = 1; - --- ⑥ source_ref 可空(应为 YES) -SELECT is_nullable FROM information_schema.columns - WHERE table_schema='douyu_login' AND table_name='account_events' AND column_name='source_ref'; - --- ⑦ 版本(应为 20261004_0046) -SELECT version_num FROM alembic_version; -SQL -``` - -**功能性验证(新代码起来后)**: - -1. 打开账号列表 —— 状态卡有分布数字,账号行有 CK / 绑定 / 积分鱼翅 / 标签列 -2. 打开任一账号详情 —— 生命周期状态卡正常、时间线能加载 -3. **CK 仍可解密**(与部署前基线对比,长度量级一致) -4. 手动跑一次「账号检测」(选一个账号)—— 应能完成,且该账号详情页时间线出现一条检测事件 - ---- - -## 4. 回滚预案(**顺序不能错**) - -> 🔴 **关键:`accounts.cookie` 在降级时会被 drop。S2 之后 CK 只写账号、不双写 -> `login_tasks`。所以「部署期间新拿到的 CK」如果不先搬回去,会永久丢失。** -> -> CK 丢失是本项目唯一不能承受的损失(重登成功率接近 0、全量重登 ≈ 2.7 天)。 -> **演练验证过:按下面的顺序,部署期间新拿到的 CK 能完整幸存。** - -```bash -# 1) 先反向回填(把账号侧 CK/健康/重登进度搬回 login_tasks) -docker compose run --rm douyu-login \ - python scripts/backfill_login_tasks_from_accounts.py --dry-run # 先看会改什么 - -docker compose run --rm douyu-login \ - python scripts/backfill_login_tasks_from_accounts.py --apply - -# 脚本自带自检:「账号侧 CK 数」必须 ≤「登录记录侧去重账号数」。 -# 输出 «不通过 ❌» 就**不要**继续降级。 - -# 2) 再降级迁移 -docker compose run --rm douyu-login alembic downgrade 20261003_0040 - -# 3) 最后切回旧代码镜像 -docker compose up -d douyu-login # 用旧 tag -``` - -**回滚后应确认**: - -| 检查 | 期望 | -|---|---| -| `alembic_version` | `20261003_0040` | -| `accounts` 行数 | 不变(15182) | -| `login_tasks` 里持有 CK 的去重账号数 | ≥ 14323,且**包含部署期间新增的** | -| `accounts.esports_bind_status` 类型 | 回到 `varchar(32)`(旧代码要写自由文本) | -| `accounts.bind_status` | 原值完好(S1–S7 从未改它) | -| `role_bind_status` / `esports_opened` / `points_expire_at` / `account_events` | 已 drop(预期) | - -> 降级实测 **2 秒**。 - ---- - -## 5. 已知限制与后续 - -| 项 | 说明 | -|---|---| -| **`0044` 的旧值不可恢复** | 归一化覆盖了 `esports_bind_status`/`xpd_bind_status` 的旧自由文本取值。`bind_status` 原值保留、可人工比对。回退旧代码不影响(旧代码读 `bind_status`)。 | -| **积分到期金额是「预计」** | 到期时刻由作业补记,但**实际扣了多少没亲眼看到**,事件标 `confirmed=false`。 | -| **任务级失败不进时间线** | 设计上只记成功的事实;失败只在 `douyu_tasks.message`(保留 90 天)。 | -| **应用宝充值未并入** | 它按游戏角色记账、当前未启用(按决定先放着)。 | -| **S8(删旧列)** | 本次**不做**。要等稳定运行、且做了逻辑备份 + 反向回填演练之后。 | -| **`douyu_tasks` 837MB** | 保留策略作业(`douyu_tasks_retention`)随本次一起上线,但**默认关闭**,需要时手动打开。首次开启预计清掉 82,185 行(噪声档超 7 天的)。 | - ---- - -## 6. 演练证据(本地生产克隆库) - -| 步骤 | 结果 | -|---|---| -| 克隆生产库 | 15,182 账号 / 14,324 CK / 版本 `0040` / 27 张表 —— 与生产逐项一致 | -| **迁移全链** | 6 个迁移,**9 秒**,无错误(含 S1 的 fail-closed 自校验) | -| ① 账号数 | 15,182 ✓ 不变 | -| ② CK 搬运 | 账号侧 14,323 = 登录记录侧**去重账号数 14,323**(精确相等)✓ | -| ③ 精英映射 | bound 410 / pending 75 / unbound 14 ✓(与迁移前基线一致) | -| ④ 电竞映射 | bound 360 / pending 66 / **unbound 26** ✓ ← 修复生效 | -| ⑤ esports_opened | **4** ✓ ← 抢救生效 | -| ⑥ source_ref | `YES` ✓ | -| **回滚** | 6 个迁移降级,**2 秒**,`bind_status` 原值完好、两列回到 `varchar(32)` | -| **回滚前置脚本** | 模拟「部署期间新拿到的 CK」→ 回填 → 降级 → **该 CK 完整幸存** ✓ | -| **生产 CK 解密** | 抽样 5 条,**5/5 正常**(长度 1613–1660)✓ | - ---- - -## 附:把生产库同步到本地(测试用) - -生产已于 2026-10-04 部署完成(`main@2f610a5`,迁移 `20261004_0046`)。 -本地已建立一份**与生产逐项一致**的镜像库,方便后续测试在本地跑。 - -```bash -# 重新同步(默认全量,含 douyu_tasks 的 837MB) -scripts/refresh_local_prod_mirror.sh -# 精简版(跳过 douyu_tasks 行,43MB,够绝大多数测试) -scripts/refresh_local_prod_mirror.sh --no-tasks -``` - -然后用镜像库跑应用 / 脚本: - -```bash -DB_NAME=douyu_login_prod ./dev.sh -# 或 -DATABASE_URL="mysql+pymysql://douyu_login:@127.0.0.1:3307/douyu_login_prod?charset=utf8mb4" ... -``` - -### 当前本地镜像的内容(2026-10-04 建立) - -| | 本地 `douyu_login_prod` | -|---|---| -| 迁移版本 | `20261004_0046`(与生产一致) | -| 账号 / 持有 CK | 15,182 / 14,323 | -| 登录记录 | 15,169 | -| `douyu_tasks` | **253,424**(含 837MB JSON —— 可用来实测保留策略) | -| `account_events` | 34(部署后产生的真实事件) | -| 库大小 | 907 MB | - -### 两个关键前提 - -1. **`APP_ENCRYPTION_KEY` 必须与生产一致**,否则镜像里的 CK 解不开、CK 相关测试全废。 - 已验证:当前本地 `.env` 的密钥**与生产一致**(抽样解密正常,CK 前缀 `dy_accounts_main`,长度 1659–1702)。 -2. **不要在镜像库上跑测试套件**。测试用的是独立库 `douyu_login_test` - (由 `TEST_MYSQL_DATABASE` 指定,`init_test_schema` 会 `create_all`)。 - 镜像库只用于手工/联调。 - -### 踩过的坑(脚本里已规避) - -- **不要把 `mysqldump` 串进 SSH 管道**:实测只有 ~49KB/s(MySQL 读 + gzip + 网络互相拖); - 改成"服务器本地 dump 成文件 + rsync"后 ~8MB/s,快两个数量级。 -- 用 **rsync 而非 scp**:可断点续传。 -- **`docker compose run` 默认读 stdin** —— 脚本如果通过 `ssh 'bash -s' < 脚本` 喂进去, - 它会把**脚本剩下的行**当 stdin 吃掉(这就是本次部署 90 秒停机的根因)。 - 正确写法:`docker compose run -T --rm ... ⚠️ **最危险的是第二条**:假阴性会让人得出「没事」的结论。凡是靠脚本判定「有没有丢数据」, -> 都必须先验证解出来的值**非空且正确**(本例中先 dump 一行原始结构就发现了)。 - -## 沉淀下来的规则 - -1. **删除登录任务 = 删除该账号的 CK**。任何按条件批量删除的入口,判定必须基于 `cookie` 字段; -2. **显示状态 ≠ 数据状态**:`attempt_status` 是「最近一次尝试」,`status`/`cookie` 才是 - 「当前是否持有 CK」。写筛选、统计、删除时先想清楚要哪一个; -3. **binlog 是这类事故的唯一退路**:不要关 `log_bin`、不要缩短保留期;做批量删除类功能前 - 先确认恢复路径可用; -4. **生产镜像缺 `mysqlbinlog`**:需要解 binlog 时直接用 `pymysqlreplication`,别在容器里找工具。 diff --git a/docs/生产服务器高负载排查与优化报告.md b/docs/生产服务器高负载排查与优化报告.md deleted file mode 100644 index ea7b406..0000000 --- a/docs/生产服务器高负载排查与优化报告.md +++ /dev/null @@ -1,201 +0,0 @@ -# 生产服务器高负载排查与优化报告 - -- **事件时间**:2026-10-03(排查 12:33 ~ 12:54,索引优化当日落地) -- **服务器**:`47.114.114.225`(主机别名 `order+douyu_huya`) -- **规格**:**4 vCPU / 7.1 GiB RAM / Ubuntu 24.04**(`nproc` + `free -h` 实测,见 §1) -- **涉及容器**: - - 本项目:`live-hub-py-douyu-login-1`、`live-hub-py-mysql-1` - - 工单平台:`order_site-postgres-1`、`order_site-backend-1` - -> 本文合并了并行记录的两份原始文档(《排查分析报告》与《优化落地实证报告》), -> 去掉了重复段落,并修正了互相矛盾的两处事实(服务器规格、负载基线)。 -> 两份原始文档对同一台机器的规格记录不一致(一处写 8 核/16GB), -> 本文以带原始命令输出的 **4 vCPU / 7.1 GiB** 为准。 - ---- - -## 0. 结论速览 - -两个**互相独立**的根因同时压在一台 4 核机器上,任何一个单独出现都不会打穿负载: - -| # | 归属 | 根因 | 处理 | 状态 | -| :--- | :--- | :--- | :--- | :--- | -| ① | `order_site` | PostgreSQL LLVM **JIT** 对复杂聚合反复编译,单次 7.2s 全花在代码生成 | `ALTER SYSTEM SET jit = off;` 运行时止血 | **已止血,未根治**(§5) | -| ② | `live-hub-py` | `douyu_tasks` 按归属客服筛选时跨表 JOIN + `Using temporary; Using filesort` | 迁移 `20261003_0040` 冗余 `account_assigned_to` + 复合索引 | **已根治**(单次 400~500ms → 5.7ms,**87 倍**) | - -**核心数字**:Postgres CPU 197.75% → 1.58%;MySQL CPU 111.45%(尖刺)→ 1.86%~11%; -宿主机 load 17.09 → 9.52(JIT 止血后) → 7.6~8.8(索引优化后)。 - ---- - -## 1. 现场取证:宿主机与容器负载 - -```bash -# uptime -12:33:44 up 43 days, 14:12, 3 users, load average: 17.09, 13.75, 11.38 - -# nproc & free -h -4 vCPU -Mem: total 7.1Gi, used 4.1Gi, free 351Mi, buff/cache 4.1Gi, available 3.0Gi -Swap: total 4.0Gi, used 577Mi, free 3.4Gi - -# docker stats --no-stream -CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O -301d6a15a032 live-hub-py-douyu-login-1 90.53% 593.4MiB / 2GiB 28.97% 2.95GB / 234MB 116MB / 22.2MB -04111b7b40ae live-hub-py-mysql-1 49.07% 895.6MiB / 2GiB 43.73% 532MB / 11.8GB 384GB / 21.8GB -d1831428e110 order_site-postgres-1 197.75% 1.708GiB / 2.5GiB 68.34% 2.88GB / 24.9GB 6.79GB / 9.84GB -0fc436d47135 order_site-backend-1 39.75% 289.2MiB / 1GiB 28.24% 28GB / 14.8GB 278MB / 130MB -``` - -**结论**:CPU 合计超过 **376%**,4 核已饱和。`order_site-postgres-1` 占大头(~200%), -本项目 `douyu-login` 第二(~90%),`mysql` 第三(~50%)。 - ---- - -## 2. 根因一:PostgreSQL JIT 编译放大(`order_site` 侧) - -**慢查询位置**:`/home/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts:463` -函数 `getWorkerOrderOverview(workerId)`,结构为多段 `UNION` 的 CTE 后按状态 `GROUP BY` 聚合。 - -**EXPLAIN ANALYZE 关键指标(Worker 7,名下 6895 单)**: - -```text -JIT: - Functions: 63 - Options: Inlining true, Optimization true, Expressions true, Deforming true - Timing: Generation 82.769 ms, Inlining 2197.205 ms, Optimization 2832.841 ms, Emission 2098.693 ms, Total 7211.508 ms -Execution Time: 8803.367 ms -``` - -- **默认(JIT 开)**:8803 ms,其中 **7211 ms(82%)全耗在 LLVM 代码生成与优化**上; -- **`SET jit = off`**:8803 ms → **1174 ms**(该接口实测 369 ms),省掉 7.2s CPU 运算。 - -次要因素:`worker_base` 把 `visible_orders`(几千条)与 `work_orders`(10.2 万条)做 Hash Join 时 -引发 `work_orders` 的大范围 Index Scan。 - ---- - -## 3. 根因二:`douyu_tasks` 跨表文件排序(`live-hub-py` 侧) - -**慢查询**:客服列表轮询 `GET /api/douyu/tasks?handbook_scope=elite` - -```sql -SELECT douyu_tasks.id AS douyu_tasks_id, ... -FROM douyu_tasks -INNER JOIN accounts ON accounts.id = douyu_tasks.account_id -LEFT OUTER JOIN accounts AS accounts_1 ON accounts_1.id = douyu_tasks.account_id -WHERE accounts.assigned_to = 13 - AND (douyu_tasks.handbook_scope = 'elite' - OR douyu_tasks.handbook_scope = 'legacy' AND douyu_tasks.task_type IN (...)) -ORDER BY douyu_tasks.id DESC -LIMIT 100; -``` - -**EXPLAIN 诊断**: - -```text -id: 1, table: accounts, type: ref, key: ix_accounts_assigned_to, rows: 734, - Extra: Using index; Using temporary; Using filesort -id: 1, table: douyu_tasks, type: ref, key: ix_douyu_tasks_account_id, rows: 19, - Extra: Using where -``` - -`accounts` 作驱动表筛出 734 个账号、关联出几万条任务,而最终要按 `douyu_tasks.id DESC` 排序 —— -MySQL 只能把全部关联结果写进临时表做 **filesort**。24 万条任务中符合条件的 1.1 万条全量排序, -导致 MySQL 周期性冲到 **111.45%** CPU、累计 **384GB** 磁盘读,日志频繁报 `Out of sort memory`。 - -### 3.1 优化方案(迁移 `20261003_0040`) - -`web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py` - -1. `douyu_tasks` 新增 `account_assigned_to`(外键关联 `users.id`); -2. 历史数据回填: - - ```sql - UPDATE douyu_tasks dt - JOIN accounts a ON a.id = dt.account_id - SET dt.account_assigned_to = a.assigned_to; - ``` - -3. 建立覆盖排序的复合索引: - - `ix_douyu_tasks_assigned_scope_id (account_assigned_to, handbook_scope, id)` - - `ix_douyu_tasks_account_assigned_to_id (account_assigned_to, id)` - -### 3.2 配套代码改造 - -1. **创建任务时自动注入**:`douyu_service.py` 的 `create_douyu_tasks()` 带入账号当前的 `assigned_to`; -2. **账号改分配时级联更新**:`accounts.py` 的 `assign_account()` 同步更新该账号下任务的 `account_assigned_to`; -3. **彻底消除跨表排序**:`douyu.py` 的 `_visible_tasks_query()` 改为纯单表条件: - - ```python - # 优化前:需要 JOIN Account 表 - # query = query.join(Account).filter(Account.assigned_to == current.id) - # 优化后:纯单表条件,走复合索引 - query = query.filter(DouyuTask.account_assigned_to == current.id) - ``` - -### 3.3 上线与实测 - -- 生产环境共回填 **239,118** 条历史任务;`alembic_version` 推进到 `20261003_0040`; -- `/api/health` 返回 `{"status":"ok"}`;WebSocket 正常,斗鱼批量赠送精英令 / 改名 / 改密任务并发执行无异常。 - -| 指标 | 优化前 | 优化后(生产实测) | 改善 | -| :--- | :--- | :--- | :--- | -| 执行计划 | `Using temporary; Using filesort` | `Backward index scan` | **filesort 彻底消除** | -| 索引命中 | 仅 `account_id`,跨表无法索引排序 | `ix_douyu_tasks_assigned_scope_id` (len 71) | 直接命中最优复合索引 | -| 单次查询耗时 | 400 ~ 500 ms | **5.7 ms** | **~87 倍** | -| `Out of sort memory` | 频繁 | **0 次** | 根治 | -| MySQL 容器 CPU | 尖刺 **111.45%** | 平稳 **1.86% ~ 11%** | 降低 90%+ | -| 系统平均负载 | 峰值 12.8 ~ 14.5 | 回落至 7.6 ~ 8.8 | 显著稳定 | - ---- - -## 4. 为什么负载「忽高忽低」:四个周期脉冲重叠 - -对宿主机连续采样(12:50 ~ 12:54,2 秒间隔): - -```text -12:51:51 Postgres 158% MySQL 49% Node 35% Python 11% -12:51:55 Postgres 171% MySQL 65% Node 22% Python 72% -12:51:59 Postgres 138% MySQL 111% Node 102% Python 36% -12:52:04 Postgres 155% MySQL 104% Node 104% Python 56% -12:52:09 Postgres 211% MySQL 66% Node 76% Python 4% -12:52:33 Postgres 活跃查询归零,CPU 骤降 -``` - -| 脉冲源 | 周期 | 触发动作 | 瞬间峰值 | -| :--- | :--- | :--- | :--- | -| ① MySQL 任务全表排序 | ~3~5 s | 客服工作台轮询 `/api/douyu/tasks`,24 万条里做 filesort | 4% → **111%** | -| ② PostgreSQL 聚合风暴 | ~5~15 s | `getWorkOrderStatistics`(10 万工单 × 28 万订单 LEFT JOIN 全量 SUM)、`countWorkerAcceptedOrders`、`getWorkerOrderOverview` | 1.5% → **211%** | -| ③ Node 定时同步与派单 | 20 s | `pdd_order_sync` 拉单派发;每 60 s 超时检测与押金解冻 | 22% → **104%** | -| ④ Python 充值/绑定波峰 | 任务驱动 | 供应商直充下单、5 秒一轮的绑定状态轮询、代理池并发探测 | 4% → **73%** | - -**结论**:关掉 JIT 后 Postgres 不再陷入 10~20 秒的持续假死(这是 load 从 17 降下来的原因); -但四个周期任务缺少防抖 / 短缓存 / 关键索引,一旦某一秒同时触发,4 核仍会出现 1~2 秒的 100% 尖刺 —— -这就是「忽高忽低」的机理。① 已在 §3 根治,②③ 见 §5。 - ---- - -## 5. 遗留与后续 - -| # | 事项 | 归属 | 说明 | -| :--- | :--- | :--- | :--- | -| 1 | `getWorkerOrderOverview` 的 CTE 重构 | `order_site` | `UNION` → `UNION ALL`,减少大集合 Hash 去重;合并 `worker_order_cancel_requests` / `worker_order_feedbacks` 的 EXISTS 检查 | -| 2 | 打手端概览加短缓存 | `order_site` | 5~10 秒的内存缓存(LRU / Redis / Node Map),避免切页与轮询直穿数据库 | -| 3 | **`jit = off` 只是止血** | `order_site` | 后续复测显示该容器 CPU **又回到 100%~222%**(见 [迁移方案](迁移方案-live-hub-py独立部署.md) §9)。SQL 重构与缓存不做,问题会复发 | -| 4 | 单机混部缺硬隔离 | 运维 | 4 核上跑 2 个数据库(PG 16 + MySQL 8.4)与 2 套高并发业务。应在 `docker-compose.yml` 为各服务配 `cpus` 限额,防止单个慢查询占满宿主机 | - -> 「严禁在生产上手工热修业务代码」的推进原则见 [迁移方案-live-hub-py独立部署.md](迁移方案-live-hub-py独立部署.md): -> 运行时参数属于紧急止血,源码改动一律走分支 → 本地验证 → 正规部署。 - ---- - -## 6. 时间线 - -| 时刻 (CST) | 事件 | -| :--- | :--- | -| 12:33 | 采样确认 4 核饱和、load 17.09;定位 PG JIT 与 MySQL filesort | -| 12:42 | 执行 `ALTER SYSTEM SET jit = off; SELECT pg_reload_conf();` | -| 12:43 | Postgres 197.75% → 1.58%,MySQL 49% → 4.46%,load 17.09 → 9.52 | -| 12:50 ~ 12:54 | 连续采样,拆出四个脉冲周期(§4) | -| 当日 | 落地迁移 `20261003_0040`(回填 23.9 万行 + 复合索引)并发布,单次查询 87 倍提速 | diff --git a/docs/登录系统全流程定案.md b/docs/登录系统全流程定案.md deleted file mode 100644 index 40640ec..0000000 --- a/docs/登录系统全流程定案.md +++ /dev/null @@ -1,127 +0,0 @@ -# 虎牙登录系统全流程定案(R35-R39 终态) - -> 日期: 2026-08-29 · 状态: 纯协议账号密码登录无卡点, 一号一设备成立 -> 本文是全链路终态总结; 算法细节见 `HUYA_HDID_ALGORITHM_GEN.md` §11.44-45 / §11.66-67。 - -## 一、总体架构: 三个库, 三条链 - -``` - com.duowan.kiwi (13.4.22) - │ - ┌─────────────────────┼──────────────────────┐ - ▼ ▼ ▼ -libudbauthunify.so libhydeviceid.so libturingmfa/ga.so - 账号登录(UDB) ★核心枢纽★ 腾讯turing SDK - md5/AES/xxtea/OTP ├─ hdid 生成 (XXTEA 属 hdid 子系统, - 账号级凭据 ├─ turing SDK 静态链接 与 dfpReport 无关) - └─ dfpReport 加密(RC4) -``` - -`libhydeviceid.so` 是枢纽 — "45 个钩子零调用"之谜的答案: turing SDK 整个被静态 -链接进该库, dfpReport 加密走库自身路径; `libz.deflate` 是它的必经外部导出 -(R36 以此为锚点定位)。 - -## 二、密钥/身份常量总账 (全部实锤) - -| 常量 | 值 | 存放位置 | 用途 | -|---|---|---|---| -| dfpReport RC4 key | `865a4924a40897ac1fcfe6b4c2abc798` | libhydeviceid .data (datadiv 加密) 字符串表 @3912240, 紧邻 "dfpReport" | 报文流加密。**APK 内嵌常量, 与设备无关** (R39) | -| k1 | `865a4924a40897ac1fcfe6b4c2cbb0e3` | BusinessCfg+0x10 (同在 datadiv) | appSign 派生 + 登录 AESkey 派生 | -| channelKey | `865a4924a40897ac1fcfe6b4c2cbb045` | resinfo (AES-ECB, key=`HuyaUdb1928374650qwertyuiop` 前16B), 服务端下发 | t1 设备指纹 | -| appSign | `ed0db8334cadd236c00cadf7e11ab5a5` | 运行时算出 | = MD5("5008_13.4.22_" + k1), 跨账号稳定 (R15) | -| hdid | 40hex (`7c5387e0...85ee`) | 设备派生 | getHDID / 登录身份 | -| getkey 表 | (1,2)=`MKDKeridjing7avnsasdSDHI`, (1,3)=`nskdI7MDGKSDJsnadjdoonvs` | libudbauthunify .data | AESkeyMgr 按 (1,cnt) 取盐 | -| xxtea key | 账号 UID (如 `1199666914671`) | 登录时填入 | UDB OTP 内层加密 | -| Perseus 会话密钥 | 16字符 `[A-Za-z0-9]`, SecureRandom | 内存 (RSA 信封上行) | 独立通道, 与 dfpReport 无关 (R34) | - -## 三、登录链 (UDB, 账号密码 → cred) - -1. **WUP 封装**: 登录请求走 taf/wup POST `wsapi.huya.com`; `createWupRequestData@0x38dab0` 构造, t1.t0 = appSign -2. **OTP 生成** (全链语义 100% 定案 §11.44): `getOtp(uid, 2, 2, "5008", k1, cred, cnt, nonce)`, - 明文 = `02 0c00 [12B xxtea(uid)] 7200 [114B cred]`, **AESkey = md5_char16(k1 + getkey(1,cnt))**, AES-ECB -3. **cred 凭据**: 账号级, 每次登录轮换; `cred0`/`credAnonymous` 落盘 (加密) -4. **会话密钥链**: `Perseus.a()` SecureRandom 16字符 → RSA 公钥封装 → guava{seq, env, KEY, d, expire} 上行 - -## 四、dfpReport 链 (风控上报, R35-R37 完全攻破) - -``` -采集指纹 JSON (~7-8KB: Athena + deviceinfo/Hebe_D1-D5 + guid + signature + terminal + version) - → gzip (level 9, 头 1f8b08 00 00000000 02 03) - → RC4(865a...c798) keystream 逐字节 XOR ← 密钥是 APK 常量! - → wire body = [10B "magic"(=keystream⊕gzip头, 非真魔数)] + [密文] (+尾部10B) - → taf/wup → POST wsapi.huya.com /dfpReport -``` - -- 触发配方: 清 `app_turingdfp/app_turingfd` + `resinfo*` (保留登录) → spawn → ~40s 自动重注册上报 -- 服务端判定 (差分实验定论): 三元组在设备库 + collection 完整 → 回显 hdid; 否则动态随机 token -- collection (行为数据) 不影响设备身份判定, 只影响动态/静态分级 -- 历史验证: 12/12 历史密文解密 + 3 轮伪造密文逐字节一致 (`tools/dfp_rc4.py` selftest) - -## 五、纯协议登录现状 (无卡点定案) - -生产链已存在且跑通: - -``` -core/huya/dfp_register.py 每次登录前: getDfpConfig → selectOperator → dfpReport(零设备伪造) - → 服务端签发 safedeviceid(t2) + device_id(t5, 40hex) -core/huya/app_login.py WUP hypasswordLogin (账号密码, t2/t5 填入登录帧) - → 正常: 直出新鲜 cred(114B) - → 风控: safe_auth 滑块自动过验 (pt_auth JS 逆向闭环) → 重发直出 -``` - -### R35-R39 带来的两个实质升级 - -1. **旧链隐形枷锁解除**: 旧伪造方式 = "一帧真实 wire 的 keystream 做 XOR 差分改明文", - 受限于捕获帧 3625B 长度且共用捕获 keystream。现在密钥是 APK 常量, gzip+RC4 离线无限 - 生成, 伪造报文与真机**密码学不可区分** (同 app 版本真机每轮 keystream 也相同 — - "magic" 恒定的原因)。 -2. **伪造质量升级**: R36 解出的 12 份真实指纹 JSON (Athena/Hebe/guid/signature/terminal/ - version 完整结构) 即标准模板, 可构造字段级真实 collection。 - -### 一号一设备方案 - -- 每账号一套设备画像 (hdid 40hex, guid, Hebe_D1-D5, CDID) → 跑一次注册链 → - 服务端签发该"设备"的 safedeviceid/device_id → 登录绑定 -- 同一账号后续 dfpReport 保持同一套画像值 (自洽); 不同账号画像互不相同 -- 滑块风控已有自动过验兜底 - -### 留意点 (非卡点) - -登录帧 t1.t0 的 32hex hdid 是"服务端硬锚" (libhydeviceid 设备 ID 体系), 注册链目前 -用随机占位 — 实测服务端照常签发; 若风控升级开始校验 t1.t0, 需补 hdid 真实派生算法 -(仓库最初主课题)。 - -## 六、离线复原能力清单 - -**✅ 纯离线可做 (无设备)**: dfpReport 全伪造 (`tools/dfp_rc4.py`) · appSign 计算 · -resinfo 加解密 · OTP 引擎 (V2 模型) · WUP 编解码 (`huya_wup_encoder.py`/`taf_decode.py`) · -历史密文批量解密 (12/12) - -**⚠️ 仍需真机**: 绕过配方 (唯一实测稳定 = RE 仓库三脚本两阶段流程; 合并版 bypass_all.js -未通过三层健康检查已弃用, 见 `Frida绕过配方.md`) · collection 完整采集逻辑 (Poseidon -反检测字段) · 换版本时 datadiv 常量重提取 (unidbg hydev harness 可离线做) - -## 七、工具索引 - -| 工具 | 说明 | -|---|---| -| `tools/dfp_rc4.py` | dfpReport 编解码器 (rc4_keystream/decrypt/encrypt/gzip_wrap + CLI) | -| `scripts/bypass_healthcheck.py` | 三层判定: pid 稳定 / 界面轨迹 / 崩溃 ANR + 主线程冻结探针 | -| `scripts/hook_*.py` | 各钩子 runner; `hook_final_capture.py` 的两阶段时序为模板 | -| `tools/frida/hook_deflate.js` | libz deflate 锚点钩 (只挂 libz.so; GL 驱动同名符号禁挂) | -| `tools/frida/hook_hy.js` | RC4 key 捕获 (libhydeviceid!0x60e6c x1) | -| `evidence/dfp_live/dec_*.json` | 12 份真实指纹明文 (伪造模板) | -| `tools/unidbg/hydev/` | libhydeviceid 离线 harness (datadiv 解密) | - -## 八、设备绑定实弹验证与 sdid 前缀定论 (R40 补充) - -- GUI 批量 App 登录实弹全通: 滑块过验 → cred(114B) → Cookie(720B) → 渠道记录。 -- **sdid 前 22 字符 (`0UnHUgv0_qmfD4KAKlwzhq`) = hydevice SDK 协议常量**: 16 字节固定 - 密文头 (固定 key + 固定载荷头) 的 base64 前缀 — 所有使用 hydevice-prod-1.2.45 的 - 客户端 (含真机) 都相同, **不是设备关联信号**。决定性实验: 两套完全不同的设备参数 - (xiaomi/samsung, 不同 UA/屏幕) 前缀依旧一致, 而 40hex hdid 每次全异。 -- 一号一设备的三道真实隔离 (均已实装): - 1. 画像: data/huya_device_profiles.json (机型/屏幕/CDID40/guid32/Hebe, 幂等补齐) - 2. 采集输入: data/huya_fp_states/<账号>/device.json → env.js makeEnv(overrides) - (UA/屏幕/机型按绑定画像生成, 决定性改变 collect 载荷) - 3. 状态: data/huya_fp_states/<账号>/localstorage.json (df/token 持久化) diff --git a/docs/自动化与资料收集.md b/docs/自动化与资料收集.md new file mode 100644 index 0000000..7a2492a --- /dev/null +++ b/docs/自动化与资料收集.md @@ -0,0 +1,62 @@ +# 自动化与资料收集 + +更新时间:2026-11-06 + +本文描述当前已落地的自动化、外部订单读取和文件存储能力。具体实现以 +`web/backend/services/automation/`、`external_order_source.py` 和 `file_storage.py` 为准。 + +## 自动化运行模型 + +- 调度器随应用启动,总开关为 `AUTOMATION_ENABLED`,默认关闭。 +- 每个作业还有独立开关和运行参数,默认关闭,在 `/automation` 页面配置。 +- 部署固定单容器、单 worker;作业在进程内线程池运行,重启会把未结束记录标为中断。 +- 作业状态和运行记录落 `automation_jobs`、`automation_runs`,不靠内存保存进度。 +- 单个作业失败只影响本轮;失败率异常时作业会自动停用,避免继续消耗账号或触发风控。 + +## 内置作业 + +| 作业 | 作用 | 默认边界 | +| --- | --- | --- | +| `ck_health` | 检测持有 CK 的账号 | 区分 `valid`、`invalid`、`error`;`error` 不覆盖上次有效结论 | +| `ck_relogin` | 对失效 CK 自动重登 | 有退避、次数上限和失败分型;极验/凭证/必须绑手机转人工 | +| `account_overview_refresh` | 分批刷新账号资料和一键查询快照 | 只查 CK 有效且未停用账号;“没查过”优先于“过期重查” | +| `points_expire` | 补记积分到期 | 只写账号事件,不修改外部平台 | +| `douyu_tasks_retention` | 清理过期任务日志 | 按任务类型保留期分批删除 | + +所有账号级作业都必须排除 `status` 为 `disabled` / `archived` 的账号,并尊重 +`auto_managed=False`。停用是“账号不可用”,`auto_managed=False` 是“账号可用但不要自动操作”, +两者不能混用。 + +## order_site 订单读取 + +`web/backend/services/external_order_source.py` 是全项目唯一允许读取 order_site +PostgreSQL 的地方: + +- 只读账号、`statement_timeout`、小连接池,不能承载写操作; +- 按订单号聚合,任一发货行或工单行命中都算已处理; +- 查询结果缓存 24 小时,缓存失败不影响主流程; +- `ORDER_SITE_LOOKUP_ENABLED=false` 或连接失败时降级到人工确认,并明确返回“未验证”; +- 不在其它模块重写跨库 SQL。 + +## 文件与资料收集 + +- `file_assets` 保存对象 key、场景、上传人和关联信息;业务表不保存临时 URL。 +- `OSS_MODE=local` 用于开发和测试;生产使用 OSS,容器只保留配置和对象 key。 +- 上传前限制大小并重编码图片;读取必须经过授权接口。 +- 孤儿文件由清理脚本处理;删除业务记录时要同时检查关联文件。 + +## 运维检查 + +```bash +# 查看自动化作业、运行记录和当前状态 +curl -s http://127.0.0.1:8000/api/automation/jobs +curl -s http://127.0.0.1:8000/api/automation/runs + +# 只读验证 order_site 查询是否启用 +uv run pytest tests/test_external_order_source_live.py -q -rs + +# 全量回归 +./dev.sh test -q +``` + +真实订单、兑换、支付和 CK 导出属于写操作或敏感操作,自动测试只验证契约和降级路径。 diff --git a/docs/自动化与资料收集规划.md b/docs/自动化与资料收集规划.md deleted file mode 100644 index cbdd6dc..0000000 --- a/docs/自动化与资料收集规划.md +++ /dev/null @@ -1,1460 +0,0 @@ -# 自动化与资料收集规划 - -更新时间:2026-10-02 -状态:**P1a(CK 巡检)+ P1b(CK 自动重登)已上线**;**P0 适配层已完成并真机验证(无接口/页面、开关仍关、生产未跑迁移)**;下一步 P2。产品决策已确认(见 §4),施工单见附录 C。 - -> ### 🔜 续作入口(下一个会话先看这里) -> -> 1. **当前该做 P2(资料收集链接 + OSS 上传)**,施工顺序见 **§C.5**,下一个迁移编号 **`20261003_0042`**; -> 2. P0 的适配层已就绪(`services/external_order_source.py`,`lookup_order()` 直接可用), -> P2 做「用户填订单号后回显商品」时**直接调它**,不要重写跨库 SQL; -> 3. P0 遗留三件事(要真正启用时才做):生产跑迁移 0039 建表、`.env` 开 `ORDER_SITE_LOOKUP_ENABLED`、补查询接口/页面(见 §C.4-bis); -> 4. 上手前必读 **§C.2 通用约定**(方言、时区、环境变量三处同步、迁移编号取号规则、 -> 「动手前先核实外部库真实表结构」)与 **§C.3 已核实的环境事实**。 - ---- - -## 1. 背景 - -当前后台的日常动作基本都要人工点:判断 CK 是否失效、失效后重登、和用户来回沟通收集资料、手工记录兑换过的皮肤。人工环节一多就会出现三个问题: - -1. **CK 维护不及时** —— 账号放着不管,等到客服要跑任务时才发现 CK 失效;临时重登又可能赶上邮箱验证码配额被打满; -2. **信息靠人转述** —— 用户在聊天里发订单号、游戏名、主页截图,客服再抄进系统,容易抄错、抄漏、无法追溯; -3. **兑换历史无台账** —— 谁在什么时候兑过什么皮肤只看聊天记录,无法执行「一年内同一件皮肤只兑一次」的规则。 - -目标是把这几块沉淀成系统能力,最终拼成一条半自动流水线:**客服只做「发链接」和「点执行」两件事**。 - -> 现状是**全人工**,因此本规划的基本原则是「逐步减少人工」,不是一步到位全自动(见 §5.1 第 5 条)。 - -## 2. 术语表 - -| 术语 | 含义 | -| --- | --- | -| **订单号 / order_no** | 用户在快手(或拼多多)下单后拿到的平台订单号,如 `2627501841217849` | -| **订单快照** | 从 order_site 读到的订单信息(platform / sku_code / sku_name / order_status)在本地留档,防止外部数据变动导致无法追溯 | -| **route** | 订单去向判定(只看 order_site 的客观事实):`unhandled`(既无履约任务也无接单平台工单,**没人管 → 本系统候选**)/ `auto_fulfilled`(有履约任务,发货平台已处理)/ `worker_platform`(有接单平台工单,打手已处理) | -| **皮肤 / skin** | 游戏内可兑换的外观类商品(套装 / 头盔 / 背包 / 枪皮等),是本系统实际要兑换的东西 | -| **skin_key** | 皮肤**标准名**,365 天规则的唯一判定键(由 C 桶皮肤类的 923 个原始商品名归一而来,具体收敛数待分析) | -| **别名** | 同一件商品的各种写法:快手 `relSkuId` / `relItemId` / `skuNick`、斗鱼商品名 | -| **skuNick** | 快手 SKU 规格名 —— **买家真正买的那件东西**(比店铺标题 `itemTitle` 精确,§3.6) | -| **relItemId / relSkuId** | 快手商品 ID / SKU ID,`(itemId, skuId)` 是单个订单的精确定位键 | -| **工单 / exchange_order** | 一笔兑换任务的载体:订单快照 + 用户资料 + 执行账号 + 状态 + 结果 | -| **资料模板** | 配置「要用户补哪些字段」,本系统固定为 游戏编号 / 游戏昵称 / 主页截图 | -| **兑换规则** | 配置「什么订单 → 对应哪个皮肤 → 用哪个斗鱼商品兑换」 | -| **台账** | `account_exchange_records`:账号 × 皮肤 × 时间的兑换历史 | -| **CK** | 斗鱼账号的登录 Cookie;本系统用「CK 有效/失效」描述账号状态 | -| **attempt** | 一次登录/重登尝试,落在 `login_tasks.attempt_*` 字段上,与账号持有的 CK 分离 | - -## 3. 现状盘点 - -### 3.1 可直接复用的能力 - -| 能力 | 现状 | 位置 | -| --- | --- | --- | -| CK 有效性判定 | `check_douyu_cookie()` 双接口(鱼丸 + 等级)同时通过才算有效 | `web/backend/services/cookie_check_service.py` | -| 检测结果持久化 | `login_tasks.ck_check_status / ck_check_result / ck_checked_at` | 已落库 | -| 批量检测 | `POST /api/cookies/check`、`/api/cookies/operations/check` | `web/backend/routers/cookies.py` | -| 批量重登无效 CK | `POST /api/cookies/operations/relogin-invalid` | 同上 | -| 重登执行器 | `LoginBatchRunner(mode="relogin")`:成功才替换 CK、`attempt_*` 状态机、可停止、重启会收敛 | `web/backend/services/login_service.py` | -| 邮箱配额信号 | `EmailQuotaError`(斗鱼返回「发送次数已达上限」时抛出,**只影响该账号**) | `core/douyu/login.py` | -| 商品/皮肤快照 | `douyu_goods_snapshot`(名称、积分、库存、raw)—— **实测只有 78 个可兑换商品**(皮肤类 60),这就是我们能兑的全部(§3.5) | 已落库 | -| 斗鱼侧兑换记录 | `client.exchange_records(manual_id)` + `query_exchange_records` 任务 | `core/douyu/activity_client.py:950` | -| 任务编排 | `douyu_tasks`(批次/账号/类型/结果/时间)+ `DouyuBatchRunner` | 已成型 | -| 权限与审计 | `PERMISSIONS` / `ROLE_PERMISSIONS` + `audit_logs` | 已成型 | -| 部署形态 | 单容器(应用 + worker);Caddy 反代 `live.khhao.com → douyu-login:8800`;`./data`、`./logs` 持久化 | `docker-compose.yml` | - -### 3.2 缺口 - -1. **没有任何定时/调度机制** —— 全项目只有 HTTP 触发 + 进程内线程,没有 scheduler; -2. **没有文件上传与存储** —— `UploadFile` 全项目未使用,也没有对象存储依赖(`oss2` 未安装),还没有公开(无鉴权)页面; -3. **没有兑换台账** —— 兑换历史散落在 `douyu_tasks.result` 里,无法按「账号 × 皮肤 × 时间」查询; -4. **没有订单概念** —— 用户手里的「订单号 → 商品/皮肤」不在本系统,需与 order_site 整合(见 §3.3); -5. **`POST /api/login/batch` 没有审计记录** —— 自动化要可追溯,需补上。 - -### 3.3 外部依赖:order_site(快手自动发货系统) - -项目:`/Users/yml/codes/order_site`、仓库 `gitea.hao858.com/yml/order_site.git`,与本系统**同一台服务器**部署(`order_site-web-1` / `order_site-backend-1` / `order_site-postgres-1`,Caddy 反代 `worker.khhao.com`)。 - -**它已经持有「订单号 → 商品/皮肤」这层数据**(2026-10-02 线上 PostgreSQL 实测): - -| 事实 | 数据 | -| --- | --- | -| 订单量 | 271,032 单(快手 268,436 / 拼多多 2,596) | -| 订单与商品明细 | **1 单 = 1 件商品**(271,024 单为 1:1) | -| 关键字段 | `orders.platform_order_id`(订单号)+ `order_items.sku_code` / `sku_name` | -| 实例 | `2627501841217849` → `suit_shadow_gothic` / `套装-暗影哥特` | -| 商品名种类 | distinct `sku_name` **1,514** 个;distinct `sku_code` **4,241** 个 | -| 语义化皮肤码 | 非纯数字 code 共 **173** 个(`suit_*` / `helmet_*` / `bag_*` / 中文名) | - -**两条关键结论(直接决定本规划的数据模型)** - -1. **统计归类要看名称,单订单定位要看 SKU**:同一个商品名(如「指挥官密钥5个」)会对应 30~45 个不同数字 `sku_code`(不同店铺/不同时期的 item 各挂一个),所以**按名称做归类统计**;但单个订单的 `(relItemId, relSkuId)` 精确指向一件具体商品(详见 §3.6); -2. **同一件皮肤有多种写法**:实测「套装-浪漫天命」至少三种形态—— - `sku_code=套装-浪漫天命 / sku_name=套装-浪漫天命`、`sku_code=suit_romantic_destiny / sku_name=套装-浪漫天命`、`sku_code=套装-浪漫天命 / sku_name=浪漫天命`。 - 按名称当 key,365 天规则会失效(同一件被当成三件)—— 所以判定键最终落在**斗鱼商品**上(§3.5)。 - -**它已有的、与本规划同形但语义不同的能力** - -| order_site 能力 | 说明 | 与本规划的关系 | -| --- | --- | --- | -| `/api/collect/lookup · submit · upload` | 凭**订单号**查待完善单 → 动态字段 → 上传截图 → 提交 | 面向**打手工单**(`work_orders`),字段由 `fulfillment_profile_requirements` 决定,语义不同;**实现模式与资料模板结构可直接借鉴** | -| `file_assets` + `/api/v1/files/object` | **阿里云 OSS(私有桶 `order-site-assets`)**,`sharp` 生成 thumb(480)/medium(1280) 变体;DB 只存 `object_key`,图片通过**自己的后端代理**读取(带 ETag/304) | **P2 的图片存储照这个模式做**(§8.3) | -| `claim_tokens` + `/api/claims/:token` | 用户领取页令牌(有效期 / 次数) | P2 的 token 设计可对齐 | -| `POST /api/open91/orders/query` | 面向 91 卡券的**签名鉴权**订单查询 | 证明跨系统调用可行,但返回的是发货状态,不含 sku | -| 限流中间件 `createRateLimitMiddleware` | 公开接口按字段/参数限流 | P2 可参考阈值设计 | -| 接单平台的规则/模板/状态机 | 见 §10.0 | **P4 的整体设计蓝本** | - -**当前没有**:面向内部系统的通用 API Key 鉴权、按订单号返回商品明细的内部接口。(`/api/admin/orders` 需要后台管理员 session。) - -### 3.4 order_site 的订单路由:决定「哪些订单需要来这边」 - -实测(2026-10-02):order_site 把订单**二选一**路由,`fulfillment_tasks` 与 `work_orders` **完全不重叠**(both = 0,共 271,134 单): - -| 桶 | 判据 | 规模 | 典型商品 | 来 live-hub-py? | -| --- | --- | --- | --- | --- | -| A 已自动发货 | 有 `fulfillment_tasks` | 85,338 | 套装-浪漫天命 / 套装-暗影哥特 / 国宝胖达头盔 / 萌熊伴侣背包 | ❌ **不要**(发货平台已处理) | -| B 接单平台 | 有 `work_orders`(打手众包) | 96,755 | 指挥官密钥 / 荣耀专属礼盒 / 苏尔南钻石 / 暮色之下礼盒 | ❌ 不要(打手已处理) | -| **C 无人处理** | 两张表都没有 | **89,542** | **兔兔白日梦 / 狂战萌兔 / 青涩年华** / 黑色ROCK长袖 / 幸运币2个 | ✅ **要(本系统的目标)** | - -**为什么 A 桶不要**(`fulfillment_tasks.delivery_status` 实测): - -| 通道 | delivered | cancelled | closed | pending | -| --- | --- | --- | --- | --- | -| affiliate_dash | 29,713 | 1,044 | 433 | 569 | -| kuaishou_ct_assisted | 39,697 | 3,154 | 2,452 | 1,591 | -| kuaishou_feifei | 6,216 | 507 | 113 | 171 | -| **合计** | **75,626(88.3%)** | 4,705 | 2,998 | 2,331 | - -**判据方向:A、B 都是「已经有人管」的订单(发货平台 / 打手),只有 C 是「没人管」——才轮到我们。** -(这一版推翻了本规划早期把 A 当成目标的判断:A 桶 88.3% 已 delivered,是发货平台的活。) - -**C 桶内部分类**(1,227 个商品名 / 89,542 件,按商品名关键词粗分): - -| 子类 | 件数 | 名字数 | 典型商品 | 处理 | -| --- | --- | --- | --- | --- | -| ① 服装类 | 17,704 | 19 | 黑色ROCK长袖+10幸运币 / 蓝色运动服上衣-黑色ROCK(2选1) | ⏸ **后面单独处理**(负责人已定) | -| ② 卡券/道具类 | 17,948 | 202 | 幸运币2个 / 9月改名卡 / 指挥官密钥 / 礼盒 | ❓ 待确认(疑似走电子凭证自动发码) | -| ③ 纯数字商品名 | 4,491 | 83 | 100089 / 99999 / 100033 | ❓ 待确认(需人工识别是什么) | -| ④ **皮肤/套装类** | **49,400** | **923** | 兔兔白日梦 / 狂战萌兔 / 青涩年华 / 风蚀行者 / 猫布灵 / 雪山精英 / AUG-魔性柴柴 / 浪漫波比 / 暮夜私语 | ✅ **现在做** | - -> ④ 里混着大量 SEO 堆词的商品名(如「和平精英皮肤小店入口浪漫天命黑色上衣暗影哥特雪国小丑木乃伊」「和平精英熔岩游骑兵胖达团团圆圆萌熊伴侣背包雪山精英兔兔白日梦」),**923 个原始名归一后应明显收敛**,具体收敛到多少要先跑一遍归一分析(见 §9.2)。 - -**准入判据(两层,缺一不可)** - -| 层 | 判据 | 回答的问题 | -| --- | --- | --- | -| 第一层:外部事实 | `fulfillment_tasks` 与 `work_orders` **都不存在**(`route = unhandled`) | 这单是不是**没人管**? | -| 第二层:我们的配置 | 商品能匹配到「皮肤兑换规则」(§10.1) | 这单的皮肤我们**做不做**? | - -两层串起来正好把「P0 的 route 判定」和「P4 的规则匹配」接上:route 决定候选范围,规则匹配决定最终放行。 - -> **分阶段落地**:P2(收集链接)上线时 P4 的规则匹配还没有,此时只能做第一层(`route=unhandled` 放行 + 客服受理时人工把关);P3/P4 上线后再加第二层收窄。 - -### 3.5 我们实际能兑什么:斗鱼商品目录才是真正的边界 - -`douyu_goods_snapshot` 实测(2026-10-02):**只有 78 个可兑换商品**,全部来自手册 `20260120QYOOB`(精英宝典)—— **皮肤类 60 个**、非皮肤 18 个(体验卡 / 改名卡 / 幸运币礼包 / 精英火箭等)。 - -把 C 桶皮肤类那 923 个快手商品名往这 60 个皮肤类商品上映射: - -| 类别 | 快手名 | 件数 | 占比 | 处理方式 | -| --- | --- | --- | --- | --- | -| ✅ **唯一命中**斗鱼商品 | 62 | 13,678 | 27.7% | 订单可自动确定皮肤 → 直接建单 | -| ⚠️ **命中多个** | 14 | 6,122 | 12.4% | 必须让用户在链接里**选一件** | -| ❌ **斗鱼兑不了** | 847 | 29,612 | 59.9% | 拒绝(斗鱼没有对应商品) | - -**结论:斗鱼现有商品只能覆盖 40.1% 的 C 桶皮肤订单。** 剩下近 6 成(青涩年华、风蚀行者、雪山精英、AUG-魔性柴柴、浪漫波比、暮夜私语、顽皮橙 …)斗鱼根本没有对应商品,**做不了**。 - -> **锚点必须是斗鱼商品,不是快手商品名。** 快手那 923 个名字最终都要落到这几十个商品上:映射得上 = 能做,映射不上 = 做不了。 -> 完整商品清单、可映射的 76 个快手名、兑不了的 Top 40 见 **[皮肤准入映射分析](皮肤准入映射分析.md)**。 - -### 3.6 SKU 才是精确定位键(匹配层按这个来) - -`order_items.spec_json` **已经带了定位所需的全部字段**,不需要解析商品名文本: - -```jsonc -{ - "skuId": "192758405127439", // 快手 SKU ID - "skuNick": "(3选1)甜心娇蕊-清雅山茶-紫梦罗兰", // SKU 规格名(比 itemTitle 精确) - "sellerId": "4661340439", - "relItemId": "25228845231439", // 快手商品 ID - "relSkuId": "", - "itemTitle": "和平精英皮肤小店入口浪漫天命黑色上衣暗影哥特雪国小丑木乃伊", // 店铺商品标题(SEO 堆词,不可靠) - "source": "kuaishou_send_code", - "paymentFen": 10300 -} -``` - -**一个 item(皮肤小店)挂多个 SKU,每个 SKU 就是一件具体商品** —— 实测同一 `relItemId=25228845231439` 下: - -| skuId | skuNick | -| --- | --- | -| `187748927889439` | 兔兔白日梦 | -| `187748927890439` | 狂战萌兔 | -| `192651207500439` | 黑色ROCK长袖+10幸运币 | -| `193618786956439` | 冥河战士大礼包 | -| `177839563418439` | 青涩年华 | -| `191455593788439` | 风蚀行者 | - -所以:**`itemTitle` 是店铺标题(堆词,没用),`skuNick` / `skuId` 才是买家真正买的那件东西。** -实测 4,092 个数字 `skuId` 中 89% 只对应一个 `skuNick`;另外 11% 是跨 item 复用,用 `(relItemId, relSkuId)` 组合即可区分。 - -**接单平台就是按 SKU 定位的**(`work_product_rule_mappings` 实测): - -| mapping_type | 定位字段 | 真实例子 | -| --- | --- | --- | -| `sku_exact` | `rel_sku_id`(单个或多个快手 SKU ID) | `191555220780642` → `小金人+金AUG+金M338`;`191555220783642` → `红雪隼AA12` | -| `sku_exact` | `rel_item_id`(多个 item ID)+ `sku_nick` | rule 237:11 个 item ID + `指挥官密钥` | -| `sku_series` | `sku_nick` 前缀 | `指挥官` / `碎梦浮光珍宝礼盒` / `突击兵` | -| `product_default` | `rel_item_id` 或 `item_title` | 兜底 | - -> **本系统的映射层照抄这个结构**(§10.1):精确 SKU 映射覆盖高频商品,文本归一只做兜底。 -> 顺带修正本规划早期的一个错误结论:不是「数字 skuId 不能用」,而是「跨 item 会重复,所以要用 `(itemId, skuId)`」。 - -## 4. 产品决策(已确认) - -| # | 决策 | -| --- | --- | -| 1 | 邮箱验证码配额打满**只针对单个账号**:跳过该账号、按天退避,**不暂停整个批次** | -| 2 | CK 巡检频率默认 **6 小时**,可在后台配置 | -| 3 | 收集链接**不做复杂进度回执**;兑换成功后用户通过同一个链接能看到结果 | -| 4 | 同一皮肤判定用**滚动 365 天**;判定键是**斗鱼商品**(`skin_catalog.commodity_id`),不是快手商品名 —— 因为一个斗鱼商品对应多个快手写法(§3.5) | -| 5 | 用户填的订单号只做基础处理:**非空 + 唯一 + 去除空白字符** | -| 6 | 命中 365 天规则时允许**强制兑换**,但**仅超管**且**必填原因**(写审计) | -| 7 | 收集提交的可见范围:**归属客服 + 运营/超管** | -| 8 | 资料模板字段固定为 **游戏编号 + 游戏昵称 + 主页截图**;**皮肤优先由订单号自动对应**;仅当订单命中多个斗鱼商品时(「3选1」「小店自选」类,占 12.4%),才让用户在链接里**选一件** | -| 9 | 兑换**不涉及「加好友后赠送」**,状态机不需要"等待可赠送"等待态 | -| 10 | 订单准入**分两层**:① 订单在 order_site 里**既无履约任务、也无接单平台工单**(`route=unhandled`,即「没人管」)才进入候选;② 候选订单的商品必须**命中皮肤兑换规则**才放行。其余一律提示「该订单暂不支持,请联系客服」,客服可在后台人工放行(留审计) | -| 11a | **重登失败按原因分型**(`relogin_failure_kind`),不是笼统「失败」:极验/凭证问题转人工,取码/中断/网络自动重试,配额满退避到次日(详见 §7.4.3) | -| 11 | **准入白名单以斗鱼商品目录为准**:能映射到 `douyu_goods_snapshot` 的才放行;映射不到(占 C 桶皮肤订单 59.9%)直接拒绝 —— 因为斗鱼兑不出来。**不匹配就不建单**,后续按实际数据逐步放宽 | -| 12 | **匹配优先按 SKU 精确定位**(`relSkuId` / `skuNick`),商品名文本只作兜底(§3.6)—— 接单平台已是这个做法 | - -## 5. 总体架构决策 - -### 5.1 五条原则 - -1. **调度器放进程内,状态全部落库** - 新增一个 60s tick 的 daemon 线程;作业定义、开关、间隔、上次/下次运行时间、每轮结果都写库(`automation_jobs` / `automation_runs`)。进程重启不丢状态,也不会因为重启而重复跑一轮。 - -2. **自动化只做「决策 + 排队」,执行仍走现有批次** - 复用 `LoginBatchRunner` / `DouyuBatchRunner`,不另写执行器。这样人工和自动共用同一条安全链路(`attempt_*` 状态机、停止信号、启动清理、CK 只在成功时替换)。 - -3. **公开入口单独一条链路** - 收集链接是全新的无鉴权入口,按「最小暴露 + 强限流 + 图片二次编码」设计,不与内部接口共用鉴权中间件。 - -4. **单容器前提** - 不做分布式锁;用「数据库里的在途状态 + 进程内作业锁」避免重入即可。将来若多副本部署,再引入 DB 级 advisory lock。 - -5. **渐进上线:每一步都保留人工入口,自动化只是"少点几下"** - 现状是**全人工**,目标是逐步减少人工,所以每条自动化都必须: - - 有独立开关,**默认关闭**,观察一段时间再打开; - - 人工入口原样保留(人工检测 / 重登 / 建单 / 执行都还能走); - - 出问题能回退到人工,**不做不可回退的自动执行**(尤其涉及花钱的支付环节,永远人工扫码确认); - - 先做「只读 / 只提示」,确认数据无误后再做「自动动作」。 - -### 5.2 与 order_site 的整合方式 - -**结论:只整合「订单号 → 商品/皮肤」这一段;收集链接在 live-hub-py 自建。** - -理由:P4 的工单、P3 的台账、兑换执行全在 live-hub-py,让用户提交的数据与工单同库,链路最短;order_site 的 collect 是打手侧语义,硬套会把两套业务搅在一起。 - -**原则:order_site 一行代码都不改,也不改它的部署**,把「读订单」的能力放在 live-hub-py 一侧。 - -#### 方案对比(核心诉求:改需求时只改一处) - -| 方案 | 要改 order_site? | 契约 | 契约稳定性(实测) | 运行时依赖 | 改需求时改几处 | -| --- | --- | --- | --- | --- | --- | -| A. 在 order_site 加内部 HTTP 接口 | **要**(写接口 + 鉴权 + 发版) | 我们设计的 HTTP 字段 | 中:接口由我们定,但每次调整都要对方配合发版 | 强 | **2 处** ❌ 已否决 | -| **B. live-hub-py 只读直连 PG** ✅ 采用 | **不要**(仅一次性建只读账号) | `orders` / `order_items` / `fulfillment_tasks` / `work_orders` 的少数几列(只判存在性 + 取 sku) | **极高**:88 个迁移里 `orders`/`order_items` **从未 ALTER** | 强(有缓存与降级开关) | **1 处** | -| C. 定时同步进本地表 | 不要(读法同 B) | 同 B + 同步器 | 高 | 弱(本地有副本) | 1 处(多一个同步作业,可复用 P1 调度器) | -| D. 完全不整合,用户从斗鱼商品下拉选皮肤 | 不要 | 无 | — | 无 | 1 处 ❌ 已否决(决策 8:皮肤由订单决定) | - -**A 之所以「改两处」,不是因为要读数据,而是因为把读接口的实现放进了对方系统。** B/C 把读的能力收回自己这边,order_site 就彻底静止了。 - -#### 实测支撑(2026-10-02) - -- **网络已通,不需要动 order_site 的部署**:`live-hub-py-douyu-login-1` 同时挂在 `live-hub-py_default` 与 **`order_site_default`** 两个网络上,实测可直连 `order_site-postgres-1:5432`(172.19.0.2)。这是 Caddy 反代时加的,属于既有事实; -- **表结构极稳**:order_site 共 88 个迁移,`orders` / `order_items` **自 001 基线以来从未被 ALTER**;我们只需要 `platform_order_id`、`sku_code`、`sku_name`、`order_status`(外加 `platform`),全部是外部平台透传字段; -- **查询走索引**:`idx_orders_platform_order_id btree (platform_order_id)` 存在,单按订单号查也能命中(另有 `(provider, platform, platform_order_id)` 复合索引;线上 provider/platform 只有 `91kaquan/kuaishou` 与 `pdd/pdd`)。 - -> 具体实现见 §6 P0。 - -## 6. P0 · 订单读取适配层 - -### 6.1 目标 - -让本系统能「凭订单号知道这是什么订单、要不要我们处理、对应哪件皮肤」,同时对 order_site **零代码改动、零部署改动**,且出问题时能一键降级。 - -### 6.2 数据模型(迁移 1 个) - -**`external_order_cache`(订单快照)**:`platform_order_id` + `source_order_id`(联合唯一;对应 order_site `orders.platform_order_id` 与 `orders.id`)/ `platform` / `provider` / `shop_id` / `order_status` / `sku_code` / `sku_name` / **`seller_id` / `rel_item_id` / `rel_sku_id` / `sku_nick` / `item_title`** / `quantity` / `route` / `payload`(JSON) / `fetched_at` - -> **⚠️ 2026-10-02 修订**:order_site 没有 `order_no` 列,主键改为 `platform_order_id` + `source_order_id`。 -> 后者不可省 —— 同一个 `platform_order_id` 有 541 组重复(详见 §6.5.1),按单列存会互相覆盖。 -> -> 用途:用户提交订单号时立即回显商品;工单与台账留档;order_site 不可用时降级。 -> 缓存有效期建议 24 小时(订单状态可能变化,过期后重查)。 - -### 6.3 适配层接口 - -**所有跨库 SQL 只允许出现在一个文件里**:`web/backend/services/external_order_source.py`。将来表结构或数据源变化,只改这一个文件。 - -对外只暴露一个语义化方法: - -```python -lookup_order(order_no) -> { - order_no, platform, provider, order_status, - sku_code, sku_name, route, # route ∈ {unhandled, auto_fulfilled, worker_platform} - seller_id, rel_item_id, rel_sku_id, # SKU 定位键(取自 spec_json,§3.6) - sku_nick, item_title, # SKU 规格名 / 店铺标题 - cached: bool, error: str | None, -} -``` - -- `route` 由 §3.4 的互斥判据得出:有 `fulfillment_tasks` → `auto_fulfilled`;有 `work_orders` → `worker_platform`;都没有 → `unhandled`。**业务代码只认 `route`,不认 SQL 细节**; - > **⚠️ 按 §6.5.1 的决策 2,判定要在「订单号维度」聚合**:按 `platform_order_id` 取到全部行, - > 任一行有 `fulfillment_tasks` 即 `auto_fulfilled`(540 个已发货的重复组靠这条拦住)。 -- 查询顺序:先查 `external_order_cache`(未过期)→ 未命中再查 PG → 回写缓存。 -- 入参 `order_no` 实际就是 `platform_order_id`(对外仍叫 order_no,保持接口语义稳定)。 - -### 6.4 准入规则(用户填订单号时) - -| `route` | 第一层处理 | 第二层(命中皮肤兑换规则?) | -| --- | --- | --- | -| `unhandled`(两张表都无) | ✅ **候选**,继续第二层 | 命中 → 放行建单;未命中 → 拒绝或人工归类 | -| `auto_fulfilled`(有 `fulfillment_tasks`) | ❌ 拒绝:「该订单已由发货平台处理,无需在此提交」 | —— | -| `worker_platform`(有 `work_orders`) | ❌ 拒绝:「该订单由接单平台处理,无需在此提交」 | —— | -| 查不到订单 | ❌ 拒绝:「订单号不存在,请核对」 | —— | -| order_site 不可用 | ⚠️ 允许提交但快照为空、标记 `order_unverified`,交客服人工确认 | —— | - -> 第二层依赖 P4 的规则匹配;P2 阶段先只做第一层,客服受理时人工把关(见 §3.4 分阶段落地)。 - -### 6.5 只读保护(对对方生产库) - -**✅ 已在生产落地(2026-10-02)**: - -| 项 | 实际值 | -| --- | --- | -| 角色 | `livehub_ro`(`CONNECTION LIMIT 5`) | -| 授权 | `SELECT` on `orders` / `order_items` / `fulfillment_tasks` / `work_orders`(仅这 4 张) | -| 超时 | `statement_timeout = 3s`、`idle_in_transaction_session_timeout = 15s` | -| 写保护 | `default_transaction_read_only = on` —— **物理上写不了**(实测 `CREATE TABLE` 被拒) | -| 越权 | 实测查 `admin_users` → `permission denied` ✅ | -| 连接串 | `ORDER_SITE_DATABASE_URL` 已写入 `/root/live-hub-py/.env`(`ORDER_SITE_LOOKUP_ENABLED=false`,默认关) | - -应用侧仍需遵守:只走单行索引查询、禁止全表扫描与聚合;**独立连接池且极小(2 条)**,不影响本系统主库连接池。 - -> **⚠️ 实现前提:应用容器里没有任何 PostgreSQL 驱动**(`psycopg` / `psycopg2` / `asyncpg` / `pg8000` 全未安装,只有 SQLAlchemy 本体)。 -> P0 必须往 `pyproject.toml` 加一个驱动,三选一: -> - ✅ **`psycopg[binary]`(已定)**,SQLAlchemy 2.0 原生支持,URL 用 `postgresql+psycopg://` -> - (备选:`psycopg2-binary` / `pg8000` 纯 Python) -> -> 适配层要把 `.env` 里的 `postgresql://` 归一成带驱动的 URL(如 `postgresql+psycopg://`)后再交给 SQLAlchemy。 - -### 6.5.1 ⚠️ 实测偏差:没有 `order_no`,且同一订单号跨桶重复(2026-10-02 上机核实) - -在生产机(`order+douyu_huya`)用只读账号 `livehub_ro` 实测,§6.2/§6.4 的两处假设与真实库不符。**P0 必须按本节实现**: - -| 项 | 规划原文 | 实测 | -| --- | --- | --- | -| 订单号列 | `order_no`(PK) | ❌ **不存在**。只有 `id`(内部 bigint PK)与 `platform_order_id`(用户手上的快手单号) | -| `platform_order_id` 唯一性 | 隐含唯一 | ❌ **非唯一**:276,371 行 / 275,830 个号 → **541 个号重复** | -| 重复的形态 | —— | 541 组**全部**是 `n=2, shops=2`:`shop_id='91kaquan'` 行 + 一个真实店铺行 | -| 重复组的分桶 | —— | **540 组跨 A/C 桶**:`91kaquan` 行有 `fulfillment_tasks`(`delivery_status` = delivered/closed)→ A;真实店铺行无 A/B → C | -| 影响面 | C 桶 89,542 | C 桶实测 **91,029 行 / 91,028 个号**,其中 **540 个号的同一单已被 91kaquan 发货** | - -**决策(已确认)** - -1. **查询入口 = `platform_order_id`**(用户/客服填的快手订单号),走 `idx_orders_platform_order_id` 索引;`orders.id` 只作内部关联,不当对外入口; -2. **route 判定改为「订单号维度聚合」**:按 `platform_order_id` 取**全部行**, - 任一行命中 `fulfillment_tasks` → `auto_fulfilled`;否则任一行命中 `work_orders` → `worker_platform`;都没有 → `unhandled`。 - > 不这么做,这 540 个**已发货**订单会被误判为「没人管」而放行重复履约 —— 与 §3.4「A 桶不要」直接矛盾。 -3. 回显的 SKU 取**真实店铺行**(`shop_id != '91kaquan'`,带 `sku_code` / `sku_name` / `spec_json`); - `91kaquan` 是发货方内部行(`provider=91kaquan`、`automation_mode=manual`、`sku_code` 形如 `suit_shadow_gothic`),不用于展示。 - -> 缓存表主键相应改为**订单号 + 店铺行**(见 §6.2 的 `order_no` → `platform_order_id`,并带 `source_order_id`),避免多行互相覆盖。 - -### 6.6 降级开关 - -`ORDER_SITE_LOOKUP_ENABLED=false` 时完全不查外部,退化成「客服手工填订单信息 + 人工确认」,主流程照常可用。 - -> 若将来想要「零运行时依赖」,把「按需查」升级为「定时增量同步」(§5.2 方案 C),同步作业直接挂到 P1 的调度器上,不引入新机制。 - -### 6.7 验收标准 - -1. 输入一个 A 桶订单号(有履约任务)→ `route=auto_fulfilled`,被正确拒绝; -2. 输入一个 B 桶订单号(有接单平台工单)→ `route=worker_platform`,被正确拒绝; -3. 输入一个 C 桶订单号(如「兔兔白日梦」的订单)→ `route=unhandled`,进入候选并带出 `sku_name`; -4. 输入不存在的订单号 → 明确提示「订单号不存在」; -5. 关掉 `ORDER_SITE_LOOKUP_ENABLED` → 系统仍可正常走手工流程; -6. 查一次订单对 order_site 的负载可忽略(单行索引查询,实测 < 50ms); -7. **输入一个「重复订单号」(`91kaquan` 行已发货 + 店铺行无人管,共 540 个)→ `route=auto_fulfilled`,不被放行**(§6.5.1)。 - -## 7. P1 · CK 自动管理 - -### 7.1 目标(分两步,不要一次上) - -- **P1a(先做)**:把「哪些账号 CK 失效了」自动找出来并展示 —— **只检测、只标记、只提示,不改动任何 CK**;重登仍然人工点。 -- **P1b(观察后再开)**:把「失效账号自动重登」也自动化 —— 有退避、有熔断、有开关,**默认关闭**。 - -> **P1a 已落地(2026-10-02)**,实际落点: -> `services/automation/scheduler.py`(调度线程 + 到点判定 + 运行记录)、 -> `services/automation/jobs/ck_health.py`(巡检作业)、 -> `routers/automation.py`(4 个接口)、`permissions.py`(`automation:view` / `automation:manage`)、 -> `migrations/versions/20261002_0037_automation_jobs.py`、 -> 前端 `pages/AutomationPage.tsx`(菜单「自动巡检」)、 -> `tests/test_automation_ck_health.py`(22 条)。 -> 端到端已用 3 个真实账号验证:巡检结果与直接检测逐条一致,且 `cookie` / `status` / `message` / `attempt_status` 全程未变。 - -**生产上线记录(2026-10-02)** - -按三段走完,全部通过: - -| 段 | 动作 | 结果 | -| --- | --- | --- | -| ① | `AUTOMATION_ENABLED=true` + 重启 | 容器内变量生效,日志「自动化调度器已启动,tick=300s」 | -| ② | 手动跑一轮(每轮 50 条,作业开关仍关) | `success`,50 条 / 7 秒,**斗鱼无风控反应**(无验证码、无频控、无超时) | -| ③ | 每轮 200 条 + 打开作业开关 | 调度器自动接管:12:27:26 → 12:27:48 跑完 200 条(22 秒),`next_run_at` 按 tick 推进到 +5 分钟 | - -**上线过程中抓到(并已修)两个只有生产才会暴露的问题**,都已在 §C.2 记档: - -1. `NULLS FIRST` 在 SQLite 合法、**MySQL 报 1064** → 改用 `col IS NULL DESC`,并加了一条按 MySQL 方言 `compile()` 的回归测试; -2. compose 的 `environment:` 是**显式列表**,只写 `.env` 不会传进容器 → `AUTOMATION_ENABLED` 起初根本没生效(顺带发现早先写入的 `ORDER_SITE_*` / `OSS_*` 也全都没传进去)。 - -**首轮数据结论(供客服侧预期管理)**:前几轮 100% `invalid` 属正常且正是价值所在 —— 选行是「从没检测过的优先」,而积压的 1,897 条未检测记录里大部分是 **2026-08-04 一次性批量导入、`attempt_status` 为空(从未尝试登录)**、CK 已放两个月的陈旧账号。错误信息统一是斗鱼侧的「用户未登录」,不是风控。按 200 条/5 分钟,约 5.4 小时扫完全库积压后进入稳态。 - -> **注意**:巡检会把这些陈旧账号的 `ck_check_status` 从空翻成 `invalid`,客服在「CK 检测与重登」页会突然看到大批失效账号 —— 这是预期行为,不是故障。 - -### 7.2 数据模型(迁移 1 个,P1a/P1b 共用) - -**新表 `automation_jobs`** - -| 字段 | 说明 | -| --- | --- | -| `key` (PK) | 作业标识:`ck_health` / `ck_relogin` | -| `title` | 展示名 | -| `enabled` | 开关(**默认 false**) | -| `interval_seconds` | **单账号重检间隔**(CK 巡检默认 6h)—— 注意不是作业运行周期,作业每个 tick 都跑(见 §7.3) | -| `batch_size` | 单轮处理上限(`ck_health` 默认 **200**,按「5 分钟一轮 × 200 条 = 40 条/分钟 ≥ 需要的 36 条/分钟」反推,见 §7.3;`ck_relogin` 默认 20) | -| `concurrency` | 单轮并发 | -| `config` (JSON) | 作业私有配置(退避表、每日上限、失败率阈值等) | -| `last_run_at` / `next_run_at` | 调度用 | -| `last_status` / `last_message` | 最近结果 | -| `updated_at` | | - -**新表 `automation_runs`**:`id` / `job_key`(索引) / `started_at` / `finished_at` / `status` / `scanned` / `valid` / `invalid` / `relogin_started` / `relogin_success` / `skipped` / `message` / `detail`(JSON) - -**`login_tasks` 增字段**:`relogin_attempts`(int, 默认 0) / `relogin_next_at`(datetime, 可空) / `relogin_last_error`(string) - -**`accounts` 增字段**:`auto_managed`(bool, 默认 true) —— 给特殊账号留「不自动动」的后门 - -### 7.3 P1a · 作业一:`ck_health`(巡检,只检测) - -- **选取范围**:从**持有 CK 的 `login_tasks` 行**里挑(即现有 `cookie_holding_filter()` 的口径:`cookie is not null and cookie != ''`)—— CK 是挂在登录记录上的,不是挂在 `accounts` 上; -- **调度**:**每 5 分钟跑一轮**(`TICK_SECONDS = 300`);每轮挑「距上次检测超过 `interval_seconds`(默认 6h)」的行,最多 `batch_size`(默认 200)个。 - 这样全库是**滚动覆盖**的,负载平稳,不会出现「整点扫 1.2 万个账号」的尖峰; -- **⚠️ 两个「间隔」不能混**:`TICK_SECONDS` 是**作业多久跑一轮**;`job.interval_seconds` 是**同一行数据多久重新检测一次**(只用于选行 cutoff)。 - 如果误把作业周期设成 `interval_seconds`,就变成「200 条/6 小时 = 800 条/天」,全库 12,881 条要 16 天才能过一轮 —— 等于没巡检(P1a 实现时踩过一次,已修); -- **排除**:`auto_managed = false`;`attempt_status in (pending, running)`(正在被批次处理);最近 10 分钟内刚检测过(防重入); -- **动作**:调用现有 `check_douyu_cookie()`(两个只读接口,直连),写回 `ck_check_status / ck_check_result / ck_checked_at`; -- **⚠️ 三类判定(关键防护)**:检测失败不等于 CK 失效,结果必须分三类处理,否则斗鱼接口抖动一小时就能把上千个好账号标成失效、让客服误判成「CK 大面积挂掉」: - - | 判定 | 依据 | 处理 | - | --- | --- | --- | - | `valid` | 两个接口都通过 | 写 `ck_check_status='valid'` | - | `invalid` | 斗鱼**明确**回「未登录 / 登陆异常 / 请重新登陆」 | 写 `ck_check_status='invalid'` | - | `error` | 其余失败(`请求失败:` 超时、`响应异常`、未知结构、走异常兜底) | **不覆盖该行原结论**,只更新 `ck_check_result`(带 `verdict`)与 `ck_checked_at`,计入 `skipped` | - -- **自动暂停**:一轮里 `error` 占比 > 30% 且条数 ≥ 20 时,作业把自己 `enabled` 置 false 并在 `message` 写明原因(`detail.paused=true`)—— 大面积「查不到」通常是接口抖动或被风控,继续跑只会污染更多账号; - > 注意:**真实死号占比高不触发暂停**。首轮 100% `invalid` 是合法且预期的情况(选行是「从没检测过的优先」,积压的多是两个月前批量导入、从未登录的陈旧账号)。 -- **并发**:默认 5,沿用现有 `ThreadPoolExecutor` 模式; -- **不做**:不改 CK、不建重登批次、不动 `login_tasks.status`。 - -**容量测算(真实数据,2026-10-02 实测)** - -| 指标 | 数值 | -| --- | --- | -| 持有 CK 的 `login_tasks` 行 | **12,881**(12,880 个账号;总账号 13,529) | -| 当前状态分布 | valid **10,079** / invalid **905** / **从未检测 1,897** | -| 历史检测时间 | 最早 2026-08-07,最新 2026-10-02(人工零星跑的) | - -按「单账号 6 小时内重检一次」折算: - -``` -需要吞吐 = 12,881 ÷ 6h ≈ 36 行/分钟 -一个 tick = 5 分钟 → 每轮需要 ≈ 180 行 -单行耗时 ≈ 0.13s(实测 3 个账号 0.4s,并发 3)→ 180 行并发 5 约 5 秒/轮 ← 很轻松 -``` - -**`batch_size` 默认 200**(比需要的 180 留一点余量): - -- **冷启动**:全库积压 12,881 条,按 200 条/5 分钟 ≈ **5.4 小时**跑完第一遍,随后进入稳态; -- **稳态**:每分钟约 36 条到期,每轮扫到一百多条,负载很轻; -- 取行按 `ck_checked_at ASC`(最久没查的优先,`NULLS FIRST`),自然错峰,不会同一时刻全量到期。 - -### 7.4 P1b · 作业二:`ck_relogin`(自愈,默认关闭) - -> **这一节 2026-10-02 按线上实测数据重写过。** 原来的设计只有「退避 + 熔断 + 开关」, -> 数据表明那样不够:**失败必须分型**,否则 64% 本可自动解决的失败会被当成人工活。 - -#### 7.4.1 为什么必须和 P1a 联动(数据) - -巡检只产出「谁失效了」的清单,不兑现修复,1,000+ 个账号还是要人工逐个选集→点重登→过滑块。 -线上实测(2026-10-02): - -| 项 | 数值 | 依据 | -| --- | --- | --- | -| 当前失效 | **1,356**(还在涨,巡检在排空积压) | `ck_check_status='invalid'` | -| 其中「还没在 attempt_* 时代重试过」 | 941 | 纯新机会 | -| 其中「最近一次重登失败过」 | 415 | 已有失败记录 | -| 历史重登**真实成功率** | **≈78%** | 成功 1,767 ÷ (1,767 + 真实失败 508),已排除「中断/停止」这类非真失败 | -| 预计可自动救回 | **≈1,050** | 1,356 × 78% | -| 预计需人工 | **≈300** | 主要是极验滑块 | - -**关键结论:失效是「会话级」而不是「凭证级」。** 斗鱼对这批账号回的是「用户未登录」, -不是「密码错误」;而这些行**本来都是登录成功的产物**(见下)。 - -> **「持有 CK 的行 = 真实登录成功过」的依据**:`LoginTask` 全项目**只在一处创建** -> —— `services/login_service.py`(登录成功写回 CK 处);**导入账号不建 LoginTask、 -> 不写 cookie**(已 grep 核实)。因此 `cookie` 非空且 `status='success'` 的行, -> 只可能是某次真实登录成功留下的。那批 `attempt_status=''` 不是「从未登录」, -> 而是「登录成功发生在 `attempt_*` 字段存在之前」(字段是迁移 `20261001_0035` 才加的)。 - -#### 7.4.2 候选筛选 - -- 每 5 分钟 tick,取满足以下条件的行,最多 `batch_size`(默认 20)个: - `ck_check_status = 'invalid'` 且 `relogin_next_at <= now`(或为空) - 且 `relogin_attempts < max_attempts_per_day`(默认 2) - 且 `auto_managed = true` 且 `attempt_status` 不在途; -- **不再按「有没有成功过」筛**:持 CK 的行按定义都成功过,这个条件没有区分度; - 真正有区分度的是「上一次为什么失败」(下一步的分型)。 - -#### 7.4.3 失败分型(本次新增的核心) - -重登失败不能笼统地「再试一次」或「丢给人工」。按原因落 **`login_tasks.relogin_failure_kind`** -(枚举字段,新增迁移;**不要靠 `attempt_message` 做字符串匹配** —— 文案一改就失效, -且页面要按类型筛选),不同分型走不同后续: - -| kind | 触发(线上实测条数) | 后续动作 | -| --- | --- | --- | -| `captcha` | 极验滑块失败(144 / 35%) | **转人工队列,不再自动重试**(反复撞只会加重风控) | -| `email_code` | 验证码错误 / 等待超时(144 / 35%) | **自动重试**(走退避表);这批多半正是 `20261001` 修掉的取码 bug 造成的 | -| `interrupted` | 服务重启 / 人工停止(121 / 29%) | **不是真失败** → 自动重试 | -| `email_quota` | 「发送次数已达上限」 | 退避到**次日零点 + 随机抖动 ±30 分钟**(决策 1:只影响该账号,批次继续) | -| `credential` | 密码错误 / 账号异常 | **转人工队列**(需核对密码或邮箱) | -| `network` | 请求失败 / 超时 / 连接异常 | 自动重试(退避) | - -> 64% 的线上「失败」(`email_code` + `interrupted`)其实是可自动解决的;不分型就会把它们 -> 一起推给人工,白费 265 个可救名额。 - -#### 7.4.4 退避与熔断 - -- **退避**:`relogin_attempts` 1→1h、2→3h、3→12h、4+→24h(写 `relogin_next_at`); -- **极验即停**:同一账号连续 2 次 `captcha` → 直接转人工,不再自动重试; -- **系统性故障刹车(与决策 1 不冲突)**:代理 API 不可用 / 单轮失败率超阈值(默认 80%) - / 单轮异常数超阈值 → **自动暂停作业并告警**。这些不是单账号问题,继续跑只会浪费资源。 - -#### 7.4.5 速率与风控 - -| 约束 | 实测/建议 | -| --- | --- | -| 邮箱配额 | **13,618 个账号对应 13,618 个独立邮箱(1:1,实测)** → 单账号配额满不影响他人,决策 1 成立;也不存在「打爆共享邮箱」的问题 | -| IP 才是瓶颈 | 1,356 次登录 + 1,356 封验证码,短时间集中做会触发风控 → **走现有代理池 + 限速每小时 30~60 个**(约 1~2 天跑完,后台慢慢来完全可以接受) | -| 单轮上限 | `batch_size` 默认 20(5 分钟一轮 = 上限 240/小时,实际由退避与每日上限压住) | - -#### 7.4.6 页面呈现(对应「失败单独标记提示」) - -`/automation` 页在作业卡片之外增加两块: - -- **自动重登中**:有退避倒计时、下次重试时间、当前 `relogin_attempts`; -- **待人工处理**:按 `relogin_failure_kind` 分组 —— - - 极验失败(要人工过滑块) - - 凭证问题(要核对密码/邮箱) - - 多次失败(要人工查账号可用性) - -#### 7.4.7 上线方式(三段,同 P1a 的谨慎程度) - -1. **小规模实测** ✅ **已完成(2026-10-02),结果远好于预期**: - - | 项 | 结果 | - | --- | --- | - | 样本 | **20 个** invalid,两组各 10:A「还没重试过」/ B「取码 bug 时代失败过」 | - | 成功率 | **20 / 20 = 100%**(历史推断只有 78%) | - | 耗时 | 2 分 13 秒(≈ 9 个/分钟) | - | 极验滑块 | **0 次** | - | 邮箱取码 | 全部正常 → **`20261001` 的取码修复在生产验证通过**(B 组全过,证实那 144 个「验证码失败」就是旧 bug 造成的) | - | CK 替换 | 20 个全部换成新 CK;`ck_check_status` 由登录流程自动改回 `valid`(`login_service.py` 成功分支) | - - **结论**:会话确实只是过期,重登几乎必定成功;可救量从估的 1,050 上调到 - **≈1,350**(1,356 × 近 100%)。失败分型仍然要做,但属少数路径(极验/配额/凭证)。 - 实测 9 个/分钟且零极验,说明斗鱼当前风控容忍度比预想高,不过全量仍建议限速 - (先按每小时 100~200 个开,观察极验率再决定是否加速); -2. **限量开作业**:按实测成功率定 `batch_size` 与日上限,先只跑一个分组; -3. **全量**:观察若干轮,确认分型与退避符合预期后再放开。 - -### 7.5 并发与互斥 - -- 同一账号不允许人工与自动同时操作:依据 `attempt_status` 在途 + 现有 `cookie_relogin_registry`; -- 作业级:进程内 `threading.Lock` 保证同一作业不会重叠执行; -- 批次级:`ck_relogin` 一轮只起一个 `LoginBatchRunner`,与人工批次并存但目标账号互不重叠。 - -### 7.6 接口与权限 - -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| GET | `/api/automation/jobs` | 列出作业与配置、最近一次结果 | -| PATCH | `/api/automation/jobs/{key}` | 改开关 / 间隔 / 批量大小 / 并发 | -| POST | `/api/automation/jobs/{key}/run` | 立即跑一轮(手动触发) | -| GET | `/api/automation/runs` | 运行历史(分页、按作业过滤) | - -权限点:`automation:view`(运营/超管)、`automation:manage`(仅超管,见 §11.1)。 - -### 7.7 前端 - -新页 `/automation`:作业卡片(开关 / 间隔 / 上次与下次运行 / 最近结果统计)、运行历史表格、手动「立即跑一次」。 -Dashboard 增加一张「自动化」卡片(今日巡检数、无效数、自愈成功数、待重登数)。 - -### 7.8 验收标准 - -**P1a** - -1. 人为让一批账号 CK 失效 → 一个巡检周期内被检出并标记 `ck_check_status='invalid'`; -2. 巡检过程中**没有任何 CK 被改动**(可对比巡检前后的 `login_tasks` 行); -3. 关闭作业开关后立即停止调度; -4. `/automation` 能看到每一轮的明细(扫描数 / 有效 / 无效 / 跳过)。 - -**P1b** - -5. 开启自愈后,失效账号按退避表排队重登,不会每轮都撞同一个账号; -6. 命中邮箱配额上限时,只有该账号被跳过并退避到次日,其余账号正常完成; -7. 连续失败率超阈值时作业自动暂停并留下 `automation_runs` 记录。 - -### 7.9 顺带修 - -给 `POST /api/login/batch` 补审计记录(对齐 `cookie:relogin` 等已有动作),否则自动化与人工的登录来源无法追溯。 - -## 8. P2 · 资料收集链接 - -### 8.1 目标 - -客服把一条链接发给用户,用户填**订单号 + 游戏编号 + 游戏昵称 + 主页截图**,信息直接进系统;**皮肤由订单号自动对应**(决策 8)。 - -### 8.2 数据模型(迁移 1 个) - -**`collect_links`**:`id` / `token`(唯一,32 字节随机 base64url) / `account_id`(可空) / `created_by` / `title` / `expires_at` / `max_submissions`(默认 1) / `submissions_count` / `status`(open/closed/expired) / `created_at` / `closed_at` - -**`collect_submissions`**:`id` / `link_id` / `account_id` / `order_no`(归一化后) / `order_snapshot`(JSON,来自 §6) / **`fields`(JSON:游戏编号 / 游戏昵称)** / **`screenshots`(JSON:主页截图路径数组)** / `remark` / `status`(pending/processing/done/rejected) / `handled_by` / `handled_at` / `ip` / `user_agent` / `created_at` - -> 用户只填这三样。**唯一命中的订单**皮肤由订单号自动带出,提交记录只存订单快照。 -> **用户"选一件"的结果不放在这里**,而是放在工单的 `exchange_orders.chosen_commodity_id` 上(见 §10.3/§10.4)—— 提交记录是用户视角的原始凭证,不该被后续业务流程改写。 - -**截图存储**:**阿里云 OSS**(不落本地磁盘),DB 只存 `object_key`;读取走后端鉴权代理。详见 §8.3。 - -### 8.3 图片存储:OSS(不落本地磁盘) - -**服务器磁盘有限,用户上传的图片一律进 OSS,本地只做内存处理(Pillow 从 bytes 读,不写临时文件)。** - -参考 order_site 的成熟做法(实测): - -| 环节 | order_site 的做法 | 我们照做 | -| --- | --- | --- | -| 桶 | 阿里云 OSS `order-site-assets`(**私有**,region `oss-cn-hangzhou`) | **`live-hub-assets`** —— ✅ **已建好**(私有 ACL + 版本控制已开,内网端点,实测 put/get/list/delete 全通) | -| 端点 | 上传走内网 `oss-cn-hangzhou-internal.aliyuncs.com` | 同(实测容器可达:`100.118.28.52`,无公网流量费) | -| 存什么 | DB 只存 `object_key`,**不存 URL** | 同(换域名/换桶不用改数据) | -| 怎么读 | 自己的后端代理 `/api/v1/files/object?key=...`,带 ETag / Last-Modified / 304 | `GET /api/collect/submissions/{id}/screenshots/{idx}`,鉴权 + 流式返回 + 304 | -| 变体 | `sharp` 生成 thumb(480) / medium(1280) | Pillow 生成缩略图(列表页用),原图保留 | - -**落地要点** - -1. **桶权限:私有**(不开公共读)—— ✅ 已实测:公共 URL 直取返回 **403**。既然读走后端代理,就不需要公网端点、公开 ACL 或签名 URL,攻击面最小; - - **凭据已就位**:独立 RAM 子账号 `live-hub-oss` + 策略 `live-hub-assets-rw`,实测跨桶访问被拒(最小授权); - - **实现注意**:`object_storage.py` 只用 `PutObject` / `GetObject` / `DeleteObject` / `ListObjectsV2`,**不要用 `HeadBucket`**(该策略下返回 403),健康检查改用 ListObjects 或 Put/Get 往返; -2. **object key 规范**:`collect//.`,缩略图 `collect//_thumb.`; -3. **上传前处理**:校验 magic bytes → 限制 ≤5MB / ≤3 张 → Pillow **重新编码**(剥 EXIF、防伪装)→ 生成缩略图 → 两张都传 OSS; -4. **鉴权读取**:读图接口挂登录依赖 + 数据范围过滤(决策 7:归属客服 + 运营/超管),不允许无鉴权直读; -5. **凭据**:建议新建 **RAM 子账号**,只授权这一个桶的 `PutObject` / `GetObject` / `DeleteObject`,不复用 order_site 的 AK; -6. **不落地**:`UploadFile` 读进内存即可,**不写 `./data`**,容器磁盘只用于日志; -7. **清理**:给桶配生命周期规则(如转低频/过期删除)由运维侧决定;本轮不做应用层删除。 - -> Python 侧用官方 `oss2`(需新增依赖,当前环境未安装);不用 boto3,省掉 S3 兼容层的坑。 - -### 8.4 订单号处理(决策 5 + P0 整合) - -1. 归一化:去掉首尾空白 + 去掉所有空白字符(含全角空格 `\u3000`)→ 非空校验; -2. **查单**:走 P0 适配层拿 `sku_code / sku_name / order_status / route`,写入 `external_order_cache` 与提交记录的快照(准入规则见 §6.4); -3. **唯一性**:`order_no` 全库唯一(重复直接拒绝并提示「该订单号已提交过」)。不做格式校验。 - -> 假设:不同平台订单号不会撞号。若将来会撞,改为 `(order_no + platform)` 组合唯一。 - -### 8.5 接口 - -**公开(无鉴权,独立前缀 `/api/public/collect`)** - -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| GET | `/api/public/collect/{token}` | 链接状态、资料模板字段、已提交时的处理状态 | -| POST | `/api/public/collect/{token}` | multipart 提交(订单号 + 游戏编号 + 游戏昵称 + 主页截图) | - -> **为什么单独开 `/api/public/` 前缀**:① 若公开接口用 `/api/collect/{token}`,会把同级的 `/api/collect/links` 当成 `token="links"` 吞掉(FastAPI 按声明顺序匹配参数化路由);② 无鉴权边界写在 URL 上,一眼能审出哪些路由不该挂登录依赖。 - -**内部(需登录,前缀 `/api/collect`)** - -`POST /api/collect/links`(生成)、`GET /api/collect/links`、`POST /api/collect/links/{id}/close`、`GET /api/collect/submissions`(按范围过滤)、`GET /api/collect/submissions/{id}/screenshots/{idx}`(鉴权读图,见 §8.3)、`POST /api/collect/submissions/{id}/accept|reject` - -> 现有代码**没有全局鉴权中间件**,鉴权是逐路由 `Depends(get_current_user)` —— 所以公开路由只要不挂依赖即可;新路由记得在 `web/backend/main.py` 里 `include_router`。 - -### 8.6 防护(公开入口必须做) - -- token 32 字节随机、有效期默认 7 天、提交次数上限; -- 每 IP 限流(内存令牌桶,默认 10 次/分钟); -- 文件:仅 png/jpg/webp、单张 ≤ 5MB、最多 3 张、校验 magic bytes,并用 Pillow **重新编码**(剥离 EXIF、防伪装文件),随后直传 OSS(§8.3,不落本地盘); -- 公开接口只回显最少信息(不暴露账号密码、CK、内部备注);截图读取一律走后端鉴权接口,不给 OSS 公共读。 - -### 8.7 前端 - -- **公开页** `/collect/:token`: - - **P2 阶段**:订单号 → 显示**订单商品名**(来自订单快照,仅作核对,不做皮肤匹配——那时还没有别名表)→ 游戏编号 → 游戏昵称 → 主页截图 → 提交。 - - **P4 阶段**:在 P2 基础上增加皮肤匹配显示 —— 唯一命中时展示「你买的是:套装-暗影哥特」让用户确认;`skin_choice`(多选)时展示候选列表让用户选一件(§10.4)。 - 已提交过则显示状态(**决策 3**:不做复杂回执,但兑换成功后用户打开同一链接能看到「已兑换 XX 皮肤 + 时间」)。 - **链接在 `status=open` 期间可反复打开**(查看状态不算一次提交,不消耗 `max_submissions`)—— 用户没保存链接时,客服转发原链接即可;这是"用户能回来看到结果"的实现前提; - 该路由必须放在 `App.tsx` 登录判断**之外**,SPA fallback 已支持; -- **内部页**:`/collect-links`(链接管理:生成 / 复制 / 关闭)、`/collect-submissions`(待处理队列 + 大图查看 + 受理 / 驳回)。 - -权限点:`collect:link`、`collect:handle`;可见范围按决策 7。 - -### 8.8 验收标准 - -1. 手机打开链接能完成填写与上传,提交后客服端队列立即出现; -2. 同一订单号二次提交被拒绝; -3. 超过有效期 / 超过提交次数 / 非图片文件 / 超大文件都被正确拒绝; -4. 上传的图片**只存在于 OSS**,容器与宿主机磁盘上找不到任何副本(可 `find` 验证); -5. 未登录访问读图接口返回 401/403;客服只能看到自己范围内账号的截图; -6. OSS 不可用时提交给出明确失败提示,不产生"半条记录"; -7. 兑换成功后,用户用原链接能看到结果。 - -## 9. P3 · 账号皮肤台账 - -### 9.1 目标 - -知道「哪个账号、什么时间、兑换了什么皮肤」,并在兑换前拦住「365 天内重复兑换同一件皮肤」。 - -### 9.2 数据模型(迁移 1 个) - -**别名归一(本阶段的核心):以斗鱼商品为锚点** - -| 表 | 字段 | 说明 | -| --- | --- | --- | -| `skin_catalog` | `commodity_id`(PK,斗鱼商品 ID) / `manual_id` / `name` / `points` / `category`(suit/helmet/bag/weapon/pack/other/**non_skin**) / `active` / `updated_at` | **斗鱼商品目录是主表**。365 天规则的判定键就是它 | -| `skin_aliases` | (`source`, `raw_key`) 联合 PK / `commodity_id`(索引) / `confidence` / `updated_at`;`source ∈ {kuaishou_rel_sku_id, kuaishou_rel_item_id, kuaishou_sku_nick, manual}` | 快手 SKU / item / 规格名 → 斗鱼商品(**优先 SKU 精确,名称只兜底**,见 §3.6) | -| `skin_keywords` | `keyword`(PK) / `commodity_id`(索引) / `updated_at` | 归一层用的核心词(当前 30 个),用于在「小店自选 / 3选1 / 堆词标题」里识别候选商品 | - -> 依据 §3.3 实测:`sku_code`(数字型,易变)、`sku_name`(中文,有 `套装-` 前后缀变体)、语义化 code(`suit_*`)都只是**别名**,必须归一到 `skin_key` 才能正确判「同一件」。 -> **规模(已跑完分析,见 [皮肤准入映射分析](皮肤准入映射分析.md))**:不是梳理 923 或 615 个快手标准名,而是维护 -> **60 个斗鱼商品 ← 76 个快手商品名**的映射 —— 工作量从「数百条」降到「几十条」,人工过一遍即可。 -> 其中 62 个快手名唯一命中(订单可自动定皮肤)、14 个命中多个(需用户选)。 - -**与 `douyu_goods_snapshot` 的关系(同步策略)** - -| 项 | 约定 | -| --- | --- | -| 同步方向 | `douyu_goods_snapshot` **→** `skin_catalog`(单向;snapshot 是抓取结果,catalog 是我们的可用目录) | -| 存多少 | **全部 78 行**都进 catalog(非皮肤 18 个标 `category=non_skin`、`active=false`);准入只看 `active=true` 的 | -| 何时同步 | **新增同步作业挂到 P1 的调度器上**(`sku_catalog_sync`,默认每天一次),不引入新机制;也可在 `/skins` 页手动触发 | -| 冲突处理 | `commodity_id` 已存在 → 只更新 `name`/`points`;**不覆盖人工维护的 `category`/`active`**;snapshot 里消失的商品 → 标 `active=false`,**不物理删除**(历史台账要能查到) | - -**`account_exchange_records`(台账)**:`id` / `account_id`(索引) / `skin_key`(索引) / `goods_name` / `exchange_time` / `source`(our_task / douyu_api / manual / order) / `order_no`(订单号, 可空) / `sku_code` / `sku_name`(订单快照) / `task_id`(可空) / `role_name` / `remark` / `raw`(JSON) / `created_at` -唯一键:`(account_id, order_no)`(有订单号时)防重复导入。 - -### 9.3 四路数据源 - -1. **我方兑换任务成功时自动落账**(最准,带账号/时间/商品/任务/角色);斗鱼商品先经 `skin_douyu_items` 映射到 `skin_key`; -2. **斗鱼侧 `exchange_records` 回填**(复用 `query_exchange_records`,解析 `goods_name / buy_time / serial` 后 upsert); -3. **订单驱动落账**(P4 工单执行成功时,直接用订单快照里的 `sku_code/sku_name` 归一后落账); -4. **人工补录**(带截图,用于查不到的历史)。 - -### 9.4 规则引擎 - -`can_exchange(account_id, skin_key, within_days=365)`: - -- 取该账号该 `skin_key` 最近一条 `exchange_time`; -- 若 `now - exchange_time < 365 天` → 阻止,并给出「上次兑换 2026-03-01,2027-03-01 后可再兑」; -- 超管可用 `force_reason` 强制通过(**决策 6**),写审计。 - -**归一流程**(订单号进来的皮肤): -`sku_code / sku_name` → 查 `skin_aliases` → 命中则用 `skin_key`;**未命中则挂起待人工归类**(工单进 `rule_unmatched`,见 §10.4;**不静默放行**,避免 365 天规则被绕过)。 - -接入点: - -- 兑换**前**:工作台里选中账号 + 商品时校验并提示(前端禁止 + 后端接口二次校验); -- 兑换**后**:任务成功即写台账; -- 工单**受理时**:用订单快照里的 `skin_key` 再校验一次。 - -### 9.5 前端 - -新页 `/skins`: - -- 皮肤视角:某皮肤被哪些账号兑换过、最近一笔时间、还剩多久可再兑; -- 账号视角:某账号的兑换时间线(含截图); -- 检索与导出;`skin:manage` 可维护别名映射。 - -权限点:`skin:view`、`skin:manage`。 - -## 10. P4 · 皮肤兑换工单(模板 + 匹配 + 状态机) - -设计思路**整体对齐 order_site 接单平台**:同样是「订单进来 → 按规则模板匹配 → 命中才建单 → 用户补资料 → 执行 → 归档」,只是把「打手抢单」换成「客服执行」。 - -### 10.0 参考对象:接单平台的六层机制 - -| 层 | 组件 | 关键设计 | -| --- | --- | --- | -| ① 规则/模板 | `work_categories`、`work_product_rules`、`work_product_rule_mappings` | 规则带 `enabled` / `auto_create` / 来源约束 / **`requirement_json.fields`(资料模板)** / 报酬 / 超时策略 / 验收模式;映射分 `product_default` / `sku_exact` / `sku_series` 三种粒度 | -| ② 匹配 | `resolveMatchingProductRuleDecision()` | 打分 → 排序 → **只有唯一最高分才算命中**;并列判 `ambiguous` 绝不猜;匹配上下文取自订单快照;结果与候选写 `work_product_match_logs` | -| ③ 建单 | `syncWorkerOrdersForSourceOrder()` | 未命中 → 只写日志不建单;命中但 `auto_create=false` → 跳过;校验订单号跨订单唯一(防撞号);**初始状态由模板决定**:有必填 → `pending_material`,无必填 → `unassigned`;`material_json` 存来源订单快照;`requirement_json` 存模板快照 | -| ④ 状态机 | `WORK_ORDER_STATUS` | `pending_material` → `unassigned`/`open` → `in_progress` → `pending_acceptance` → `accepted`;旁路 `problem`/`cancelled`;配 `deadline_at` + `timeout_policy`;`acceptance_mode` 分 `standard`/`friend_gift` | -| ⑤ 补资料 | `/api/collect/lookup · submit · upload` | 凭订单号找 `pending_material` 工单;提交写 `material_json.collect = {fields, screenshots, submittedAt, complete}`;**状态回落**:`complete ? 下一态 : pending_material` | -| ⑥ 事件与实时 | `work_order_events` + realtime + 通知 | 每次状态变更写 `from_status → to_status + payload`;实时推送;建单时给管理端发通知 | - -**模板的两种编辑方式**(直接抄): - -```jsonc -// 结构化 -"requirement_json": { "fields": [ - { "key": "gameId", "label": "游戏编号", "required": true, "type": "text", "options": [] }, - { "key": "system", "label": "系统", "required": true, "type": "select", "options": ["安卓","苹果"] } -]} -``` - -``` -# 文本(一行一个字段:key:label#选项1,选项2) -gameId:游戏编号 -system:系统#安卓,苹果 -``` - -**我们的模板固定三个字段**(决策 8): - -``` -game_id:游戏编号 -game_nickname:游戏昵称 -homepage:主页截图#image -``` - -比接单平台**多一个 `image` 字段类型** —— 他们的类型只有 `text`/`select`,截图是单独一个 `screenshots` 数组;我们把图片也做成模板字段,这样「必填校验」统一走一套逻辑,不用给截图开特例。 - -### 10.1 规则/模板层(新增 2 张表) - -**`exchange_skin_rules`(业务配置,一个斗鱼商品一条规则)** - -| 字段 | 说明 | -| --- | --- | -| `id` / `rule_key`(唯一) | | -| `commodity_id`(唯一, 索引) | 指向 `skin_catalog.commodity_id` —— **规则直接挂在斗鱼商品上** | -| `enabled` / `auto_create` | 是否参与准入 / 命中后是否自动建单 | -| `required_points` | 预计消耗积分(用于前置校验) | -| `cooldown_days` | 冷却天数,默认 **365** | -| `requirement_json` | **资料模板**(默认 `游戏编号` / `游戏昵称` / `主页截图`,三项均必填) | -| `sort_order` / `remark` / `created_at` / `updated_at` | | - -> **没有单独的映射表** —— 原先打算照抄接单平台的 `exchange_skin_rule_mappings`,但它与 §9.2 的 `skin_aliases` 职责重叠(两条链路都能把订单映射到同一个斗鱼商品),实现时会反复纠结「这条映射该写哪张表」。 -> 接单平台要 rule + mapping 两层,是因为它要在**多个店铺/多个接单模板**之间路由;我们只有**一个数据源(快手)**,不需要。 -> -> **最终分工**:商品身份识别统一交给 `skin_aliases` / `skin_keywords`(§9.2),规则表只挂业务配置 —— **一张表一件事**。 -> 连带好处:**`rule_ambiguous` 状态也不需要了**(`skin_aliases` 主键 `(source, raw_key)` 天然一对一,不会出现多条规则并列),候选复数只剩「小店自选」一类,走 `skin_choice`。 - - -**`exchange_match_logs`(匹配诊断)**:`order_no` / `rel_sku_id` / `sku_nick` / `item_title` / `match_status`(`matched` / `multi_match` / `unmatched`) / `commodity_id` / `candidates`(JSON) / `created_at` - -### 10.2 匹配层 - -```python -resolve_skin(order_snapshot) -> { commodity_ids: list[str], reason: matched|multi_match|unmatched, source: alias|keyword, rule | None } -``` - -- 输入:订单快照的 **`(sellerId, relItemId, relSkuId, skuNick, itemTitle)`**(直接来自 `spec_json`,§3.6); -- **第一步:解析候选商品**(`skin_aliases` / `skin_keywords`)—— 匹配顺序从精确到宽泛:**`rel_sku_id` 精确 → `sku_nick` 精确 → `sku_nick` 系列前缀 → `rel_item_id` → `item_title` 包含**; -- **第二步:取业务配置**(按命中的 `commodity_id` 取 `exchange_skin_rules`:模板、积分、冷却、启用状态)—— 分工见 §10.1; -- **候选 1 个且规则 `enabled` → `matched`**(皮肤由订单自动确定); -- **候选多个 → `multi_match`**(「3选1 / 小店自选」类):工单进 `skin_choice`,让用户在链接里选一件(决策 8 例外,占 12.4%); -- **候选 0 个,或规则 `enabled=false` → `unmatched`**(斗鱼兑不了 / 我们不接,占 59.9%)→ 拒绝、不建单; -- **没有 `ambiguous`**:`skin_aliases` 主键 `(source, raw_key)` 天然一对一,不存在多条并列(§10.1); -- 匹配结果**每次都要落日志**(命中 / 多选 / 未命中都写),这是排障的唯一依据。 - -### 10.3 工单模型(新增 2 张表) - -**`exchange_orders`(工单)** - -| 字段 | 说明 | -| --- | --- | -| `order_no`(唯一) / `order_snapshot`(JSON) | 来源订单 + 快照 | -| `chosen_commodity_id`(可空) | **最终选定的斗鱼商品** —— 多选类由用户在链接里选、或客服代选(决策 8 例外的落点) | -| `submission_id`(可空, 索引) | 来源的 `collect_submissions.id` —— **P2 与 P4 的衔接点**(见 §10.5) | -| `sku_code` / `sku_name` / `skin_key` | 皮肤标识(`skin_key` 可空,未归类时为空) | -| `rule_id` / `requirement_json` | 命中的规则(按 `commodity_id` 取)+ **模板快照**(模板后续改动不影响历史工单) | -| `account_id` / `assignee_id` | 执行账号(客服选)+ 归属客服 | -| `status` | 见 §10.4 | -| `material_json` | 用户提交内容 + 截图路径 + 提交时间(对齐接单平台 `material_json.collect`) | -| `eligibility_check`(JSON) | 前置校验结果:CK 状态 / 365 天规则 / 角色绑定 / 积分 | -| `task_batch_id` / `result`(JSON) | 关联的斗鱼任务批次与执行结果 | -| `force_reason` | 超管强制兑换原因 | -| `deadline_at` / `finished_at` / `created_at` / `updated_at` | | - -**`exchange_order_events`(事件流水)**:`order_id` / `actor_type`(`collect` / `support` / `system`) / `actor_id` / `event_type` / `from_status` / `to_status` / `payload`(JSON) / `created_at` - -### 10.4 状态机 - -> **工单的创建时机**:① 用户通过链接提交后自动建单(主路径);② 客服手动建单(不走链接的特殊情况)。 -> 「客服生成链接」本身**不建工单** —— 链接是 `collect_links`,用户没提交时没有工单行,所以状态机里**没有 `draft`**。 - -``` -pending_material(待用户补资料) ← 模板有必填字段时的默认入口 - → pending_match(订单已提交,待匹配皮肤) - ├─ rule_unmatched(皮肤映射不到斗鱼商品)→ 人工归类或拒绝 → 回 pending_match - ├─ rule_unmatched(映射不到斗鱼商品 / 规则未启用)→ 人工归类或拒绝 → 回 pending_match - └─ skin_choice(命中多个斗鱼商品,等选一件)→ 选定 chosen_commodity_id → 回 pending_match - → ck_repairing(账号 CK 无效 → 等重登;**P1b 已启用则自动重登,未启用则等人工点重登**) - → ineligible(前置校验不过:365 天规则命中 / 积分不足 / 角色未绑定) - └─ 超管 force_reason 强制 → 回 ready;否则取消 - → ready(前置校验全过,可执行) - → running(兑换执行中,关联斗鱼任务批次) - → done(已完成,已写台账) - 旁路:problem(执行异常待处置)/ cancelled(取消) -``` - -> **`ineligible` 与 `ck_repairing` 的区别**:`ck_repairing` 是**能自动修复**的(等 CK 重登好),`ineligible` 是**需要人决策**的(365 天命中就摆在那儿,只能超管强制或放弃)。两者都不能直接进 `running`。 - -补充规则(对齐接单平台): - -- **初始状态由模板决定**:模板有必填 → `pending_material`;全可选 → 直接进 `pending_match`; -- **状态回落**:用户补资料不完整(缺必填)时状态**退回** `pending_material`,并写事件; -- **超时**:`deadline_at` + `timeout_policy`,我们只需要 `reopen`(回 `ready`)和 `cancel` 两种; -- **不做**接单平台的 `open`(抢单)/ `unassigned`(派单池)/ `pending_acceptance` 复杂分支 —— 我们是一对一客服,「执行完成即 done」,异常统一进 `problem`; -- **`skin_choice` 怎么选**(决策 8 例外的落地方式):工单进 `skin_choice` 后,客服把(同一个)链接再发给用户 —— 链接在 `status=open` 期间可反复打开,此时公开页展示候选列表让用户选一件;用户不方便时客服可在工单页**代选**。两种情况都写 `chosen_commodity_id` 并写事件。 - -### 10.5 补资料流程(对齐 `/api/public/collect/*`) - -| 步骤 | 行为 | -| --- | --- | -| 用户打开链接 | 已有 `order_no` 时直接查;没有则输入订单号 | -| 查订单 | P0 适配层 `lookup_order()` → `route` 判定;`route ≠ unhandled` 直接拒绝(§6.4) | -| 匹配规则 | `resolve_skin_rule()` → 命中则带出皮肤与资料模板;未命中 / 歧义 → 进对应待处理状态 | -| 渲染表单 | 按 `requirement_json.fields` 动态渲染:`text`→输入框、`image`→上传(预览 + 删除,单张 ≤5MB,Pillow 重编码) | -| 提交 | 校验必填(`image` 字段看路径数组非空)→ 写 `material_json.collect = {fields, screenshots, submitted_at, complete}` → 状态流转 + 写事件;**不完整则退回 `pending_material`** | -| 存图 | **直传 OSS**(`collect//.`,§8.3),DB 只存 `object_key`;**不落本地磁盘** | -| 多件商品 | 我们 **1 单 = 1 件商品**(实测 271,024 单 1:1),不需要接单平台「多件要求指定 workOrderId」的分支 | - -### 10.6 前端页面(对齐接单平台三处界面) - -| 页面 | 参考实现 | 内容 | -| --- | --- | --- | -| `/exchange-rules` | `ProductRulesPanel.tsx` | 规则列表 + 模板编辑(**支持文本一行一字段**)+ 启用 / 自动建单开关 + 斗鱼商品绑定 | -| `/exchange-orders` | `WorkOrdersPanel.tsx` | 状态筛选、资料查看(含大图)、一键执行、备注、强制兑换(超管 + 原因) | -| `/collect/:token` | `CollectPage.tsx` | 动态字段表单 + 截图上传 + 提交后状态展示(与 §8.7 同一页面) | - -权限点:`exchange:view`(查看)、`exchange:manage`(改规则 / 执行工单 / 强制兑换,见 §11.1)。 - -### 10.7 与接单平台的差异(不照搬的部分) - -| 接单平台有 | 我们 | 原因 | -| --- | --- | --- | -| 抢单大厅 / 分享 / 多打手 / 派单池 | ❌ | 一对一客服执行 | -| 报酬、保证金、钱包、提现、等级、VIP | ❌ | 不涉及众包结算 | -| 验收草稿、申诉、售后、超时扣款 | ❌(简化) | 只保留 `problem` + 客服处置 | -| `friend_gift` 好友赠送冷却(默认 72h) | ❌ **不涉及**(决策 9) | 我们的兑换不需要加好友等待 | -| 资料是给打手看的 | ✅ 换成 | 资料是给客服操作游戏账号用(游戏编号 / 游戏昵称 / 主页截图) | -| —— | ➕ CK 健康检查(P1) | 接单平台没有账号池概念 | -| —— | ➕ 365 天规则(P3) | 接单平台没有跨订单冷却 | -| —— | ➕ 斗鱼商品映射与积分校验 | 接单平台直接对接发货平台 API | - -### 10.8 端到端流程 - -``` -客服选账号 → 生成收集链接 → 发给用户 - ↓ -用户填订单号 → P0 适配层查 order_site(route 判定:是否「没人管」)→ 匹配规则(皮肤做不做) - ↓ -按规则模板渲染表单 → 用户补游戏编号 / 游戏昵称 / 主页截图 → 提交(写 material_json) - ↓ -系统自动前置校验(后台作业): - ① skin_key 归一(归不到 → rule_unmatched,人工处理,不静默放行) - ② CK 是否有效 → 无效则挂 P1 自动重登,工单停在 ck_repairing - ③ 皮肤 365 天规则(P3)→ 命中则标记「不可兑换」并提示上次兑换时间 - ④ 角色绑定 / 积分是否足够(复用现有 query 任务) - 汇总写 eligibility_check - ↓ -工单变 ready → 客服一键「执行」→ 现有任务链(绑定 → 开通 → 兑换) - ↓ -二维码 / 结果回填工单 → 成功自动写台账(P3)→ 用户用原链接看到结果 -``` - -### 10.9 验收标准 - -1. 从「发链接」到「兑换完成」全程无需手工录数据; -2. 用户填的订单号能自动带出商品/皮肤并留快照; -3. 映射不到斗鱼商品(`unmatched`)时不建单;候选多个(`multi_match`)时必须先选定 `chosen_commodity_id` 才能继续;匹配日志能看出命中了哪些候选; -4. 用户资料不完整时状态退回 `pending_material`,补齐后继续; -5. CK 失效时工单自动进入 `ck_repairing`,修复后自动转 `ready`; -6. 命中 365 天规则的工单不能直接执行,超管可强制并留原因; -7. 每个状态变更都有 `exchange_order_events` 记录; -8. 完成后台账、用户链接、工单三处状态一致。 - -## 11. 横切关注点 - -### 11.1 权限点与默认角色 - -新增权限点,并给出建议的角色默认分配(沿用 `web/backend/permissions.py` 的 `ROLE_PERMISSIONS` 机制): - -| 权限点 | 说明 | super_admin | operation | support | -| --- | --- | --- | --- | --- | -| `automation:view` | 查看自动化作业与运行历史 | ✅ | ✅ | ❌ | -| `automation:manage` | 改配置 / 手动触发作业 | ✅ | ❌ | ❌ | -| `collect:link` | 生成收集链接 | ✅ | ✅ | ✅ | -| `collect:handle` | 受理 / 驳回提交 | ✅ | ✅ | ✅(仅自己账号,见下) | -| `skin:view` | 查看台账 | ✅ | ✅ | ✅(仅自己账号) | -| `skin:manage` | 维护皮肤别名与目录 | ✅ | ✅ | ❌ | -| `exchange:view` | 查看兑换规则与工单 | ✅ | ✅ | ✅(仅自己账号相关) | -| `exchange:manage` | 维护兑换规则、执行工单、强制兑换 | ✅ | ✅ | ❌ | - -> **数据范围靠过滤而不是权限点**:客服只能看到「分配给自己的账号」相关的提交与台账,实现方式与现有 `account:view_assigned` / `cookie:operate` 一致(查询时按 `accounts.assigned_to` 过滤)。 - -### 11.2 审计 - -| 动作 | 落点 | -| --- | --- | -| 自动化每轮执行 | `automation_runs` | -| 自动重登单个账号 | `audit_logs`(action=`cookie:auto_relogin`) | -| 人工发起登录批次 | `audit_logs`(**P1 顺带补**,action=`login:batch`) | -| 生成收集链接 / 用户提交 / 客服受理 | `audit_logs` + `collect_submissions` 状态 | -| 365 天规则**强制兑换** | `audit_logs`(含 `force_reason`、操作人) | -| C 桶订单**人工放行** | `audit_logs`(决策 10 要求的留痕) | - -### 11.3 配置项清单 - -| 配置 | 阶段 | 默认 | 说明 | -| --- | --- | --- | --- | -| `ORDER_SITE_DATABASE_URL` | P0 | — | ✅ 已写入生产 `.env`(角色 `livehub_ro`),不入仓库 | -| `ORDER_SITE_LOOKUP_ENABLED` | P0 | `false` | 总开关;关掉即退回手工流程(✅ 已写入生产 `.env`,当前为 false) | -| `ORDER_SITE_LOOKUP_TIMEOUT_SECONDS` | P0 | `3` | 只读查询 `statement_timeout`(✅ 已写入;DB 侧角色也已设 3s,双保险) | -| `AUTOMATION_ENABLED` | P1 | `false` | 调度器总开关 | -| `COLLECT_LINK_TTL_DAYS` | P2 | `7` | 收集链接有效期 | -| `COLLECT_UPLOAD_MAX_MB` | P2 | `5` | 单张截图上限 | -| `COLLECT_UPLOAD_MAX_FILES` | P2 | `3` | 单次提交截图张数 | -| `COLLECT_RATE_LIMIT_PER_MIN` | P2 | `10` | 公开接口每 IP 限流 | -| `OSS_ENDPOINT` | P2 | 内网端点 | `https://oss-cn-hangzhou-internal.aliyuncs.com`(上传走内网,无流量费) | -| `OSS_BUCKET` | P2 | — | ✅ `live-hub-assets`(已建:私有 + 版本控制),已写入生产 `.env` | -| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | P2 | — | ✅ RAM 子账号 `live-hub-oss`,只授权该桶;已写入生产 `.env` | -| `OSS_PREFIX` | P2 | `collect` | object key 前缀 | - -> 作业级参数(`interval_seconds` / `batch_size` / `concurrency` / 退避表 / 失败率阈值)走 `automation_jobs.config`,在 `/automation` 页面改,不写死在环境变量里。 - -### 11.4 数据保留与清理 - -| 数据 | 建议策略 | -| --- | --- | -| `automation_runs` | 保留 90 天,超期清理(**待确认**) | -| `exchange_match_logs` | 保留 180 天 | -| `external_order_cache` | 每条 24 小时后视为过期(过期重查,不主动删) | -| `collect_links` 过期记录 | 保留 90 天 | -| 用户上传截图(OSS) | **不自动删**(属业务凭证);生命周期与版本控制由 OSS 侧规则管理 | - -### 11.5 备份 - -目前只备份数据库。**截图改为 OSS 后(§8.3),不再需要本地备份** —— 图片的持久性与版本控制由 OSS 承担: -- 建议给 `live-hub-assets` 开启**版本控制**并用生命周期规则管理(转低频/过期清理由运维定); -- DB 依旧只备份数据库,但要注意 **DB 里的 `object_key` 与 OSS 对象是配套的**,误删 OSS 对象会导致台账"有记录、没图"。 - -### 11.6 用户可见文案(统一) - -| 场景 | 文案 | -| --- | --- | -| 订单号为空 | 请填写订单号 | -| 订单号重复提交 | 该订单号已提交过,请勿重复提交 | -| 订单不存在 | 订单号不存在,请核对后重试 | -| 已由发货平台处理(`auto_fulfilled`) | 该订单已由发货平台处理,无需在此提交 | -| 走接单平台(`worker_platform`) | 该订单由接单平台处理,无需在此提交 | -| 无人管但皮肤未命中(`unhandled` + 规则未命中) | 该订单暂不支持,请联系客服 | -| 皮肤待归类(内部) | 该商品未归类,请先在「兑换规则」里补充映射 | -| 365 天规则命中 | 该账号已于 2026-03-01 兑换过该皮肤,2027-03-01 后可再兑 | -| 资料不完整 | 还有必填项未填写,请补全后提交 | - -### 11.7 测试计划 - -- 每个阶段补 pytest,沿用现有基线(当前 235 条通过); -- **只读与纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数,重点覆盖; -- **写操作不纳入自动回归**:兑换、重登、支付类不跑真实请求,用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py` 的写法); -- 迁移脚本要走现有 `tests/test_migrations.py` 的链完整性 + SQLite 整链执行; -- 前端沿用 `tsc -b` + `npm run build` 门禁。 - -### 11.8 安全 - -- 公开接口最小回显 + 限流 + 图片重编码(见 §8.6 防护); -- 内部接口沿用现有鉴权与数据范围;新增接口一律挂 `Depends(get_current_user)` 或具体权限; -- order_site 只读账号权限最小化,只授 4 张表的 SELECT; -- 截图存**私有 OSS 桶**,只通过鉴权接口读取(不放进 `dist`,不给公共读、不发签名 URL); -- OSS 凭据走 `.env`,建议独立的 RAM 子账号 + 最小授权;不在日志里打印 AK/SK。 - -## 12. 里程碑与依赖 - -| 阶段 | 内容 | 依赖 | 粗估 | -| --- | --- | --- | --- | -| **P1a** | CK 巡检自动化(调度器骨架 + `ck_health` 只检测/只标记 + `/automation` 页 + 登录批次审计)**不改动任何 CK** | 无 | 2 人日 | -| **P0** | 订单读取适配层(只读 PG 账号 + `external_order_source.py` + `external_order_cache`) | 运维建只读账号 | 1~1.5 人日 | -| **P2** | 资料收集链接(公开页 + **OSS 图片直传** + 后端代理读图 + 队列 + 订单回显) | P0(订单回显)+ 运维建 OSS 桶;P0 未完成时先留手工填订单号 | 5~6 人日 | -| **P3** | 皮肤台账(`skin_catalog` 以斗鱼商品为锚 + `skin_aliases` 映射 + 四路来源 + 365 天规则 + `/skins` 页) | P0(订单皮肤);映射分析**已完成**(见分析文档),人工只需过 60 个商品 / 76 条别名 | 4~5 人日 | -| **P4** | 皮肤兑换工单(规则/模板 + 匹配层 + 工单/事件表 + 状态机 + 三处界面 + 前置校验作业) | P1a + P2 + P3(**不依赖 P1b**:CK 失效时先标记等人工重登,P1b 上线后自动接管) | 6~8 人日 | -| **P1b** | CK 自愈(`ck_relogin` 自动重登:**失败分型** + 退避 + 熔断 + 开关,**默认关闭**) | P1a(检测已验证,不必再等观察期) | 2.5~3 人日 | - -**建议顺序:P1a → P1b → P0 → P2 → P3 → P4。** - -- **P1a 已上线**(2026-10-02),只做「帮你把失效 CK 找出来」,零风险; -- **P1b 已上线**(2026-10-02):20 个失效账号实测 20/20 重登成功、零极验;当前 30 条/轮、约 97% 成功率,待重登积压约 3.6k 正在消化; -- **P0 已过半**(2026-10-02):适配层 + 迁移 0039 已完成并真机验证(三桶判定全对、3.3ms、540 个重复订单号正确拦下)。 - `psycopg` 与迁移 0039 已随 2026-10-03 的部署上线(`external_order_cache` 表已在生产建好)。 - **仍缺接口与页面**,且 `ORDER_SITE_LOOKUP_ENABLED=false` —— 运营还查不了订单; - 接口/页面按 §6.4 的调用点(用户填订单号时)随 P2 一起上; -- **P1b 提到 P1a 之后**:原计划等一到两周观察期,但线上数据已把三个前提验证完了 —— 检测结果与人工/直连逐条一致、误判路径已封(三类判定 + 自动暂停)、失效性质查清(会话过期而非凭证问题)。不联动的话,巡检每天翻出失效清单却没人兑现修复,1,356 个账号只会越积越多; -- **P0 随时可做**:只动本系统,order_site 侧代码改动量为 0; -- **P1b 开关仍默认关闭**,按 §7.4.7 三段上线:小规模实测 → 限量开 → 全量; -- **P1a 上线按三段走**: - ① 打开 `AUTOMATION_ENABLED=true` 并重启(此时作业开关仍关着,调度线程空转,不会有任何检测); - ② 在 `/automation` 页点「立即跑一次」并先把「每轮条数」设 **50** —— 手动触发不受开关限制,正好用来观察斗鱼风控与失败率; - ③ 确认无异常后把「每轮条数」调 **200** 并打开「启用」,调度器接管(5 分钟一轮,约 5.4 小时覆盖全库一遍),观察一到两周确认检测结果与人工判断一致。 - -依赖关系: - -``` -P1a ──> P1b (观察一到两周后再开,开关默认关闭) - │ - └──────────────┐ - ├──> P4 (只依赖 P1a 的「检测出失效」;P1b 上线后自动接管重登) -P0 ──┬──> P2 ────┤ - └──> P3 ────┘ -``` - -## 13. 风险与回退 - -| 风险 | 影响 | 缓解 | 回退方式 | -| --- | --- | --- | --- | -| 只读查 order_site 打慢对方生产库 | 影响自动发货 | `statement_timeout=3s`、单行索引查询、2 条连接、本地缓存 | 关 `ORDER_SITE_LOOKUP_ENABLED` | -| order_site 改表结构 | 适配层报错 | 只读 4 列且 88 个迁移零变更;SQL 集中在一个文件 | 同上(降级为手工) | -| **巡检把「查不到」当成「失效」** | 接口抖动/被风控时批量误标失效,客服误判 | 已做三类判定:只有斗鱼明确回「未登录」才写 invalid,其余只记 `error`、保留原结论;`error` 占比 > 30% 自动暂停作业 | 恢复接口后重新打开作业开关 | -| 巡检本身触发斗鱼风控 | 检测接口被限流/封禁,误判大面积 invalid | 巡检走**只读接口**且不并发过高;失败率超阈值自动暂停;必要时给请求加随机抖动、降 `concurrency` | 关 `ck_health` 作业(回到人工检测) | -| 冷启动一次性全量扫 | 短时高并发被风控 | 首轮小批量(50)观察后再放量 | 调小 `batch_size` | -| 自动重登烧掉邮箱配额 | 该账号当天无法登录 | 单账号退避到次日 + 每日上限 + 失败率熔断 | 关 `ck_relogin` 作业 | -| 自动重登与人工操作撞车 | 状态互相覆盖 | `attempt_status` 在途判断 + 作业锁 | 关作业 | -| 公开收集页被刷 | 垃圾数据 / 磁盘占用 | token + 有效期 + 次数上限 + IP 限流 + 图片重编码 + 大小限制 | 关闭链接 / 全局停用公开接口 | -| 皮肤别名归类错误 | 365 天规则误判 | 未命中一律挂起待人工,不静默放行;强制兑换留审计 | `/skins` 页面修正别名 | -| OSS 桶/对象被误删 | 台账有记录、图片丢失 | 桶开版本控制 + 生命周期;DB 只存 `object_key` 便于核对 | 从 OSS 版本历史恢复 | -| OSS 不可用 | 用户传不了图 | 提交失败给明确提示,不产生半条记录;重试 | 临时降级:先收文字资料,客服后补图 | -| 规则配置错误导致误拒绝 | 用户无法提交 | 匹配日志 + 人工放行通道 | 修正规则 / 人工放行 | - -## 14. 明确不做(避免范围膨胀) - -- 不做用户注册 / 登录体系:收集链接就是一次性的、带 token 的表单; -- 不做用户端的复杂进度回执与消息推送(决策 3); -- 不做订单号格式校验(决策 5),但**会校验订单是否真实存在**(依赖 P0); -- **不改 order_site 的代码、鉴权与部署**(见 §5.2); -- **不在 order_site 里重建一套兑换流程**:只借用它的订单/商品数据; -- **不回写 order_site 的订单状态**(本轮只读); -- 不做接单平台的众包能力(抢单 / 报酬 / 保证金 / 钱包 / 验收申诉); -- 不做分布式调度 / 多副本部署适配(当前单容器); -- 不做自动付款:所有涉及花钱的环节仍需人工扫码确认。 - -## 15. 待定事项 - -### 15.1 需要运维配合(非代码改动,P0/P2 前置) - -| # | 事项 | 状态 | -| --- | --- | --- | -| 1 | order_site PostgreSQL 建只读账号并授权 4 张表 | ✅ **已完成**(`livehub_ro`,SELECT-only,详见 §6.5) | -| 2 | 连接串写入 live-hub-py 的 `.env` 并确认可达 | ✅ **已完成**(`/root/live-hub-py/.env`,容器实测可达 `172.19.0.2:5432`) | -| 3 | 只读保护参数 | ✅ **已完成**(`statement_timeout=3s`、`CONNECTION LIMIT 5`、`default_transaction_read_only=on`) | -| 4 | 新建 OSS 桶 `live-hub-assets`(私有 + 版本控制) | ✅ **已完成**(private ACL、版本控制开启、put/get/list/delete 实测通过) | -| 5 | 新建 RAM 子账号(只授权该桶 Put/Get/Delete)并写入 AK/SK | ✅ **已完成**(`live-hub-oss` + 自定义策略 `live-hub-assets-rw`,实测最小授权生效) | - -**第 5 项的做法(已完成,留档)** - -子账号 `live-hub-oss` 只挂了自定义策略 `live-hub-assets-rw`(仅 `live-hub-assets` 桶的 Put/Get/Delete/List),实测: - -| 验证 | 结果 | -| --- | --- | -| 本桶 Put / Get / Delete / List | ✅ 全通(含真实 PNG 上传 → 后端读回,字节与 ContentType 完全一致) | -| **跨桶越权**(读 `order-site-assets`) | ✅ `AccessDenied` —— 最小授权生效 | -| **公共 URL 直取** | ✅ **403** —— 证实桶是私有的,必须走后端代理 | -| `HeadBucket` | ❌ 403(OSS 对 HEAD Bucket 要的权限不在这套策略里)→ **`object_storage.py` 不要用 HeadBucket 做健康检查**,用 `ListObjectsV2` 或 Put/Get 往返 | - -原控制台步骤(供以后给新环境建号时参考): - -1. 阿里云控制台 → **RAM 访问控制** → 身份管理 → 用户 → **创建用户** - - 登录名称:`live-hub-oss` - - 访问方式:**只勾「OpenAPI 调用访问」**(不要开控制台登录) -2. 创建后**立即保存 AccessKey ID / Secret**(只显示这一次) -3. 权限管理 → **新增授权** → 自定义策略 → 新建策略(脚本编辑),粘贴: - -```json -{ - "Version": "1", - "Statement": [ - { - "Effect": "Allow", - "Action": ["oss:PutObject", "oss:GetObject", "oss:DeleteObject", "oss:AbortMultipartUpload", "oss:ListParts"], - "Resource": ["acs:oss:*:*:live-hub-assets/*"] - }, - { - "Effect": "Allow", - "Action": ["oss:ListObjects", "oss:GetBucketInfo", "oss:GetBucketAcl"], - "Resource": ["acs:oss:*:*:live-hub-assets"] - } - ] -} -``` - -4. 把 AK/SK 写入 `/root/live-hub-py/.env`: - -```bash -OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com -OSS_BUCKET=live-hub-assets -OSS_ACCESS_KEY_ID=<第 2 步的 ID> -OSS_ACCESS_KEY_SECRET=<第 2 步的 Secret> -OSS_PREFIX=collect -``` - -> 若嫌麻烦,也可先用现有 AK 跑通 P2,之后再收窄权限 —— 但最小授权更稳(`.env` 泄漏时影响面只有一个桶)。 - -### 15.2 自有待确认 - -1. `automation_runs` 保留 90 天是否合适(§11.4); -2. 收集链接默认有效期 7 天是否合适; -3. `skin_catalog` / `skin_aliases` 初版由谁确认(分析已完成,只需过一遍 **60 个斗鱼商品 + 76 条快手别名**); -4. **C 桶的②卡券/道具类(17,948 件)与③纯数字商品名(4,491 件)是否纳入**?初步判断:不纳入(疑似走电子凭证自动发码 / 需人工识别); -5. 是否需要在兑换成功后主动通知用户(目前靠用户自己打开链接看)。 - -### 15.3 已定事项(留档) - -| 事项 | 结论 | -| --- | --- | -| **准入范围** | **只接 C 桶(没人管)**;A(已自动发货)、B(接单平台)都不要(§3.4) | -| C 桶内①服装类(黑色ROCK/运动服,17,704 件) | **后面单独处理**,本轮不做 | -| 归一的锚点 | **斗鱼商品目录**(78 个),不是快手商品名(§3.5) | -| 皮肤由谁定 | 唯一命中 → 订单自动定;命中多个 → **用户在链接里选一件**(决策 8 + 例外) | -| 斗鱼兑不了的 59.9% | **直接拒绝、不建单**(负责人已定),后续慢慢优化 | -| 匹配键 | **优先 SKU**(`relSkuId`/`skuNick`),文本兜底(决策 12) | -| 规则与映射的分工 | **取消映射层**:识别归 `skin_aliases`/`skin_keywords`,配置归 `exchange_skin_rules`(§10.1);`rule_ambiguous` 一并去掉 | -| 皮肤是否由用户选 | **不用**,由订单号自动对应(决策 8) | -| 是否涉及加好友赠送 | **不涉及**(决策 9) | -| 整合方案 | 选 **B:只读直连 PG**,order_site 零改动(§5.2) | - ---- - -## 附录 A · 迁移清单(均为加表/加字段,不动存量数据;编号按落地顺序取) - -| 阶段 | 迁移内容 | -| --- | --- | -| P0 | `external_order_cache` | -| P1a | `automation_jobs`、`automation_runs`;`login_tasks` + `relogin_attempts`/`relogin_next_at`/`relogin_last_error`;`accounts` + `auto_managed`(revision `20261002_0037`) | -| P1b | `login_tasks` + `relogin_failure_kind`(枚举;按落地顺序取号) | -| P2 | `collect_links`、`collect_submissions`(截图只存 `object_key`,不存 URL/本地路径) | -| P3 | `skin_catalog`、`skin_aliases`、`skin_douyu_items`、`account_exchange_records` | -| P4 | `exchange_skin_rules`、`exchange_match_logs`、`exchange_orders`、`exchange_order_events`(**4 张**,映射层已取消) | - -## 附录 B · 文件清单(预估) - -**后端** - -| 文件 | 阶段 | 说明 | -| --- | --- | --- | -| `web/backend/models.py` | 全部 | 新增表与字段 | -| `web/backend/permissions.py` | 全部 | 新增权限点与角色默认 | -| `web/backend/migrations/versions/*.py` | 全部 | 5 个迁移 | -| `web/backend/services/automation/scheduler.py` | P1 | 进程内调度线程 | -| `web/backend/services/automation/jobs/ck_health.py` | P1a | 巡检作业 | -| `web/backend/services/automation/jobs/ck_relogin.py` | P1b | 自愈作业 | -| `web/backend/services/external_order_source.py` | P0 | **唯一**允许写跨库 SQL 的文件 | -| `web/backend/services/collect_service.py` | P2 | token / 限流 / 图片校验与重编码 | -| `web/backend/services/object_storage.py` | P2 | **OSS 封装(`oss2`)**:上传/读取/删除,唯一允许出现 OSS SDK 的文件 | -| `pyproject.toml` | P0/P2 | 新增依赖:PG 驱动(`psycopg[binary]` 等,P0)+ `oss2`(P2) | -| `web/backend/services/skin_service.py` | P3 | 别名归一 + 365 天规则 + 落账 | -| `web/backend/services/exchange_order_service.py` | P4 | 匹配 + 状态机 + 事件 | -| `web/backend/routers/automation.py` | P1 | 作业管理接口 | -| `web/backend/routers/collect.py` | P2 | 公开 + 内部接口 | -| `web/backend/routers/skins.py` | P3 | 台账与别名维护 | -| `web/backend/routers/exchange_orders.py` | P4 | 工单接口 | -| `web/backend/main.py` | P1 | 注册路由 + 启停调度器 | -| `.env.example` | P0/P1/P2 | 新增配置项说明 | - -**前端** - -| 文件 | 阶段 | 说明 | -| --- | --- | --- | -| `web/frontend/src/pages/AutomationPage.tsx` | P1 | 作业与运行历史 | -| `web/frontend/src/pages/CollectPage.tsx` | P2 | 公开收集页(**放在登录判断之外**) | -| `web/frontend/src/pages/CollectLinksPage.tsx` | P2 | 链接管理 | -| `web/frontend/src/pages/CollectSubmissionsPage.tsx` | P2 | 待处理队列 | -| `web/frontend/src/pages/SkinsPage.tsx` | P3 | 台账与别名维护 | -| `web/frontend/src/pages/ExchangeRulesPage.tsx` | P4 | 规则与资料模板 | -| `web/frontend/src/pages/ExchangeOrdersPage.tsx` | P4 | 工单管理 | -| `web/frontend/src/App.tsx` | P2 | 新增路由(公开页需放在 `isLoggedIn` 分支之外) | -| `web/frontend/src/layouts/MainLayout.tsx` | P1~P4 | 菜单项 | -| `web/frontend/src/api/{automation,collect,skins,exchangeOrders}.ts` + `types.ts` | 全部 | 接口封装与类型 | - -**文档** - -| 文件 | 说明 | -| --- | --- | -| `docs/自动化与资料收集规划.md` | 本文档 | -| `docs/README.md` | 索引 | - -## 附录 C · 施工单(实现顺序与验证) - -战略与数据模型在正文已定;这里只写**按什么顺序动手、每步怎么验证**。 - -### C.1 迁移编号(按落地顺序取,**不预留**) - -现状 head = `20261003_0041`(账号事件,在 `dev`;`main` 上是 `20261003_0040`)。**谁先落地谁取下一个号**,不要按阶段预分配。 - -| 阶段 | revision | 说明 | -| --- | --- | --- | -| P1a(已落地) | `20261002_0037` | `automation_jobs` / `automation_runs` / `login_tasks` +3 / `accounts.auto_managed` | -| P1b(已落地) | `20261002_0038` | `login_tasks` + `relogin_failure_kind` | -| P0(已落地) | `20261002_0039` | `external_order_cache` | -| 生产热修(已落地) | `20261003_0040` | `douyu_tasks.account_assigned_to` + 复合索引(消除任务列表 filesort) | -| 账号事件(已落地,在 `dev`) | `20261003_0041` | `account_events` + `audit_logs.account_id`([设计](设计-账号详情页与事件历史.md)) | -| P2 / P3 / P4 | 各自落地时依次取号(**下一个是 `20261003_0042`**) | 见附录 A 的表清单 | - -> **为什么不能预留编号**:Alembic 的 `down_revision` 必须形成单链。若先给 P1 占 0038、 -> 把 0037 留给 P0,等 P0 真落地时它的 `down_revision` 只能指向 0038(否则与 0038 平级, -> 出现**两个 head**,`tests/test_migrations.py` 的单头断言会直接失败),编号比父提交还小。 -> 所以按落地顺序递增,才是唯一不别扭的做法。 - -约定:每个迁移的 `down_revision` 指向前一个;**写成幂等的**(照 `20261001_0036` 的写法,先 `sa.inspect` 查存在再改);每加一个迁移,`tests/test_migrations.py` 的 `HEAD_REVISION` 要同步改。 - -> **⚠️ 迁移编号一旦被任何库应用过就不能再改名**。P1a 犯过一次:迁移先叫 `20261002_0038`,本地 dev 库跑过一次,随后为了链条连续改名成 `0037`,dev 库里的 -> `alembic_version` 还记着 `0038`,再启动就报 -> `CommandError: Can't locate revision identified by '20261002_0038'`。 -> 改名要在**还没被任何库应用**之前做;已经应用过又必须改名时,除了改文件,还要把每个应用过的库 -> 手动对齐:`UPDATE alembic_version SET version_num='<新编号>';`(前提是迁移内容没变, -> 否则要按实际差异补脚本)。 - -### C.2 通用约定 - -- 测试文件按功能命名:`tests/test_<域>_<主题>.py`(现有 38 个文件、235 条); -- **纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数重点覆盖; -- **写操作不进自动回归**:兑换/重登/支付用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py`); -- 提交门禁:pre-commit 跑 `ruff format --check`(仅暂存文件)+ `tsc -b`;pre-push 跑全量 pytest; -- **新增环境变量必须同步改 `docker-compose.yml`**:本项目的 compose 用的是 - `environment:` **显式列表**(不是 `env_file`),只写进 `.env` **不会传进容器**。 - P1a 踩过一次:`.env` 加了 `AUTOMATION_ENABLED=true`,容器里查不到,调度器照旧不启动; - 早先 §15.1 写入的 `ORDER_SITE_*` 与 `OSS_*` 同样没生效。新增变量时三处都要动: - `.env`(服务器实值)+ `docker-compose.yml`(透传)+ `.env.example`(留档); -- **测试现已跑在真实 MySQL 上**(2026-10-02 改造完成,不再用 SQLite):SQLite 与 MySQL 的方言差异 - 曾让 `NULLS FIRST` 这类写法本地全绿、生产 1064,而 P1b 又踩到 SQLite 测不出的 - REPEATABLE READ 快照问题(见下条)。现在 `tests/conftest.py` 指向独立的 `douyu_login_test`、 - 每用例前后清表,库名与开发库相同时直接拒绝运行(防误清);`./dev.sh test` 需要 MySQL 已启动。 - **代价**:跑测试前要先 `./dev.sh` 起库,耗时从约 3.6 秒增至约 13 秒; -- **注意方言差异:生产是 MySQL 8**。SQLite 能吃下的写法 MySQL 未必认,行为测试也发现不了。 - P1a 踩过一次:`ORDER BY ck_checked_at ASC NULLS FIRST` 在 SQLite 正常,MySQL 直接报 - `1064 ... near 'NULLS FIRST'`。跨方言的排序用可移植写法(如 `col IS NULL DESC, col ASC`), - 并在测试里按 MySQL 方言 `compile()` 一次做回归。**P0 更要注意:它连的是 order_site 的 PostgreSQL**, - 三套方言(MySQL / SQLite / PG)都出现过,写 SQL 前先想清楚方言; -- **DB 读出来的时间是 naive UTC**:`datetime` 列取回后不带时区,直接和 `datetime.now(UTC)` 比较会 - `TypeError: can't compare offset-naive and offset-aware`(P1a 踩过一次)。比较前一律用 - `_as_utc()`(`services/yyb_service.py` 与 `services/automation/scheduler.py` 各有一份)补时区。 - SQL 里用绑定参数比较不受影响,只有 Python 侧比较需要注意 —— P0 判缓存过期时会再遇到; -- **每个阶段独立可发布**,新作业/新功能一律**开关默认关闭**; -- **迁移是应用启动时自动跑的**(`database.init_db()` → `run_migrations()` → `alembic upgrade head`)。 - 所以「部署」就等于「跑迁移」,**不要**再手动执行一遍;同时也意味着**迁移写坏了会挡住整个应用启动**, - 必须保持幂等(先 `sa.inspect` 查存在再改)。想先看效果可在本地 dev 库跑; -- **⚠️ 删除登录任务 = 删除该账号的 CK**(2026-10-03 出过事故,丢过 5019 个 CK,详见 - [事故复盘](事故复盘-CK误删与恢复.md)):斗鱼 CK **只存在 `login_tasks.cookie`**, - `accounts` 表没有 cookie 列。列表显示的「状态」是 `attempt_status`(最近一次尝试), - 与「是否持有 CK」(`status`/`cookie`)**语义不同** —— 当时有 752 行「显示失败但持有 - 有效 CK」。任何按条件筛选/删除的入口,判定必须基于 `cookie` 字段; -- **⚠️ 动手前先核实外部库的真实表结构,别信文档和早期分析**(P0 踩过一次,差点白干): - 规划 §6.2 写的 `orders.order_no`(PK) 在真实库里**根本不存在**,实际是 - `platform_order_id`(且**非唯一**,541 组重复)。同一订单号下还有「发货方内部行 + - 真实店铺行」的成对结构,**按单行判 route 会放行 540 个已发货订单**。 - 做法:先用只读账号 `SELECT column_name FROM information_schema.columns` 核对一遍, - 再把结论写进文档(§6.5.1 就是这么留档的)。**跨系统的字段假设一律当作待验证**; -- **加依赖后必须重锁 `uv.lock`**:Dockerfile 用的是 `uv sync --frozen`(见 §C.3), - 只改 `pyproject.toml` 不重锁,生产构建会直接失败。P0 加 `psycopg[binary]` 时走过一遍: - 本地 `uv lock` + `uv sync`,提交时带上 `uv.lock`; -- **验证容器里的代码要 `docker cp`,挂载只有 logs/data**:compose 只 bind 了 - `/root/live-hub-py/logs` 与 `/data`,**代码在镜像里**。改完宿主机文件不会自动生效; - 临时验证用 `docker cp` 进 `/app`,之后**记得删掉**,别污染生产工作区(会让 `git pull` 起冲突); -- **容器里装包要用 `uv pip` 且指定 `VIRTUAL_ENV`**:容器的 `/app/.venv` 里**没有 pip**, - 系统 `pip` 装的东西 venv 里的 python 看不见(P0 验证时白装过一次)。正确姿势: - `docker exec -w /app <容器> env VIRTUAL_ENV=/app/.venv uv pip install <包>`; - 另:`docker exec -e` 覆盖不了 compose 已注入的同名变量,验证时改用容器自带的 env - 或在脚本里 `os.environ[...] = ...`。 -- **⚠️ `create_all` 不会修改已存在的表**:测试库靠 `Base.metadata.create_all()` 建表, - 它**只建缺失的表**。给**已有表**加列/改列后,测试库里的旧结构不会更新,症状是一堆 - **不相关**的用例同时挂(例如加 `audit_logs.account_id` 后 22 个用例报 - `Unknown column 'account_id'`)。处理:改已有表的模型后,对测试库手动 `ALTER TABLE`, - 或 `DROP TABLE` 让它按新模型重建。**新增表**不受影响(会直接建出来); -- **灌测试数据要带 `--default-character-set=utf8mb4`**:`docker exec -i mysql ... < file` - 不带这个参数时中文会被双重编码(`套装` 存成 `套装`)。排查这类问题的快捷判断: - 对可疑列取 `HEX()`,若是 `C3A5C2A5...` 这种「UTF-8 再编码一次」的形态, - 说明是**写坏了**而不是读坏了。 - -### C.3 已核实的环境事实 - -| 事实 | 值 | 对实现的影响 | -| --- | --- | --- | -| uvicorn worker 数 | **`--workers 1`**(单容器单进程) | 进程内调度线程安全,**不需要分布式锁**;调度器在 lifespan 里启停 | -| 生产路径 | `/root/live-hub-py`,容器 `live-hub-py-douyu-login-1` | 部署 = `git pull` + `./deploy.sh` | -| 鉴权 | **无全局中间件**,逐路由 `Depends(get_current_user)` | 公开路由只要不挂依赖;新路由要在 `main.py` 注册 | -| PG 驱动 | `psycopg[binary]` 已进 `pyproject.toml` + `uv.lock`,**已随镜像上线**(2026-10-03,容器实测 3.3.6) | 部署时随镜像装上 | -| 只读 PG | `livehub_ro` @ `order_site-postgres-1:5432`,`.env` 已配(容器内 3 个 `ORDER_SITE_*` 均可见) | 适配层直接用 `ORDER_SITE_DATABASE_URL`;**`LOOKUP_ENABLED` 仍是 `false`** | -| OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | P2 用 `oss2`;**不要用 HeadBucket** | -| 迁移 head / 测试基线 | `20261002_0039` / 338 条(2026-10-03) | 见 C.1;**应用启动会自动跑迁移**,0039 已在生产应用 | -| 时区 | 库/`NOW()` 都是 UTC(`--default-time-zone=+00:00`),接口返回 `+00:00`,前端转北京时间 | **别把 `SELECT NOW()` 当真实时间**,详见 README「时区口径」 | -| 备份 | **没有定期逻辑备份**;依赖 MySQL ROW 格式 binlog(`log_bin=ON`,保留 30 天) | 误删只能靠 binlog 恢复;**不要关 binlog 或缩短保留期**;镜像里没有 `mysqlbinlog`,用 `pymysqlreplication` | - -### C.4 P1a 施工单(✅ 已按此完成,8 步) - -| # | 动作 | 文件 | 验证 | -| --- | --- | --- | --- | -| 1 | 迁移 0037:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0037_automation_jobs.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 | -| 2 | 调度器骨架:作业注册表、60s tick、进程内锁、开关、`automation_runs` 记录、优雅停止 | `services/automation/scheduler.py` | 单测:假时钟驱动 tick,断言「作业 enabled=false 时不执行」「不重叠执行」 | -| 3 | `ck_health` 作业:选行(`cookie_holding_filter` + 超期 + 排除在途/`auto_managed=false`)、并发 5、调 `check_douyu_cookie()`、写回 `ck_check_*` | `services/automation/jobs/ck_health.py` | 单测:选行条件正确;**断言全程不写 `cookie` / `status`**(P1a 的核心约束) | -| 4 | 接口 + 权限 + 注册:4 个 `/api/automation/*`;`automation:view` / `automation:manage`;lifespan 启停 | `routers/automation.py`、`permissions.py`、`main.py` | `tests/test_automation_api.py`:权限校验、开关生效、手动触发 | -| 5 | 补 `login:batch` 审计 | `routers/login.py` | 单测:发起批次后 `audit_logs` 有记录 | -| 6 | 前端:作业卡片 + 运行历史 + 手动触发;路由/菜单/接口封装 | `pages/AutomationPage.tsx`、`App.tsx`、`MainLayout.tsx`、`api/automation.ts` | `tsc -b` + `npm run build` | -| 7 | 本地端到端:对 3~5 个账号跑一轮,与人工点「检测」的结果逐条比对 | —— | 结果一致才进入下一步 | -| 8 | 上线:`git pull` + `./deploy.sh` → 先 `batch_size=50` 跑一轮看风控 → 无异常再调到 200 | —— | `/automation` 页面能看到每轮明细;CK 未被改动 | - -**P1a 完成定义(DoD)**:接口能开关与手动触发;巡检结果与人工检测一致;`login_tasks.status` / `cookie` 全程无变化;关掉开关后调度立即停止。 - -### C.4-bis P0 施工单(✅ 已完成,2026-10-02;**迁移 0039**) - -| # | 动作 | 文件 | 验证 | -| --- | --- | --- | --- | -| 1 | 加 PG 驱动 `psycopg[binary]` 并重锁 `uv.lock`(Dockerfile 用 `uv sync --frozen`,不重锁构建会失败) | `pyproject.toml`、`uv.lock` | 容器内 `import psycopg` 通过 | -| 2 | 迁移 **0039**:`external_order_cache`,主键 `platform_order_id + source_order_id` | `migrations/versions/20261002_0039_external_order_cache.py`、`models.py` | `pytest tests/test_migrations.py`(`HEAD_REVISION` 已同步);本地建表通过 | -| 3 | 适配层(唯一跨库 SQL):URL 归一、`statement_timeout=3s`、2 条连接池、**订单号维度聚合 route**、回显取真实店铺行 | `services/external_order_source.py` | `tests/test_external_order_source.py`(24 条) | -| 4 | 真机验证(只读账号 `livehub_ro`) | `tests/test_external_order_source_live.py`(手工跑,未配 URL 时 skip) | 见下 | -| 5 | 降级开关验证 | —— | 未配 URL / `ENABLED=false` / 库不可用时均抛 `OrderLookupUnavailable`,主流程照常 | - -**真机验证结果(生产容器,`ORDER_SITE_LOOKUP_ENABLED` 临时打开)** - -| 用例 | route | 回显 | 结论 | -| --- | --- | --- | --- | -| C 桶 `2627501602915345` | `unhandled` | 激萌罗小黑 | ✅ 进候选 | -| B 桶 `2627501932004488` | `worker_platform` | 指挥官密钥4个 | ✅ 拒绝 | -| A 桶 `2627501850053080` | `auto_fulfilled` | 套装-浪漫天命 | ✅ 拒绝 | -| **重复组** `2624100167260659` | **`auto_fulfilled`** | **狂沙舞者** | ✅ **拦下,且回显真实店铺行** | -| 重复组 `2624100563293477` | `auto_fulfilled` | 100011 | ✅ 拦下 | -| 不存在 `0000000000000000` | —— | —— | ✅ 明确「订单号不存在」 | -| 单次查询耗时 | —— | —— | ✅ **3.3 ms**(要求 < 50ms) | - -**⚠️ 上线前还需做(P0 未完的部分)** - -1. `ORDER_SITE_LOOKUP_ENABLED` 仍为 `false`(默认关闭)—— 要正式启用需改生产 `.env` 并重启; -2. ~~生产库还没跑迁移 0039~~ ✅ **已于 2026-10-03 部署时自动应用**(`external_order_cache` 表已存在,psycopg 3.3.6 随镜像装上); -3. **P0 目前只有适配层,没有接口和页面** —— 运营还查不了。按 §12 排期,接口/页面可随 P2 收集链接一起上(用户填订单号时调用)。 - -### C.5 其余阶段的施工顺序(粗粒度) - -| 阶段 | 顺序 | -| --- | --- | -| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移(取下一个号)+ `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 | -| **P2** | ① 迁移(取下一个号)→ ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) | -| **P3** | ① 迁移(取下一个号)→ ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 | -| **P4** | ① 迁移(取下一个号)→ ② `exchange_order_service.py`(`resolve_skin` 两步匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) | diff --git a/docs/虎牙/HUYA_APP_OVERVIEW.md b/docs/虎牙/HUYA_APP_OVERVIEW.md index 3de40bb..420d798 100644 --- a/docs/虎牙/HUYA_APP_OVERVIEW.md +++ b/docs/虎牙/HUYA_APP_OVERVIEW.md @@ -1,119 +1,77 @@ - +# 虎牙 App 协议总览 -# 虎牙 App 协议链 总览(去困惑地图) +更新时间:2026-11-06 -> 目的:一张图讲清“整体流程 / 几个 hdid 区分 / 几个登录入口 / 哪里随机 / 哪里有风险”, -> 避免在代码和多个零散文档里来回翻。**先看本文,再按需深入具体文档。** -> -> 关联文档:`docs/dfpReport破解进度.md`(破解细节)、`docs/dfpReport生成流程.md`(零设备注册链)、 -> `docs/模拟器存活闪退诊断报告.md`(Frida 存活实验,模拟器)、`docs/虎牙纯Python全自动登录-总结与交接.md`。 -> Cookie 来源审计:`docs/HUYA_COOKIE合并审计与来源矩阵.md`。 +本文是虎牙协议文档的入口。先确认当前链路,再按需进入字段来源、算法或抓包证据。 ---- +## 当前生产入口 -## 一、生产登录整体流程(唯一推荐的入口) +| 能力 | 代码入口 | +| --- | --- | +| App 密码登录 | `core/huya/app_login.py` | +| Web 密码登录 | `core/huya/login.py` | +| 短信登录 | `core/huya/sms_login.py` | +| 自动注册 | `core/huya/auto_register.py` | +| Cookie 补齐与重建 | `core/huya/web_cookie_fields.py`、`core/huya/cookie_utils.py` | +| 活动与商城 WSS | `core/huya/wss_client.py`、`core/huya/elite_session.py` | +| API 路由 | `web/backend/routers/huya/` | -入口:`core/huya/app_login.py` → `login_huya_app_password()` / `HuyaAppPasswordLogin`。 -Web UI 通过 `web/backend/routers/huya.py` 调用它。 +App 登录主链: -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ 每次 WUP 登录前 │ -│ ① 读取/创建账号设备画像 core/huya/device_profile.get_profile() │ -│ 同一账号复用 vendor/model/screen/width/height/fingerprint/guid32 │ -│ │ -│ ② 新设备注册链 core/huya/dfp_register.register_device() │ -│ getDfpConfig → selectOperator → dfpReport(随机cw) │ -│ 三步请求的设备字段均来自①,返回 (t1, safedeviceid, device_id) │ -│ 返回字段为本次登录专用的新令牌,非画像旧值 │ -│ 失败 → 抛 DfpRegistrationError → HuyaAppLoginError,终止,不回退 │ -│ │ -│ ③ WUP 密码登录 wup_password_login_raw() │ -│ POST wup.huya.com hypasswordLogin(safedeviceid/device_id 用①的)│ -│ ├─ 正常 → 解出 114B cred + 真实 uid │ -│ └─ pt_auth 滑块 → solve_safe_auth() 自动过验 → 重发登录(≤3轮) │ -│ │ -│ ④ 本地铸证:nonce_forge(uid+k1) → cert_forge 铸证书 → envelope_forge │ -│ 补丁 WUP 信封(证书 + UID) │ -│ ⑤ 模拟扫码绑定四步流 QrRole(getQrId→scan→bind→tryQrLogin)→ biztoken│ -│ ⑥ POST /web/cookie/verify → 认证 Cookie;本地补齐网页运行字段 │ -└──────────────────────────────────────────────────────────────────────┘ -``` +1. 读取或生成账号设备画像; +2. 走 `dfp_register.register_device()` 获取本次登录设备凭据; +3. 向 `wup.huya.com` 发密码登录请求,解析 UID 与 114 字节 `cred`; +4. 本地生成 nonce、证书和 WUP 信封; +5. 复用扫码绑定协议取得 `biztoken`; +6. 调网页 cookie verify,合并认证字段和网页运行字段后回写账号。 -**结论**:这是“绕开 App UI、复现协议”的纯协议链。它绕过了滑块(pt_auth), -但 **qr_auth(扫码)/ dx_auth(短信)没有自动闭环**——碰上直接失败,属已知边界。 +## 标识符口径 ---- +必须先读 [设备标识命名规范](HUYA_ID_NOMENCLATURE.md)。最容易混淆的三类值是: -## 二、几个 “hdid” 的区分(最容易混的点) +- **HDID32**:WUP 登录帧使用的 32hex 登录设备标识,不能混同网页 `hdid`; +- **DEVID40**:设备注册链返回的 40hex `device_id`; +- **GUID32**:doLaunch/会话体系的 32hex `sGuid`。 -代码里出现三种都叫“hdid”的东西,**不是同一个**: +任何新增日志、文档或字段都不要只写模糊的“hdid”。 -| # | 形态 | 示例 | 所在 | 是否登录硬锚 | 能否随机 | -|---|---|---|---|---|---| -| 1 | **WUP 登录帧 t1.t0 的 32hex hdid** | `ed0db8334cadd236c00cadf7e11ab5a5` | `core/huya/wup_encoder.py`(`hdid` 参数);`device_profile.HDID` | ✅ **服务端硬锚**,`libhydeviceid.so` native 生成上报,不走 HTTP | ❌ 换掉 → `APP_SIGN_NOT_MATCH` | -| 2 | **dfpReport 响应 t5 / device_id(40hex)** | `7c5387e0539c023c31c4ff0e807e7256117385ee` | 注册链 `dfp_register`;登录帧 `device_id` | ❌ 与 #1 非同一体系(`device_fingerprint.py` 有明示) | ✅ 随机 cw 时服务端签发随机值 | -| 3 | **注册链随机 cw 里的 “hdid”(64hex sha256)** | — | `dfp_register._random_triple` / `tools/dfp_gen.py` | ❌ 仅凑 JSON 模板形态,服务端不校验 cw 内容 | ✅ 每次随机 | +## 当前 Cookie 模型 -**一句话**:#1 是真身份、不可换、全项目唯一;#2 是登录帧 `device_id` 的签发源,动态; -#3 是诊断占位。混淆来源主要是 dfpReport JSON 明文里把 40hex 设备 ID 也叫 `hdid`。 +- `raw_cookie` 保存 App/WebView 原始凭据; +- `cookie` 保存可运行的网页 Cookie; +- 认证字段以本次登录真实返回值为准; +- 网页设备字段优先使用真实值,缺失时按已复刻算法补齐; +- 活动 WSS 缺少会话字段时先走 `wsLaunch`,拿到 guid 后重建连接。 ---- +字段级状态见 [虎牙 Cookie 路线状态](HUYA_COOKIE路线状态.md), +来源约束见 [Cookie 合并审计与来源矩阵](HUYA_COOKIE合并审计与来源矩阵.md), +浏览器证据见 [网页设备字段溯源](HUYA_WEB_FIELD_TRACE.md)。 -## 三、登录入口盘点(哪个该用、哪些是历史) +## CK 获取 -### 生产链(core/huya,Web UI 调用) -| 入口 | 功能 | 状态 | -|---|---|---| -| `core/huya/app_login.py` → `login_huya_app_password` | **App 协议全链路**(①~⑤) | ✅ **推荐**,已接注册链 | -| `core/huya/login.py` → `login_huya_password` | Web 密码登录 | ✅ 独立 Web 渠道 | -| `core/huya/sms_login.py` → `login_huya_sms` | 短信登录 | ✅ | -| `core/huya/auto_register.py` | 手机号自动注册 | ✅ | -| `web/backend/routers/huya.py` | Web UI 接口(单账号/批量) | ✅ | +- 已登录 H5 抓包:见 [HAR 提取 CK 与积分中心](HAR提取CK与积分中心.md); +- 小X分身/App WebView:见 [小X分身虎牙 CK 提取](分身CK提取.md); +- 后端重建:`POST /api/huya/cookies/{account_id}/rebuild`。 -### 工具 / 历史(tools,试验用,与生产分离,勿在生产调用) -| 文件 | 用途 | 注意 | -|---|---|---| -| `tools/huya_device_register.py` | 注册链 CLI(`--gen` 零设备 / `--login`) | 与 `core/huya/dfp_register` 同逻辑的工具侧版 | -| `tools/app_login_flow.py` | 旧 App 登录流 | ⚠ 仍读画像 `safedeviceid` 回退金样本,**未接注册链,已过时** | -| `tools/huya_wup_encoder.py` | 旧 WUP 编码器副本 | 正式版在 `core/huya/wup_encoder.py` | -| `tools/full_auto_safe.py` / `full_auto_test.py` / `full_web_cookie.py` | 一键试验脚本 | 依赖 `evidence/` 信封/证书模板,非生产 | -| `tools/web_cookie_login.py` | 网页版密码登录 | Web 渠道独立 | -| `tools/huya_device_profile.py` | 旧画像生成器 | ⚠ 仍含固定 `safedeviceid`,与 core 策略(动态签发)不一致 | +`cred0` 与 WebView Cookie 能补齐 WSS 登录态;缺少匿名设备字段时, +后端可再走匿名设备协议补齐。 ---- +## 协议与算法 -## 四、随机化矩阵(哪里随机 / 固定 / 动态签发) +- App 加密算法:[App 登录加密算法动态验证](虎牙App登录-加密算法动态验证.md) +- 证书生成:[虎牙证书算法闭环](虎牙证书算法闭环-差分实证.md) +- 登录 WUP 结构:[证书算法闭环](虎牙证书算法闭环-差分实证.md) 与 `core/huya/wup_encoder.py` +- 扫码绑定:[扫码登录闭环逆向](虎牙扫码登录闭环逆向.md) +- 设备注册与 dfpReport:[dfpReport 生成流程](dfpReport生成流程.md) +- HDID:[算法生成路径](HUYA_HDID_ALGORITHM_GEN.md) 与 [研究记录](HUYA_HDID_RESEARCH.md) +- Frida 稳定注入:[Frida 绕过配方](Frida绕过配方.md) +- Hook 与证据落盘:[Hook 运行与捕获约定](Hook脚本运行与捕获约定.md) -| 字段 | 状态 | 来源 | 风险 | -|---|---|---|---| -| **hdid (32hex)** | **固定**(全账号同一) | `device_profile.HDID` 金样本 | ⚠ 多账号设备维度关联(主要风险) | -| safedeviceid (t2) | **动态签发**(每次登录前) | 注册链 `dfp_register` | 低 | -| device_id (t5) | **动态签发**(随机 cw 时为随机值) | 注册链 `dfp_register` | 低 | -| fingerprint | **随机**(每账号) | `generate_profile` sha1 | 低 | -| 机型 / 屏幕 / 宽高 | **随机**(每账号从 `REAL_MODELS` 选) | `generate_profile` | 低 | -| app_version / sdk_version | **固定** | 常量 `13.4.22` / `1.0.80138` | 低(需与 hdid 配套) | -| 滑块/扫码 WebView UA | **按画像派生** | `device_profile.mobile_user_agent()` | 与机型/厂商/系统版本一致 | -| session / traceId / 登录行为 JSON | **每次请求动态** | `wup_encoder.make_*` + `_golden_session_assets()` | 仅协议字段固定结构 | -| QrRole context / reqid / page_id | **随机** | `QrRole`(uuid/random) | 低 | +## 已知边界 ---- - -## 五、风险与未解决项(诚实清单) - -1. **hdid 固定共享** → 服务端可在设备维度把多个账号关联起来(见 §2 #1)。纯代码无法铸造新 hdid;换掉即 `APP_SIGN_NOT_MATCH`。 -2. **qr_auth / dx_auth 无自动闭环** → 碰上直接失败(`QR_AUTH_REQUIRED`)。 -3. **无稳定性数据** → 没有 20/50/100 次连续成功率、多账号/IP/时段矩阵。 -4. **Frida attach 主进程必被 msaoaidsec 杀**(真机/模拟器均实测)→ 见 §六。 - ---- - -## 六、真机 Frida 现状(2026-08-27 实测) - -- **裸 attach 主进程**:真机同样被 `libmsaoaidsec.so` 静默 `_exit`(KeepAlive 换 pid,UI 几乎无感)。模拟器版详见诊断报告。 -- **真机稳定注入通道(已打通)**:`spawn` 挂起中加载 `bypass_msaoaid_maps_art_callsite.js`(RE 库)再 `resume` → **长时存活 + 可 hook**(90s 无 detached,抓到 `libudbauthunify.so` 基址)。真机 GPU 正常,**不再触发模拟器的 EGL 崩溃**。 -- 脚本:`scripts/phone_stable_capture.py`(真机稳定抓帧)、`scripts/diag_phone_lifecycle.py`(真机四组对照诊断)。 -- 含义:真机上做长时 Frida 抓帧/取证是可行的(不再受模拟器 ~4s stale 窗口限制);但**登录/验证码流程仍不要带 frida**(msaoaidsec 会标记)。 +- 风控可能切换到扫码、短信或其他验证方式;遇到新 challenge 时以 + `core/huya/verification/solver.py` 的异常类型和日志为准。 +- 订单、兑换和支付属于写操作,自动回归只验证协议构造与只读路径。 +- 真实 Cookie、UID、订单号和设备金样本不得写入 Markdown 或提交到 Git。 +- 协议字段状态应以当前代码和 [虎牙 Cookie 路线状态](HUYA_COOKIE路线状态.md) 为准, + 历史抓包只能作为证据,不能直接覆盖现有实现。 diff --git a/docs/虎牙/HUYA_CHAT_缺失字段补齐方案.md b/docs/虎牙/HUYA_CHAT_缺失字段补齐方案.md deleted file mode 100644 index 2d8fbf7..0000000 --- a/docs/虎牙/HUYA_CHAT_缺失字段补齐方案.md +++ /dev/null @@ -1,194 +0,0 @@ -# 虎牙网页 Cookie 缺失字段补齐方案(历史记录,2026-09-01 晚) - -> 当前字段状态以 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md) 为准;本文保留当晚的抓包与联调过程。 - -> 目标:账号 19507362959(DB id=58,yyuid=1199664135026)跑通精英宝典 WSS -> query_game_name(checkUserBindGameAccount)/ query_points(getUserScore)。 -> 当前账号 cookie 仅 16 字段(登录凭据),对比 9.1 抓包 42 字段缺 26 个。 -> 本文档按"浏览器实测来源 → 生成方案"逐字段确定,重点分析 4 个核心设备字段。 - -## 一、背景结论(已有证据) - -- 9.1 抓包(2026-09-01 09:36)42 字段 cookie:`guid/game_did/_qimei_uuid42/udb_*` 等全部齐全, - 上午所有业务接口成功;当天 21:40 用同一 cookie 逐字节重放,**带 cookie 的业务接口全部返回 - 905/259**,不带 cookie 的 getActInfo/getActTaskDetail 仍成功 → 905 是 **udb 登录态过期**, - 不是请求帧格式问题(重放原字节即可复现)。 -- 浏览器实测(真实 Chrome CDP,`evidence/browser_cookie_harvest/report.md`): - 7 个必填网页设备字段全部由**页面脚本写 document.cookie** 或**服务端匿名接口下发**产生: - | 字段 | 产生机制 | 协议可否复刻 | - | --- | --- | --- | - | `udb_deviceid` | middle/anonymousLogin Set-Cookie(10 年) | ✅ 已接入 anon_device | - | `udb_anobiztoken` | anonymousLogin 响应 biztoken | ✅ 已接入 anon_device | - | `udb_anouid` | anonymousLogin 响应 uid | ✅ 已接入 anon_device | - | `udb_guiddata` | middle URL 第三段 hex32(客户端自选) | ✅ 已接入 anon_device | - | `guid` | `HyUDBWebSDK-Exchange-2.4.js` 写 document.cookie | 会话值已接入;回退生成规则待确认 | - | `_qimei_uuid42` | QIMEI SDK 脚本写 document.cookie | 0.6.3 时间/CRC32/随机半字节算法已复刻 | - | `__yamid_new` | 门户业务脚本写 document.cookie | `room_normal.js` `generate32` 已复刻 | - | `game_did` | 门户业务脚本写 document.cookie | 35 位、63 字符集规则已复刻 | - -## 二、账号 58 现状(16 字段) - -`udb_passport, username, udb_cred, udb_uid, yyuid, udb_biztoken, udb_version, -udb_origin, udb_status, sdid, hdid, udb_anouid, udb_anobiztoken, udb_deviceid, -udb_appid, udb_guiddata` —— 4 个匿名设备字段已有(anon_device 协议), -**缺 `guid/game_did/_qimei_uuid42/__yamid_new` 4 个核心 + 22 个统计字段**。 - -## 三、缺失字段补齐方案(逐字段) - -### 3.1 四个核心设备字段(本轮重点分析) - -| 字段 | 9.1 值形态 | 浏览器首次出现 | 补齐方案 | -| --- | --- | --- | --- | -| `guid` | 32 hex,`0a7d…` 前缀 | udbres.huya.com SDK 脚本 | `wsLaunch` 会话值已接入;回退规则待确认 | -| `game_did` | 35 位字母数字 | liveapi.ip/getIpLocation 后门户脚本 | 35 位、63 字符集已复刻 | -| `_qimei_uuid42` | 42 hex,时间前缀 | QIMEI SDK 脚本 | 14 位时间前缀 + CRC32 替换段 + 随机半字节 | -| `__yamid_new` | 32 hex 大写 | 门户脚本 | `generate32` 已复刻并通过 WSS 验证 | - -浏览器 round1 同一字段多次出现均**值稳定**(跨 profile 稳定):`_qimei_fingerprint` -恒为 `2822a43b5a3bd016fc844218c35c8a74`,`_qimei_uuid42` 按秒内时间前缀稳定, -`guid` 前缀恒为 `0a7dfaa2…`。QIMEI 时间/CRC32/随机半字节生成算法已由 SDK VM -固定输入探针闭合,随机字段仍会在每次生成时变化。 - -### 3.2 统计/会话字段(随机即可,服务端仅记录) - -| 字段 | 生成规则 | 9.1 样例 | -| --- | --- | --- | -| `SoundValue` | 固定 `0.50` | `0.50` | -| `alphaValue` | 固定 `0.80` | `0.80` | -| `isInLiveRoom` | 固定 `true` | `true` | -| `__yasmid` / `__yamid_tt1` | 17 位随机小数,两者同值 | `0.24859634691671784` | -| `udb_passdata` | 固定 `3` | `3` | -| `_rep_cnt` / `rep_cnt` | 随机 0-9 | `5` / `7` | -| `udb_accdata` | 固定 `undefined` | `undefined` | -| `h_unt` | 当前时间戳(秒) | `1788226396` | -| `__yaoldyyuid` | = yyuid | `1199666914671` | -| `_yasids` | `__rootsid%3D`+32hex 大写 | `__rootsid%3DCBCC34C4…` | -| `Hm_lvt_5170…` | 当前时间戳 | `1788226384` | -| `Hm_lpvt_5170…` | 当前时间戳 | `1788226571` | -| `HMACCOUNT` | 16 hex 随机(百度统计下发) | `1B08C0532E777923` | -| `huya_flash_rep_cnt` | 随机整数 | `136` | -| `huyasp_rep_cnt` | 随机整数 | `9` | -| `huya_hd_rep_cnt` | 随机整数 | `22` | -| `huya_web_rep_cnt` | 随机整数 | `507` | - -### 3.3 已有字段(保持真实值) - -`udb_cred/biztoken/uid/yyuid/passport/version/origin/status/appid/username`(登录凭据)、 -`sdid/hdid`(设备指纹)、`udb_deviceid/udb_guiddata/udb_anobiztoken/udb_anouid`(anon_device)、 -`huya_ua`(normalize 注入)。 - -## 五、实现与端到端验证(2026-09-01 晚) - -实现(commit `5dc7365`): - -- 新增 `core/huya/web_cookie_fields.py`:`fill_web_cookie_fields(cookie, guid, uid)` - 补齐 4 核心(guid 用 wsLaunch 会话值,`_qimei_uuid42`/`__yamid_new`/`game_did` - 随机对齐格式)+ qimei 皮肤 + 22 个统计/会话字段;`missing_web_cookie_fields` 返回缺失集合。 -- `core/huya/elite_session.py`:活动通道初始化时若缺网页字段,先 wsLaunch 预取 - 服务端认可的会话 guid → 补齐 cookie → 重建会话;业务 RPC 统一走补齐后的 - cookie(`_resolve_cookie`)。 - -端到端结果(账号 id=58,yyyyuid=1199664135026,原始 16 字段登录态): - -| 任务 | 结果 | -| --- | --- | -| `query_game_name`(b_act_id=9271) | **success**,角色=收麦季(手Q/苹果iOS),game_name=和平精英,已绑定(QQ 昵称"羊"),change_bind_day=7 | -| `query_points`(sid=2203) | **success**,msg=请求成功,gainScore=4880 / usedScore=4880 / available=0 | - -修复前同类任务全部报"接口无响应"(getConfig 超时);修复后 Web API -(`POST /api/huya/tasks/batch`,账号 58)两条任务均 success 落库。 - -复现命令: - -```bash -cd /Users/yml/codes/live-hub-py -.venv/bin/python /tmp/huya_test_session.py # 原始 DB cookie → 自动补齐 → 查角色/积分 -./dev.sh # 界面 http://localhost:5174 → 精英宝典 -``` - -## 六、风险与验证要求 - -当前实现已经通过活动 WSS 查询链路验证。`guid` 的回退值和设备字段持久化仍需继续完善;详细状态见 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md)。 - -## 七、各通道 Cookie 依赖矩阵(2026-09-01 晚实测) - -不同通道对网页设备态字段的依赖不同,补齐策略按通道区别: - -| 通道 | 代表接口 | Cookie 使用方式 | 原始 16 字段能否工作 | -| --- | --- | --- | --- | -| 活动 WSS(`mobileui`/`webActUI`) | getConfig / getUserScore / checkUserBindGameAccount / scoreExchangePrize | AUTH 帧 + 业务 tReq 均带完整 cookie,wsLaunch 会话 guid 需回写 | ❌ 缺 4 核心字段时 getConfig 超时;**需补齐**(已接入 elite_session) | -| 商城 WSS(`shopMiddleUI`,m-shop.yaoguo.com) | getGoodsInfoV5 / createOrderV5 / payOrderSubmit | 业务 tReq `UserId.sCookie` 带完整 cookie,`sGuid` 置空(HAR 实证) | ✅ 实测原始 cookie 正常响应(403 只是业务参数校验) | -| HTTP RPC(cdnws.api.huya.com POST) | query_user_score / get_act_prize_list / build_bind_urls / get_livelink_mini_qrcode | baseinfo + 业务 tReq 带完整 cookie,`_resolve_cookie_guid` 自动用 guid→udb_guiddata→sha256 派生 | ✅ 实测原始 cookie 直接返回 status=200(积分 4880) | - -要点: - -1. **活动 WSS 是唯一强校验网页设备态的通道**:AUTH/getConfig 前置就失败(无响应), - 补齐字段后成功(commit `5dc7365`)。 -2. **商城 WSS 不校验**:`UserId.sGuid=""` 且 cookie 原样发送,服务端接受原始登录态 - (实测 getGoodsInfoV5 返回业务 403 而非连接/解析错误)。 -3. **HTTP RPC 自带 guid 派生**:缺 `guid` 时依次用 `udb_guiddata[:32]`、`yyuid|udb_uid| - udb_passport|username|udb_biztoken` 的 sha256 派生,业务请求体可带原始 cookie - 直接成功 —— 这是「活动接口当前主要依赖核心认证字段」的佐证(与 - `HUYA_COOKIE合并审计与来源矩阵.md` 结论一致)。 -4. 因此**没有其他必须补齐的 cookie 字段**:活动 WSS 之外,商城 WSS 与 HTTP RPC 用 - 原始登录态即可跑通。剩余未实测的链路(商城下单/支付等)依赖的仍是同一份 - 原始 cookie + `udb_cred/biztoken` 登录态,不涉及网页设备字段的新增要求。 - -## 八、商品/兑换全链路验证(2026-09-01 晚,活动 WSS) - -补齐会话后逐接口实测(账号 58,sid=2203): - -| 接口 | 请求参数 | 响应 | -| --- | --- | --- | -| `getActPrizeList` | sid=2203 | status=200,29 个商品 / 2 分类(限量返场/精英专享),如 pid=13161 破片手榴弹 3600 分 | -| `getActPrizeDetail` | sid=2203, pid=13161 | status=200,详情正常(score=3600、频次 600/3、时段 00:00-23:59:59、可兑) | -| `getUserPrizeRecords` | sid=2203 | status=200,历史 2 条(赤蝎幽灵 3600 / 萌神柯基 1280,已发放) | -| `scoreExchangePrize` | sid=2203, pid=13161 | status=604"兑换货币不足" —— **协议往返成功**,业务侧拒绝(账号可用积分 0) | - -结论:商品刷新/选择/兑换接口与 9.1 抓包字节逐一对齐(sid/pid/ip/clientEnv/source/ -isRole),RPC 往返全部正常;604 是账号真实积分状态(历史兑换已用 4880/4880), -非协议问题。兑换成功路径需账号有可用积分后复测;发起兑换前 `query_user_score` -的 `available_score` 先行校验可避免 604。 - -1. `guid/_qimei_uuid42/__yamid_new/game_did` 的**真实性/关联性**服务端是否校验未知; - 必须用生成值跑通线上活动 WSS 全链路才能定论,不能只过长度测试。 -2. 统计字段随机值不影响接口语义,但**顺序**建议与 9.1 cookie 保持一致以便对照。 -3. 补齐后仍需**新鲜的 udb 登录态**(905 = 过期),登录链路见 `app_login.py`。 -## 九、浏览器注入 ck 未登录的根因(2026-09-01 晚实测确认) - -现象:把 App 扫码登录产出的 41 字段网页 ck 注入浏览器,打开 www.huya.com 顶部仍显示 -"登录"按钮(未登录)。 - -排查过程(CDP 有头 Chrome 实测): - -1. ck 本身有效:curl 带该 ck 请求 - `l.huya.com/udb_web/udbport2.php?m=HuyaLogin&do=checkLogin` 返回 - `{"isLogined":true,"userName":"hy_300539756","uid":"1199667330433",...}`; - 8.31 真实扫码登录抓包的 checkLogin 成功值与 verify data:null 均一致。 - 注意:`user.huya.com/liveHttpUI/checkUserTimeIsLogin` 的 `iIsLogin:0` 不判定 - 登录态 —— 真实网页 ck 抓包同样为 0,可排除。 -2. `web/cookie/verify` 的 `data:null` 也是正常成功形态,非未登录信号。 -3. document.cookie 检查发现:注入后页面里只有设备字段(guid 等)、没有 - udb_uid/udb_cred/udb_biztoken/yyuid —— 登录凭据没有被 www.huya.com 子域携带。 - -**根因:Network.setCookie 的 domain 参数不能写 `huya.com`,必须写 `.huya.com` -(带前导点)。** - -- `domain="huya.com"`(无前导点)→ Chrome 存为 **host-only cookie**,只有精确访问 - `huya.com` 时才发送;`www.huya.com` / `l.huya.com` / `udblgn.huya.com` / - `user.huya.com` 等**所有子域都不会携带** → 页面意义上的登录判定失败。 -- `domain=".huya.com"`(带前导点)→ 存为 **domain cookie**,huya.com 及其全部子域 - 都携带。实测注入后 `document.cookie` 含全部字段,顶部导航"登录"按钮消失、 - 出现登录用户专属项(如"关注")。 - -正确注入姿势(CDP): - -```json -{"method":"Network.setCookie","params":{ - "name":"udb_uid","value":"1199667330433","domain":".huya.com", - "path":"/","secure":false,"httpOnly":false}} -``` - -手工用浏览器插件/DevTools 注入时同样注意:Domain 填 `.huya.com`(用户输入时 Chrome -会规范化为前导点形式;若插件按 host-only 保存则无效)。HttpOnly 无需设置 —— -uzb_* 登录凭据在真实网页中可由脚本读取,非 HttpOnly。 diff --git a/docs/虎牙/HUYA_COOKIE路线状态.md b/docs/虎牙/HUYA_COOKIE路线状态.md index b2b16b1..90f4ec6 100644 --- a/docs/虎牙/HUYA_COOKIE路线状态.md +++ b/docs/虎牙/HUYA_COOKIE路线状态.md @@ -1,12 +1,13 @@ # 虎牙 Cookie 路线状态 -更新时间:2026-09-02 +更新时间:2026-11-06 ## 当前结论 App 登录可以得到新鲜的 `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken`、`sdid` 和 `hdid`。匿名设备协议可以补齐 `udb_deviceid`、`udb_guiddata`、`udb_anobiztoken`、`udb_anouid`。活动 WSS 会话在缺少网页设备字段时先执行 `wsLaunch`,拿到会话 `guid` 后补齐 Cookie 并重建连接。 -活动 WSS 的角色、积分、商品、详情和兑换记录查询已完成端到端验证;商城 WSS 和 HTTP RPC 的查询链路也已验证。真实兑换曾验证过一次,后续自动验证保持只读。 +活动 WSS 的角色、积分、商品、详情和兑换记录查询已完成端到端验证;商城 WSS 和 HTTP RPC 的查询链路也已验证。小X分身 WebView Cookie 可通过 `cred0` 补齐 +`udb_cred`,再用匿名设备协议补齐 WSS 所需字段。真实兑换曾验证过一次,后续自动验证保持只读。 ## 字段状态 @@ -24,6 +25,9 @@ App 登录可以得到新鲜的 `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken | `_qimei_h38` | 兼容门槛已闭合 | SDK 内部 `h38` 就是 42 位 UUID42;活动积分链路只校验 Cookie 键存在,缺失时复用 `_qimei_uuid42` | | 统计字段 | 已完成 | 线上查询已接受;部分字段仍是统计用途 | +CK 获取与重建方式见 [小X分身 CK 提取](分身CK提取.md) 和 +[HAR 提取 CK 与积分中心](HAR提取CK与积分中心.md)。 + ## `_qimei_fingerprint` 算法 QIMEI 0.6.3 的 Canvas `id` 和 `geometry` 是 `getFeature()` 的两个字段,但 `_qimei_fingerprint` 使用的是另一条浏览器能力特征串: @@ -55,7 +59,8 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be - [网页字段溯源](HUYA_WEB_FIELD_TRACE.md) - [Cookie 合并审计](HUYA_COOKIE合并审计与来源矩阵.md) -- [浏览器抓包报告](../evidence/browser_cookie_harvest/report.md) +- 浏览器抓包报告属于本地 evidence,不随仓库分发;需要复现时运行 + `core/huya/browser_harvest.py`。 - [虎牙精英宝典联调指南](HUYA精英宝典联调指南.md) ## `_qimei_uuid42` VM 复刻(2026-09-02) diff --git a/docs/虎牙/HUYA_HDID_ALGORITHM_GEN.md b/docs/虎牙/HUYA_HDID_ALGORITHM_GEN.md index 8690f86..6c4cb02 100644 --- a/docs/虎牙/HUYA_HDID_ALGORITHM_GEN.md +++ b/docs/虎牙/HUYA_HDID_ALGORITHM_GEN.md @@ -5,7 +5,7 @@ # 虎牙 32hex hdid 算法生成路径分析(不用采集,纯算法铸币) -> 前置结论(见 `HUYA_HDID_RESEARCH.md`):32hex hdid(WUP 登录帧 field1.tag0)= hdid(WUP 登录帧 field1.tag0)= +> 前置结论(见 [HDID 研究记录](HUYA_HDID_RESEARCH.md)):32hex hdid(WUP 登录帧 field1.tag0)= > `com.huya.security.hydeviceid.NativeEntry.getGUID()`;真机 M2102J2SC 金值 > `0a7dfaa882938a6ab502511452142c57`;getGUID 是 getter,值在 `init()`(偏移 0x20e6e4) > 时由 `libhydeviceid.so`(OLLVM 混淆 + datadiv 壳)算出并缓存。 @@ -1632,14 +1632,14 @@ APK 内嵌: appkey 865a..c798 (datadiv 加密 .data) ──运行时解密── - **端到端伪造 = 纯离线**: JSON 构造 + tools/dfp_rc4.py (key 为常量), 无需设备! ## §11.68 终态索引 (2026-08-29) -全流程定案总结移至 `docs/登录系统全流程定案.md`: 三库架构 / 常量总账 / 登录链 / dfpReport 链 / +全流程定案摘要并入 [App 协议总览](HUYA_APP_OVERVIEW.md): 三库架构 / 常量总账 / 登录链 / dfpReport 链 / 纯协议无卡点结论 + 一号一设备方案。核心: dfpReport RC4 key = APK 内嵌常量 (datadiv@3912240), 纯协议登录链 (dfp_register → app_login) 已无设备依赖。 ### HDID32(t1.t0) "固定与否" 终审 (2026-08-29, 证据链) 问题缘起: R14 时代结论"设备级值/服务端硬锚/纯Python不可铸"与 R15 定案"app 版本级常量"并存, 文档表述不一致。终审证据链: -1. 真机抓包 (虎牙App密码登录-WUP明文抓包.md): field1{tag0=ed0db8, tag1="13.4.22"(App版本), +1. 真机抓包(WUP 明文,历史证据见 Git 历史): field1{tag0=ed0db8, tag1="13.4.22"(App版本), tag2=SDK版本, tag5=厂商} — t1.t0 与 app 版本同结构体 2. R15 公式逐字节复现: md5("5008_13.4.22_" + k1) = ed0db8334cadd236c00cadf7e11ab5a5 3. k1 = datadiv 内嵌编译期常量 (R39 字符串表实测), base.apk 内 libhydeviceid.so 全用户相同 diff --git a/docs/虎牙/HUYA_精英宝典-8.31-PY重新分析.md b/docs/虎牙/HUYA_精英宝典-8.31-PY重新分析.md deleted file mode 100644 index b08d006..0000000 --- a/docs/虎牙/HUYA_精英宝典-8.31-PY重新分析.md +++ /dev/null @@ -1,128 +0,0 @@ -# 虎牙精英宝典 HAR 重新分析(Python) - -## 1. 样本与结论 - -| HAR | SHA-256 | entries | 结论 | -|---|---|---:|---| -| `手机扫码登录-pc直播间-1.har` | `bcb1e830bfc368c9029f85f90458b708dcf086993a79b833e8e544c65e5c679f` | 1947 | 扫码登录、PC 直播间初始化、登录态建立 | -| `pc直播间-精英宝典1.har` | `f8d13a837c477aa4dc55b27df6828ffb7840bbdcccf7c078f840164d62a00e07` | 580 | 精英宝典初始化、绑定、任务、奖品、积分、记录、商城前置 | - -页面地址为 `https://zt.huya.com/b02faae1/pc/index.html`。活动固定业务参数为 `sid=2203`、`actId=25135`、绑定活动 `bActId=9271`、任务模块 `moduleId=20051`、游戏 `cjm`、组件 `gid/componentId=3203`。 - -核心结论:协议是 WSS 二进制通道承载的 WUP/TAF RPC,HTTP `cdnws.api.huya.com` 可作为现有 Python 客户端的兜底。样本没有捕获积分兑换成功请求、兑换详情响应或支付完成闭环,因此这些字段不能从 HAR 推测。 - -## 2. 传输层与帧解码 - -- 活动页主通道:`wss://wsapi.huya.com/?baseinfo=<动态值>`。 -- 直播间/绑定入口还使用 `wss://d35bf35c-ws.va.huya.com/?baseinfo=<动态值>`。 -- HTTP 兜底:`POST https://cdnws.api.huya.com/?baseinfo=<动态值>`,请求体为 WUP 包。 -- HAR 的业务消息在 entry 的 `_webSocketMessages`,不是普通 HTTP entry body。 -- WebSocket 帧前 6 字节是通道头(command、sequence),后续 body 再提取 WUP;WUP body 从 `0x10 0x03` 字段开始。仓库的 `WssMessage.decode()`、`frame_decoder._extract_wup()` 和 `WupResponse.decode()` 可以复用。 -- `launch.wsLaunch`、`mobileui.getConfig` 完成连接初始化;业务 WUP `requestId` 在同一会话中递增,不能写死 HAR 值。 - -## 3. 请求链 - -精英宝典 WebSocket entry 75 的可复现顺序如下: - -1. `launch.wsLaunch` -> `mobileui.getConfig`。 -2. `GameBizUI.getQiWeiRelationByUid`(页面辅助信息)。 -3. `webActUI.checkUserBindGameAccount(bActId=9271)`,返回和平精英、微信/QQ 平台、角色绑定状态。 -4. 并行查询:`getActPrizeList(sid=2203)`、`getActInfo(actId=25135)`、`getActTaskDetail(actId=25135)`。 -5. `getActUserTaskDetail(userId, actId=25135)`、`getUserScore(sid=2203)`。 -6. 绑定流程:再次检查绑定 -> `huyauserui.getUserProfileBatch` -> `getLiveLinkParam(bActId=9271)` -> `confirmBindActAccount(bActId=9271)` -> 再次检查绑定。 -7. 记录页:`getUserPrizeRecords(sid=2203)` -> `getEntityPrizeFieldMap` -> `getModuleAddress(moduleId=20051)`。 -8. 商城前置:`shopMiddleUI.getGoodsInfoV5`;确认页还调用 `getSupplierInfoV5`、`listPayChannelV5`、`checkHyProtocolV5`、`PlayMallUI.getMyPromotion`、`calcOrderPromotion`。 -9. 页面会重复刷新 `getActPrizeList` 与 `getUserScore`;轮询间隔由工作台自行配置,建议 10-30 秒,写操作后只做一次短轮询。 - -第一份 HAR 的扫码登录链为: - -```text -GET /web/middle/2.6/.../https/... -POST /qrLgn/getQrId -GET /qrLgn/getQrImg?k=&appId=5002 -POST /qrLgn/tryQrLogin # stage=0 -> stage=1 -> success -POST /web/cookie/verify -``` - -成功登录后得到的 UID、Cookie、`baseinfo` 必须传入同一个 WSS/HTTP 会话。二维码 ID、requestId、context、biztoken、guid、`udb_cred`、`sdid`、时间戳和签名均为动态值,报告不固化样本机密值。 - -### 参数分类 - -| 参数 | 类型 | Python 处理 | -|---|---|---| -| `sid/actId/bActId/moduleId/gid` | 业务参数 | 配置项,默认值只对应本次页面 | -| `uid`、游戏账号/角色 | 用户输入或前序响应 | 从当前登录态或绑定响应读取 | -| Cookie、`baseinfo`、WSS session | Token/Session | `HuyaHttpClient` 运行时构造并保持 | -| `qrId/requestId/context/traceId` | 随机/请求序号 | 登录和 RPC 运行时生成 | -| `t/code/sig`、`biztoken`、`udb_cred`、`sdid` | 动态签名/认证 | 只转发前序响应或 Cookie;算法未闭合,不重算 | -| `startTime/endTime/leftNum/isCanExchange` | 服务端状态 | 每次查询覆盖本地快照,不写死 | - -HTTP 请求保留 `User-Agent`、`Origin`、`Referer`、`Content-Type: application/octet-stream` 和 `Accept-Language`;`Host`、`Content-Length`、`Connection` 由 HTTP 库生成。WSS 的 `baseinfo` query 和二进制 body 由会话客户端生成,不复制浏览器历史头。 - -## 4. 业务响应复核 - -### 活动信息 - -`getActInfo`:`status=200`,`actId=25135`,名称“活动+购买任务”,时间戳 `1782662400..1790783999`,任务模块 `20051`,外部任务活动 `17096`,游戏 `cjm`,组件 `3203`。`17096` 与绑定用的 `9271` 是不同字段,代码中必须分开命名。 - -### 奖品列表 - -`getActPrizeList` 在 entry 75 的多次响应均返回 **29 项**、2 个分类:`91=限量返场`(11 项)和 `83=精英专享`(18 项)。示例: - -| pid | 名称 | 积分 | 分类 | -|---:|---|---:|---:| -| 13161 | 破片手榴弹-粉粉猫爪大礼包 | 3600 | 91 | -| 13163 | 烟雾弹-猫咪绅士大礼包 | 3600 | 91 | -| 13167 | 套装-幸运萌趣趣 | 1980 | 91 | -| 13165 | 套装-奇妙萌趣趣 | 688 | 91 | -| 13169 | 套装-幸福萌趣趣 | 1680 | 91 | -| 12873 | 套装-青涩年华 | 488 | 83 | -| 12867 | 套装-雪山精英 | 600 | 83 | -| 12879 | 套装-浪漫波比 | 1280 | 83 | -| 12865 | 幸运币礼包(大) | 900 | 83 | - -完整结构还包含 `id/sid/name/score/type/img/sort/frequency/frequencyLimit/num/status/percent/newScore/tags/leftNum/isShowNum/usedNum/goodType/detailImg/detailDesc/whiteUser/commonPrizeId/updateTime/isCanExchange/exchangeStartTime/exchangeEndTime/startTimeSlot/endTimeSlot/isTodayLimit/isUserLimit`。库存、排序和可兑换状态在重复轮询中变化,不能硬编码;工作台应以最新响应覆盖快照。 - -### 任务与用户进度 - -`getActTaskDetail` 返回 6 个任务: - -- 购买精英令 *100,奖励 10000 分,SPU `hy-5874270`。 -- 购买精英令 *10,奖励 1000 分,SPU `hy-5874269`。 -- 购买精英令 *1,奖励 100 分,SPU `hy-5879346`。 -- 购买精英徽章,奖励 10 分,SPU `hy-5872824`。 -- 每天观看和平精英直播 10 分钟,奖励 2 分,任务类型 56。 -- 订阅和平精英福利提醒,奖励 5 分,任务类型 68。 - -`getActUserTaskDetail` 的每项至少有 `actId/taskId/taskStatus/prizeStatus/taskValue/taskCount/prizeCount/subTaskDetail`,它是展示任务进度、完成和领取状态的来源。 - -### 积分 - -响应字段顺序为 `status/msg/gainScore/usedScore/newGainScore/newUsedScore/score/isOpenExchangeTip`。样本值为 `4200,4200,4200,4200,0,0`;页面逻辑优先使用 `max(0, newGainScore-newUsedScore)`,所以当时可用积分为 **0**,不能把任意一个累计字段直接当余额。 - -### 绑定、记录与地址 - -- 绑定响应显示游戏“和平精英”,账号平台可为微信或 QQ,`isBindAccount`、`isBindRole`、`isNeedActCheck` 和 `changeBindTime` 随状态变化。 -- `getLiveLinkParam` 返回 `actId=17096`、`gameIdList=cjm`、`livePlatId=huya`、动态 `t/code/sig`。样本不足以闭合 `code/sig` 算法,不实现猜测签名。 -- `confirmBindActAccount` 样本为 `status=200,msg=请求成功`。 -- `getUserPrizeRecords` 返回 2 条历史记录,字段包括 `id/name/score/createTime/status/type/isReal/newScore/account/code/commonPrizeType/commonPrizeStatus/orderId/img/goodType/prizeId/recordId/commonPrizeExchangeStatus/commonPrizeId/prizeExtraParam/ymd/commonPrizeMsg`。 -- `getEntityPrizeFieldMap` 给出实体奖品字段映射:模块 1 为姓名/手机/地址,模块 38 为 QQ,模块 39 为姓名/手机;`getModuleAddress(moduleId=20051)` 返回 `status=201,msg=找不到地址`。 - -## 5. Python 实现边界 - -仓库现有 `core/huya/http_client.py` 已实现 WUP/TAF、会话 Cookie、积分、奖品列表、记录、任务、绑定、直播链接和商城方法;新增示例见 [`scripts/huya_elite_client.py`](../scripts/huya_elite_client.py)。示例不读取 HAR,运行时从环境变量读取 UID/Cookie,调用顺序为:活动信息/任务/用户任务/绑定/奖品/积分/记录,写操作前重新查询奖品详情。 - -推荐状态机: - -```text -登录态 -> getActInfo/getActTaskDetail - -> checkBind/getActPrizeList/getActUserTaskDetail/getUserScore - -> (用户确认后) getActPrizeDetail -> scoreExchangePrize - -> getUserScore/getActPrizeList/getUserPrizeRecords 回查 -``` - -兑换必须二次校验 `isCanExchange`、活动时间、库存、频次、今日/用户限制和可用积分。`getActPrizeDetail`、`scoreExchangePrize` 成功响应以及地址写入尚未出现在样本中,客户端应把它们作为显式待补接口,而不是猜测字段。 - -## 6. 尚缺证据与补抓清单 - -当前 HAR 未闭合:`getActPrizeDetail` 响应、`scoreExchangePrize` 成功请求/响应、`addModuleAddress`、`createOrderV5`、`payOrderSubmitV5`、支付完成后的订单状态和宝典开通字段。需要重新抓“点击兑换”和“点击开通宝典至支付完成”的完整操作,并保留 Initiator、页面 JavaScript 和同一登录态。 diff --git a/docs/虎牙/HUYA_精英宝典-9.1-全流程分析.md b/docs/虎牙/HUYA_精英宝典-9.1-全流程分析.md deleted file mode 100644 index f00a8a9..0000000 --- a/docs/虎牙/HUYA_精英宝典-9.1-全流程分析.md +++ /dev/null @@ -1,81 +0,0 @@ -# 虎牙精英宝典 9.1 全流程分析(Python / 脱敏) - -branch/field: HUYA_ELITE_FULL_FLOW_9_1 - -## 1. 样本与完整性 - -- 主样本:`/Users/yml/Desktop/抓包/hy/9.1/9.1` -- SHA-256:`807fb951f9daa21e77f33618f1409a99b9b44da829966f328ec8b0614f599d55` -- 格式:Charles 会话 ZIP 归档,不是普通 HAR。 -- 归档成员 2327;`*-meta.json` 1472;请求体 552;响应体 303。 -- 状态:COMPLETE 1443,EXCEPTION 16,RECEIVING_REQUEST_BODY 12,RECEIVING_RESPONSE_BODY 1。 -- 结论:该包覆盖扫码登录后的 Web 会话、宝典初始化、游戏账号绑定、积分兑换、商城下单、支付宝二维码和支付后订单状态;比旧的 Reqable/Charles 样本完整。 - -`9.1_all` 适合静态资源检索,但没有统一 flow、headers、状态和时序索引。协议重建以 ZIP 为主,静态页面以 `1197-res.html` 等正文为辅。 - -## 2. 业务固定值与动态值 - -本次页面确认的业务值:`sid=2203`、宝典 `actId=25135`、绑定活动 `bActId=9271`、任务模块 `moduleId=20051`、外部联运活动 `17096`、游戏 `cjm`、组件 `gid=3203`、宝典商品 `spu=hy-5879340`、商品 SKU `5370360`、供应商 `87401`。 - -运行时必须重新取得:UID、Cookie、WSS `baseinfo`、WUP requestId、wsLaunch guid、游戏账号信息、`getLiveLinkParam` 的 `t/code/sig`、支付宝 payOrderId/ctoken、订单号和二维码状态。样本中的这些动态值不能硬编码,也不能用旧 Cookie 重放。 - -## 3. 登录与宝典初始化 - -1. 扫码登录链在登录样本中完成后,`POST /web/cookie/verify`(entry 859)返回 HTTP 业务成功结构,随后浏览器访问宝典页面(entry 1197,HTTP 200)。 -2. 宝典主 WSS 为 `d35bf373-ws.va.huya.com`(entry 828),完成 `launch.wsLaunch`、`mobileui.getConfig`、心跳/确认和业务 RPC。 -3. 主链首先调用 `checkUserBindGameAccount`、`getActInfo`、`getActUserTaskDetail`、`getUserScore`、`getUserPrizeRecords`、`getEntityPrizeFieldMap`、`getModuleAddress`、`getLiveLinkParam`。 -4. `getActInfo` 返回 status 200,活动名“活动+购买任务”,活动时间戳区间、模块 20051、外部活动 17096、游戏 cjm 和组件 3203。 - -WSS 帧为二进制 WebSocket payload,前 6 字节是 command/sequence,body 是 WUP/TAF。项目已有 `WssMessage.decode()` 和 `_decode_wup_body()`,同一连接的 requestId 运行时递增;不要把 frame sequence 或样本 requestId 当业务主键。 - -## 4. 积分兑换闭环 - -### 4.1 绑定和前置查询 - -- 首次绑定响应显示游戏“和平精英”;绑定状态会经历未绑定、已绑定和确认后状态,`confirmBindActAccount` 返回 `status=200,msg=请求成功`。 -- `getLiveLinkParam` 返回外部活动 `17096`、`gameIdList=cjm`、`livePlatId=huya` 和动态 `t/code/sig`。签名算法证据不足,客户端只转发实时响应。 -- `getActPrizeDetail(sid=2203,pid=12861)` 返回奖品“幸运币礼包(小)”,兑换积分 20,库存/时间/限制字段来自服务端。 - -### 4.2 写入与回查 - -请求:`webActUI.scoreExchangePrize`,字段为 `userId`、`sid=2203`、`pid=12861`、`ip`、`clientEnv`、`source`、`isRole=1`。本样本的空 `ip/source/clientEnv` 是一次浏览器操作结果,不应成为固定规则。 - -响应:`status=200,msg=请求成功`,返回兑换订单标识和 `exchangeInfo(pid=12861,exchangeScore=20,desc=兑换奖品:幸运币礼包(小))`。敏感订单标识已省略。 - -写入后浏览器按顺序回查 `getUserScore`、`getUserPrizeRecords`、`getEntityPrizeFieldMap`、`getModuleAddress`。实测积分从可用 300 变为 280,兑换记录新增该奖品、20 分和当日日期;这证明兑换链在该包中闭合。UI 应以回查值为准,并对 `isCanExchange`、活动时间、库存、今日/用户限制和可用积分做二次校验。 - -## 5. 开通宝典与支付闭环 - -商城 WSS 为 `wsapi.huya.com`(entry 1431),初始化后业务顺序为: - -1. `getGoodsInfoV5(pid=直播间上下文, spuId=hy-5879340, skuId=5370360)`:商品名“精英宝典”,说明“得300积分丨解锁道具兑换权益”,标价 3000(分);响应关联活动绑定信息和 `bActId=9271`。 -2. `getSupplierInfoV5(supplierId=87401)`:返回“和平精英官方小店”。 -3. `listPayChannelV5`:可用渠道为 `Zfb`、`Weixin`。 -4. `checkHyProtocolV5`:协议检查成功,状态 200。 -5. `PlayMallUI.getMyPromotion`、`calcOrderPromotion`:本单无返利,订单金额为 3000 分/人民币 30.00 的商城商品金额。 -6. `checkUserBuyAuth`:状态 200,允许创建订单。 -7. `createOrderV5`:状态 200,返回商城内部订单标识、支付前订单状态和应付金额;样本值已脱敏。 -8. `payOrderSubmitV5(order, channel=Zfb, payType=QrCode)`:状态 200,返回支付宝网关跳转 URL、支付流水标识和回调地址。URL 中包含 RSA2 签名,客户端应把它当作服务端生成的短期跳转值,不能自行拼接或重签。 -9. `orderDetailV5(orderId)` 多次轮询:前期订单状态为待支付,支付确认后变为完成状态(状态码 50),支付金额保持 3000;`batchQueryBuyAfterConfig` 随后返回商城后置配置。 -10. 支付 HTTP 链:支付宝网关 -> `unitradeprod.alipay.com/appAssign.htm` -> `excashier.alipay.com/standard/auth.htm` -> `queryQRStatus.json`。首次查询返回 `qrStatus=normal`,后续查询返回已登录/已确认状态(`qrStatus=2`),二维码状态由支付宝控制,不能由客户端伪造。 - -订单状态 50 是商城订单完成证据;宝典页面的购买任务积分/权益应再调用 `getUserScore`、`getActUserTaskDetail` 和相关宝典查询确认,不能仅凭支付宝页面成功推断权益到账。 - -## 6. Python 实现建议 - -仓库现有 `core.huya.wss_client.HuyaWssClient`、`core.huya.http_client.HuyaHttpClient`、`activity_structs.py` 和 `shop_structs.py` 已覆盖 WUP/TAF 结构及大部分查询。`scripts/huya_elite_client.py` 可作为查询入口: - -```python -client.snapshot() # 活动、任务、绑定、奖品、积分、记录 -# 用户明确确认后:实时 getActPrizeDetail -> scoreExchangePrize -> 三项回查 -# 开通:getGoodsInfoV5 -> checkUserBuyAuth -> createOrderV5 -# -> payOrderSubmitV5 -> orderDetailV5 轮询 -> 宝典权益回查 -``` - -建议将状态机拆为 `login_session`、`elite_snapshot`、`exchange`、`purchase` 四步;所有动态字段从前序响应传递,错误时保留服务端 status/msg,不猜测签名算法。写操作默认幂等保护:同一用户、同一 pid 在收到成功响应后不重复提交,只做回查。 - -## 7. 证据边界 - -- 归档已证明兑换成功和商城订单完成,但没有提供支付宝私钥、服务端签名算法或可脱离当前登录态的重放凭据。 -- `getEntityPrizeFieldMap`/`getModuleAddress` 在本次兑换奖品上返回无地址,不代表所有实物奖品都无需地址;实际奖品类型必须按最新响应决定。 -- 订单完成到宝典积分到账可能存在异步延迟,工作台应轮询业务接口而非只看支付状态。 diff --git a/docs/虎牙/HUYA_精英宝典-后端与9.1抓包对照.md b/docs/虎牙/HUYA_精英宝典-后端与9.1抓包对照.md deleted file mode 100644 index 201a79a..0000000 --- a/docs/虎牙/HUYA_精英宝典-后端与9.1抓包对照.md +++ /dev/null @@ -1,125 +0,0 @@ -# 虎牙精英宝典后端与 9.1 全流程抓包对照 - -## 结论 - -当前后端不是“完全不能用”,而是保留了旧版 HTTP/WUP 兜底链路;最新浏览器链路已经切换为两个长连接 WSS:活动/绑定通道和商城/支付通道。查询和下单的多数字段与抓包兼容,真正需要优先更新的是兑换闭环、支付后订单状态和权益回查。 - -## 对照矩阵 - -| 优先级 | 领域 | 9.1 实际链路 | 当前后端 | 判断 | 更新动作 | -|---|---|---|---|---|---| -| P0 | 支付监听 | `orderDetailV5(orderId)` 重复查询,状态最终为 50 | `queryUserOrderList`,同时尝试 `shopMiddleUI/revenueWebUI` | 不一致 | 增加 `orderDetailV5` 结构和方法,按订单号轮询;以订单状态 50/已支付时间作为完成依据 | -| P0 | 支付超时 | 未支付仍是待支付/取消状态 | `huya_runner_recharge.py:535-537` 将 timeout 标为 `success` | 业务错误 | 超时改为 `pending` 或 `timeout`,不能写成功;前端显示“等待支付超时” | -| P0 | WSS 会话 | 活动通道 `d35bf373-ws.va.huya.com`,商城通道 `wsapi.huya.com`;每个会话有 launch、register、confirm、递增 requestId | `HuyaBatchRunner` 全部调用 `HuyaHttpClient` 的 `cdnws.api.huya.com` 单次 HTTP | 架构不一致 | 优先实现可复用的 WSS session;HTTP 保留为显式 fallback,并记录 fallback 原因和重试次数 | -| P1 | 商城身份字段 | 最新商城业务 `UserId.sHuYaUA=web&1.0.0&huya`,`sGuid` 为空 | HTTP `_build_user` 使用 `webh5&0.0.1&websocket...`,并从 Cookie 派生 `sGuid` | 字段不一致 | 商城 WSS 优先;HTTP fallback 也应拆分商城 UserId,按最新 UA/空 guid 构造 | -| P1 | 兑换前置 | `getActPrizeDetail(sid=2203,pid)`,随后才 `scoreExchangePrize` | `huya_runner_goods.py:327-329` 直接提交兑换 | 缺步骤 | 增加 `GetActPrizeDetailReq/Resp`;提交前校验 `isCanExchange`、活动时间、库存、今日/用户限制和积分余额 | -| P1 | 兑换回查 | 成功后依次回查 `getUserScore`、`getUserPrizeRecords`、`getEntityPrizeFieldMap`、`getModuleAddress` | 成功后立即结束任务 | 缺步骤 | 将扣分、记录和地址状态写入任务结果,并更新 `HuyaAccount.points` | -| P1 | 活动初始化 | `getActInfo`、`getActUserTaskDetail`、`getUserScore`、`getUserPrizeRecords` 等并行查询 | `query_act_tasks` 只调用 `getActTaskDetail` | 信息不完整 | 增加 snapshot 任务或让页面初始化批量创建这些查询任务 | -| P1 | 权益到账 | 支付完成后仍需回查宝典积分/用户任务 | 只回查商城订单列表 | 信息不完整 | 订单完成后调用积分和用户任务接口,明确“已支付”和“宝典权益到账”两个状态 | -| P2 | 商品详情 | `getGoodsInfoV5` 直接返回 `spu=hy-5879340`、`sku=5370360`、场景 4 | 当前会先从任务详情发现 SPU,再以 sku=0 查询详情 | 兼容但脆弱 | 固定业务默认值仅作候选;优先使用实时商品详情返回的 SKU,不依赖任务类型 67 | -| P2 | 商品详情 gameId | 最新 `getGoodsInfoV5` 请求的 `gameId` 为空字符串 | `huya_runner_recharge.py:385-395` 传入 `game_id="0"` | 细节不一致 | 查询商品详情改为空字符串;`createOrderV5` 的 `gameId="0"` 仍保持不变 | -| P2 | 下单字段 | `orderType=6`、`src=4`、`orderScene=4`、`bizType=5`、`gameCategoryId=507`、`gameId="0"` | `create_order` 已按相同字段写入 | 基本一致 | 保留,增加请求快照脱敏日志以便字段回归 | -| P2 | 支付提交 | `payOrderSubmitV5(order,Zfb,QrCode,callback,env)` 返回支付宝网关 URL | 当前 HTTP 手工构造 WUP,字段基本一致 | 兼容但非同链路 | 迁移到商城 WSS;未迁移前保留 HTTP fallback | -| P2 | 支付渠道 | 先 `listPayChannelV5`,返回 `Zfb/Weixin` | 直接使用配置渠道 | 缺运行时校验 | 下单前读取可用渠道,不支持配置值时回退或报错 | -| P2 | 配置 | 本次活动 `sid=2203`、绑定活动 `9271`、外部活动响应为 `17096` | `outer_act_id` 默认 `9504` 且执行器未实际使用 | 配置过期/死字段 | 默认值改为当前活动值或删除该字段;将外部活动 ID 从 `getLiveLinkParam` 响应传递,不手填签名 | -| P2 | 数据隔离 | 业务响应随账号、绑定状态和库存变化 | `HuyaGoodsSnapshot`、`HuyaRechargeGoodsSnapshot` 为全局快照 | 并发风险 | 至少按 `sid/spu` 隔离;兑换可用性必须以执行账号的实时查询为准 | - -## 已对上的部分 - -- `ActivityUserId` 的 `lUid`、`sHuYaUA`、Cookie 前缀和空 Token 形态与 9.1 活动帧兼容。 -- `scoreExchangePrize` 的主体字段 `userId/sid/pid/ip/clientEnv/source/isRole` 已有结构,样本中后三项为空、`isRole=1` 与默认值一致。 -- 商城 `CreateOrderReqV5` 已覆盖抓包中的完整 tag 0-32;`orderType=6` 和场景 4 正确。 -- `payOrderSubmitV5` 的回调 URL、`Zfb`、`QrCode` 和环境 map 已覆盖抓包字段。 -- `CreateOrderReqV5` 的下单 `gameId="0"` 与抓包一致;只有商品详情查询的 `gameId` 应为空字符串。 -- Cookie、baseinfo、WUP requestId、支付宝订单和签名没有被当作固定常量,方向正确。 - -## 推荐更新顺序(按依赖关系) - -之前的顺序有两个问题:把支付状态修复放在协议补齐之前,且把 WSS 迁移放在写操作之后。这样会先扩大旧链路的行为面,无法判断是业务字段还是传输方式导致失败。建议按下面的依赖顺序推进,每一步都能独立验证和回滚。 - -### 0. 固化 9.1 契约和回归样本 - -整理脱敏的请求/响应 fixture,明确活动通道、商城通道、WUP tag、状态码和动态字段边界。先不改线上任务行为,只增加解码测试。验收条件:9.1 中的关键 RPC 能稳定解码,动态 Cookie、签名、订单号不会进入 fixture。 - -### 1. 先修配置和身份模型 - -将 `sid=2203`、`bindActId=9271`、`outerActId=17096`、`moduleId=20051`、`scene=4`、`sourceId=yellowcarlist` 统一为活动配置;商城 UserId 与活动 UserId 分开,商城使用最新 `sHuYaUA`/空 `sGuid` 规则。此步只影响参数构造,不启用新写操作。 - -### 2. 补齐只读协议方法和响应结构 - -优先实现并测试: - -```text -getActInfo -getActUserTaskDetail -getActPrizeDetail -getEntityPrizeFieldMap -getModuleAddress -listPayChannelV5 -orderDetailV5 -``` - -只读方法齐全后,才能用服务端实时数据做兑换和支付前置判断;不要先改 `scoreExchangePrize` 或支付提交。 - -### 3. 建立统一传输层,再做只读灰度 - -抽象活动 WSS session 和商城 WSS session,复用 launch/register/confirm、requestId FIFO、断线和超时处理。先让查询接口走 WSS,失败时明确记录原因并 fallback 到 HTTP;连续验证成功后再切换写接口。不要在每个 runner 中分别临时建 WebSocket。 - -### 4. 先做快照和数据隔离 - -页面初始化改为一次读取活动信息、用户任务、积分、奖品和记录;商品/充值商品快照至少按 `sid + spu/pid` 隔离,执行账号始终以实时详情覆盖快照。此步完成后 UI 可以展示真实状态,但仍不触发兑换或支付。 - -### 5. 重写兑换状态机 - -严格按以下顺序: - -```text -实时 getActPrizeDetail - -> 校验 isCanExchange/活动时间/库存/今日限制/用户限制/可用积分 - -> scoreExchangePrize - -> getUserScore + getUserPrizeRecords - -> getEntityPrizeFieldMap + getModuleAddress -``` - -成功、已兑换、余额不足、库存不足、需要地址和未知错误分别落库;收到成功响应后使用幂等键阻止重复提交。只有四项回查完成,任务才标记为兑换完成。 - -### 6. 重写开通和支付状态机 - -严格按以下顺序: - -```text -getGoodsInfoV5 - -> listPayChannelV5 - -> checkHyProtocolV5 / checkUserBuyAuth - -> createOrderV5(orderType=6) - -> payOrderSubmitV5(Zfb|Weixin, QrCode) - -> 立即 orderDetailV5 轮询 - -> 支付完成后回查宝典积分和用户任务 -``` - -`orderDetailV5` 必须先于 runner 改造完成;`queryUserOrderList` 只能作为兼容 fallback。支付状态拆成 `created/pending/paid/timeout/cancelled/error`,超时绝不能标记 `success`。二维码生成后任务保持 `running/pending`,不能因为 runner 线程尚未结束而隐藏二维码。 - -### 7. 最后接入 UI 和批量执行 - -UI 只调用统一任务 API:查询使用快照任务,兑换和开通使用状态机任务;通过任务 WebSocket 推送二维码、订单状态和最终权益状态。先单账号灰度,再开启并发批量,避免旧的全局商品快照污染多个账号。 - -### 8. 清理旧链路 - -连续灰度验证通过后,删除 `create_order` 的 orderType 猜测和支付列表轮询主路径;保留 HTTP fallback、失败原因、超时和回滚开关。最后再迁移旧版虎牙任务入口,避免影响非精英宝典功能。 - -## 不应照搬抓包的字段 - -UID、Cookie、guid、baseinfo、WUP requestId、支付宝 payOrderId、ctoken、二维码 URL、`t/code/sig`、用户点击时间和 RSA2 签名均为运行时动态值。它们只能来自当前登录态、前序响应或运行时生成,不能写入默认配置或测试 fixture。 - -## 本轮已实施 - -- 增加 `getActInfo`、`getActUserTaskDetail`、`getActPrizeDetail` 和详情响应结构。 -- 兑换提交前读取实时详情和积分,校验可兑换状态、库存、时间窗口和余额;成功后回查积分和兑换记录。 -- 增加 `orderDetailV5` 请求/响应结构,支付监听优先按订单号查询,旧订单列表作为 fallback。 -- 支付超时改为终态 `timeout`,不再误报成功;支付完成后回查活动积分。 -- 支付前增加 `checkHyProtocolV5`、`listPayChannelV5` 和 `checkUserBuyAuth`,不再直接跳过商城前置校验。 -- 商城开通任务优先使用复用的 WSS session,HTTP 仅作为连接/初始化失败时的 fallback;活动查询和兑换任务同样优先使用活动 WSS。 -- 商城 HTTP fallback 的 `UserId` 改为 9.1 商城 UA/空 guid 形态;商品详情 `gameId` 改为空字符串,下单仍使用 `gameId="0"`。 -- 外部活动默认值由旧值更新为 `17096`,已有旧配置会在读取时迁移。 - -仍待下一轮实施:补齐 `getMyPromotion`、`calcOrderPromotion` 的显式调用;按账号隔离商品快照;绑定流程的活动 RPC 迁移到同一活动 WSS session(外部 livelink HTTP 保持独立)。 diff --git a/docs/虎牙/HUYA_铸币机-终局总结.md b/docs/虎牙/HUYA_铸币机-终局总结.md deleted file mode 100644 index bb5431f..0000000 --- a/docs/虎牙/HUYA_铸币机-终局总结.md +++ /dev/null @@ -1,61 +0,0 @@ -# 虎牙设备铸币机 — 终局总结 (2026-08-28/29, goal rounds 1-10) - -> 命名规范前置: GUID32=doLaunch sGuid | HDID32=登录帧t1.t0.appSign(32hex设备证书) | DEVID40=注册链t5/t2.t8 | MID16=mid | 规范详见 HUYA_ID_NOMENCLATURE.md - -## 一、终局验收矩阵 (3 测试账号, 密码 aa778899) - -| 账号 | 铸币 GUID32 作 hdid | 金样本 HDID32 作 hdid | -|---|---|---| -| hy_300030430 | ❌ APP_SIGN_NOT_MATCH (652B) | ⚠️ RISK-slider (2094B, 签名通过) | -| hy_300024708 | ❌ APP_SIGN_NOT_MATCH (652B) | ⚠️ RISK-slider (2094B) | -| hy_300029555 | ❌ APP_SIGN_NOT_MATCH (652B) | ⚠️ RISK-slider (2107B) | - -3 账号 × 2 路径全部一致复现 —— 服务端接受度全貌定案: -- **GUID32 (doLaunch 铸币产物) 永远不是合法登录 hdid** (APP_SIGN 硬拒) -- **金样本 HDID32 签名通过** → 进入风控滑块 (fresh 账号首登; 滑块 solve → cred, - 历史实证过: dfpReport 矩阵 "fingerprint 随机 → slider → solver → cred ✅") - -## 二、目标达成状态 (如实) - -### ✅ 达成 (纯 Python 可用, 全部在线复现) -1. **GUID32 铸币机**: doLaunch 按 MID16 确定性签发 sGuid (5 mid × 多跑全同值, - 含任意 mid=aabbccdd00112233) —— tools/huya_launch_mint.py::mint_sguid ✓ -2. **零设备注册链**: getDfpConfig→selectOperator→dfpReport, 随机加密体一律 200, - 每账号新鲜 ACTION + DEVID40 —— 零设备、无真机 ✓ -3. **登录管线**: 金样本 HDID32 + 新鲜 ACTION → 1571B cred (多次复现; 金样本账号级连出 cred) ✓ -4. **原生加密复刻**: encode_aes@0x330218 纯 Python 逐指令复刻, 15/15 位精确 - (AES-128 变异: 64B 钥材取[0..15]+4×4转置+列旋转链扩展+无独立ShiftRows) - —— tools/huya_aes_replica.py ✓ -5. **device_mint.py 编排器** + 命名规范全库落地 ✓ - -### ❌ 未达成 (边界如实) -**HDID32 独立铸造**: 登录帧 t1.t0.appSign (32hex 设备证书) = native 私有派生 -(AESkeyMgr.getkey 双 id 派生钥 × 设备材料明文, AES 密文 16B) —— -{派生钥, 明文格式} 经多轮爆破 (钥族×转置×b64×md5×明文组合 ×10³) + decode_aes 直解 -全部零命中; 无上行明文通道 (§11.10 全链路不可见)。→ 纯代码不可达 (当前技术)。 - -## 三、落地模式 (deployable) -``` -每账号: MID16(任意铸) → doLaunch → GUID32(铸币) [身份旁证] - 随机加密体 → 注册链 → ACTION+DEVID40 [登录授权, 每账号新鲜] - 共享金样本 HDID32 (ed0db8…) [唯一签名锚, 全账号复用] - → hypasswordLogin → 风险滑块 → solve → cred -``` -- ✅ 每账号独立: GUID32/ACTION/DEVID40/fingerprint 全独立 -- ⚠️ 共享: HDID32 (服务端可设备维度关联账号 —— 唯一代价) -- 若需真隔离: 真机/模拟器设备池 (每池一 HDID32) 或 unidbg 全链native仿真 - (getkey 派生钥 + 明文序列化 = 未来的进阶攻坚点) - -## 四、未来可攻路线 (按价值排序) -1. **getkey 派生钥提取**: unidbg 正确调用 AESkeyMgr::getkey (SSO 返回 x0/x1/x2 读取, - 需修 DiffProbe 寄存器 API) → 钥定案 → 复刻 appSign → 每账号原生级独立证书 -2. **OTP 组合链复刻**: xxtea+md5_char16+AES 全链 Python 化 (md5字符中段16已识别) -3. **hyns 5008 帧 Java 构建器**: appSign 明文序列化格式 (ProtoInfo 对应已定案!) - -## 五、仓库状态 -- 提交: 铸币修复(ccfa742) → device_mint(9ff5721) → §11.9-§11.22 全档 → 命名规范(eccc00e) - → AES复刻(1afbd58+) — 全在 git log -- 证据: evidence/mint/ (final_*.bin 6 组终局矩阵 + login_* + doLaunch rsp) -- 工具: tools/device_mint.py / huya_launch_mint.py / huya_device_register.py / - core/huya/wup_encoder.py / huya_aes_replica.py / unidbg 双探针 (AesProbe/DiffProbe) -- 约束遵守: 全程禁 pm clear/attach; 差分全在 PC/unidbg; 手机 13.4.22 身份未破坏 \ No newline at end of file diff --git a/docs/虎牙/dfpReport破解进度.md b/docs/虎牙/dfpReport破解进度.md deleted file mode 100644 index f374c03..0000000 --- a/docs/虎牙/dfpReport破解进度.md +++ /dev/null @@ -1,1159 +0,0 @@ - - -# dfpReport 破解进度总结 (待破解#2) - -> **当前状态**:加密算法已确定为**逐字节 XOR keystream**(非 XXTEA),明文 JSON 已全部拿到。 -> 剩余关键问题:**keystream 每会话动态生成**,需找到其生成函数/种子。 -> IDA 已导出两个 so 的完整反编译+反汇编:`/Users/yml/Downloads/so/{libhydeviceid,libudbauthunify}.so_export_for_ai` - ---- - -## 一、已确定的结论(铁证) - -### 1. dfpReport 加密 = 逐字节 XOR keystream(不是 XXTEA!) - -**证据链**: -- 同会话 5 次 dfpReport(wire 间隔 3-6s),密文两两 XOR 非零位置 3947/3984 → keystream 每会话动态 -- 但**密文前 10B 魔数恒定** `57 18 82 cf 66 4b b3 94 01 ee`(跨天、跨会话)→ 魔数是**明文头 XOR 固定值**或者**固定前缀** -- 8.23 各样本位置 11-22 部分相同 → 是明文固定字段,不是 keystream 泄漏 -- 若为 XXTEA(hyudbxxt),则密文任何字节变化会扩散到全部 → 但实测前 10B 不动 → 排除 XXTEA - -### 2. tag2 密文结构(3984B) - -``` -tag2 = [魔数 10B][XOR密文 3974B?] -``` -- 明文 ≈ JSON(554B) + 采集二进制(3420B) ≈ 3974B,与"密文长度-魔数"吻合 -- 但注意:3954B 对齐实验(偏移 0..9 all ~1% 相同率)未找到明文精确起点 → **加密输入可能不是纯 JSON,JSON 只是其中一部分**(pa 结构待最终确认) - -### 3. 明文 JSON 完整捕获(554B,可铸造) - -```json -{"appId":"5008","appVer":"13.4.22","appkey":"865a4924a40897ac1fcfe6b4c2cbb0e3", - "channel":"xiaomi","deviceId":"02df398797432eadefcc12767119ad5e80999389", - "deviceName":"M2102J2SC","hdid":"7c5387e0539c023c31c4ff0e807e7256117385ee", - "heightPixels":"2120","isCloud":0,"isForbidLog":1,"isHome":0,"isPre":0,"openAppId":"", - "savePath":"/data/user/0/com.duowan.kiwi/files/huyaudb","sdkVer":"1.0.80138", - "servantName":"huyaudbwebui","shareAppDataPath":"/data/user/0/com.duowan.kiwi/files/huyaudb", - "systemInfo":"android","systemVer":"M2102J2SC,30,11","terminalType":1,"testEnv":0,"widthPixels":"1080"} -``` - -- **hdid 字段 = 服务端返回的 device_id (t5)** `7c5387...` → 服务端回显本地明文,本地可完全控制设备身份! -- appkey `865a...cbb0e3` ≠ 静态 t1 `865a...cbb045` → appkey 来自运行时配置 - -### 4. 响应回显验证(t1/t5 本地可铸造) - -- 重放任意 wire body → HTTP 200,344B 响应,同一 t1+t5,全新 t2 -- t1(`865a4924a40897ac1fcfe6b4c2cbb045`) 静态埋于 libhydeviceid .data(0x3bb190, 0x3bb230, 0x3e0e50),datadiv 加载时解密 -- **patch 明文实验**:改明文 JSON 的 hdid/appkey/deviceId(等长替换)后 app 崩溃 → 说明有校验或时序问题,但方向正确(见下"待办") - -### 5. keystream 前 24B(从 5 组同步 pair 恢复) - -``` -# 修正对齐 off=0 (密文[10:] XOR 明文[0:]): -t=1379: 07 f8 60 f9 b8 bc 30 bf 76 4e d4 8d a8 f6 54 21 31 fa 47 53 72 63 ef 29 -t=7268: 17 f9 70 e5 b8 3c 6c be 77 e6 ac cb c3 2a 6a 8c ef aa cd ce eb e5 67 a7 -t=3695: 17 f9 70 f9 b1 3c 4c be 77 74 14 ee 5e 7c e0 88 3a e2 c3 2c 72 63 8c 10 -... -``` -各会话 keystream 不同 → **动态生成**,极可能种子 = 时间戳或会话随机 + 固定密钥 - ---- - -## 二、IDA 导出可用资源(用户新提供,重点!) - -``` -/Users/yml/Downloads/so/libhydeviceid.so_export_for_ai/ - decompile/ # 3895 个 C 反编译 - disassembly/ # 逐函数汇编 - exports.txt # 1832 个导出 (含 70 个 .datadiv_decode) - function_index.txt, strings.txt, memory/... -/Users/yml/Downloads/so/libudbauthunify.so_export_for_ai/ - decompile/252700.c # hyudbxxt::xxtea_encrypt 标准 XXTEA (dfp 无关,登录用) -``` - -**优先排查的加密函数**: -- `decompile/1107CC.c` 引用 `0x5718` 魔数常量 ×3!(sub_1107CC,调用者 0xa6a3c)← **最可疑是真加密/解密函数** -- `decompile/1D52C8.c`、`1D5844.c`、`294FB0.c`、`7BF8C.c` 也含 0x5718 -- 大 XOR 循环候选:17F75C, 212B04, 21F98C, 2332D4, 23755C, 23C534, 26D7B0, 274BBC, B5A18 - ---- - -## 三、动态 hook 关键资产 - -### 抓同步 (明文, 密文) 对 —— 已成功! -`scripts/capture_pair_sync.py`:spawn+bp3 绕过 → SSL_write 命中 dfpReport 时 80ms 后全内存扫描 `{"appId":"5008"` → 同时刻明文。5 组同步 pair 在 `evidence/dfp_pair_sync.json` - -### 环境 -- RE repo venv: `/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/.venv/bin/python -u` -- 远程 frida: `adb forward tcp:31877 tcp:31877` + `frida.get_device_manager().add_remote_device("127.0.0.1:31877")` -- 绕过:spawn 后先 `bypass_msaoaid_maps_art_callsite.js` + `mask_frida_maps_only.js` → resume → 11s 后 `patch_guard_block_termination.js` -- **hook 规则**:后台任务 + tail 实时看 + 命中即 pkill(见 `docs/Hook脚本运行与捕获约定.md`) - ---- - -## 四、下一步行动(按优先级) - -1. **读 IDA 反编译确认加密**:重点 `decompile/1107CC.c`(引用 0x5718!)及其调用者链 0xa6a3c → 找 keystream 生成函数(输入时间戳/随机种子 + 固定 key,输出 3974B 流) -2. **hook 加密函数**:定位后 hook,抓 (明文输入, key/种子, 密文输出) 三元组 → 直接复现纯 Python 加密 -3. **验证 keystream 种子假设**:比对 5 组 keystream 的 Δ 与 wire 时间戳 Δ(3-6s 间隔,keystream 差异是否对应计数/时间)→ 若种子=时间戳,纯代码可复现 -4. **patch 明文实验修复**:上次改明文崩溃可能因 datadiv 重解码或时序;改用 hook 加密函数输入 buffer 后直接改明文再放行,避免物理内存写 -5. **最终目标**:纯 Python 生成 dfpReport 请求体 = 构造明文 JSON(改 hdid/deviceId/appkey 铸造新设备) + 魔数 + 复现 keystream 加密 → 重放得到全新 t1/t5 - ---- - -## 五、账号/数据资产 - -- 账号 `hy_300026595` / `aa778899`(老 ref `hy_300024708`) -- HAR:`/Users/yml/Desktop/抓包/hy/8.23/登录-{1,2,3,5}.har`(6 个 dfpReport wire,t2 len 3408-4237) -- 同一设备(小米 M2102J2SC, Android 11, 快照 `5dd8c93f`)所有 wire 魔数头相同 -- 关键回显:hdid `7c5387e0539c023c31c4ff0e807e7256117385ee` = 明文 hdid = 响应 t5 - -## 六、关键文件索引 - -| 文件 | 内容 | -|---|---| -| `evidence/dfp_pair_sync.json` | **5 组同步 (密文+明文) pair**(最新最有价值) | -| `evidence/dfp_pair.json` | 早期 3 组 pair(wire len 4278/4397/4413) | -| `evidence/dfp_wire_stack.json` | 8 事件 5 dfpReport wire HEX | -| `evidence/dfp_plain_big.json` | 187 个堆命中(明文 JSON @0x7814096d19 等) | -| `evidence/dfp_datadiv_full.json` | .data 7895 个已解码字符串(t1 所在) | -| `scripts/capture_pair_sync.py` | 同步 pair 抓取脚本(可复用) | -| `/Users/yml/Downloads/so/libhydeviceid.so_export_for_ai/` | **IDA 反编译(新!)** | -## 七、新会话起点(最重要的下一步) - -**IDA 反编译确认加密**:`/Users/yml/Downloads/so/libhydeviceid.so_export_for_ai/decompile/1107CC.c` -- 引用 `0x5718` 魔数 ×3,调用者 `0xa6a3c` → 极可能是 dfpReport 加解密核心 -- 同时看:1D52C8.c, 1D5844.c, 294FB0.c, 7BF8C.c(均含 0x5718) -- 反汇编对应文件在 `disassembly/`.asm - -**验证 keystream 种子**:evidence/dfp_pair_sync.json 有 5 组同步 pair, -两组 keystream 差 vs 时间戳差 → 判断种子是否时间戳/计数器。 - ---- - -## 八、本会话重大发现(新!) - -### 1. 输入结构确定: [恒定JSON 554B][collection ~3400B 每轮变化] -- 恒定 JSON(554B,已有全部字段,含 hdid/deviceId):`{"appId":"5008",...,"widthPixels":"1080"}` -- collection 是大 JSON:`{"touch_events":[...],"net_intfs":"...","phoneInfo":"{...}","qimei16/36":"...","risk_apps":"com.topjohnwu.magisk|bin.mt.plus|","scanned_appname":"",...}` -- collection 尾部含 **Poseidon 反 frida 检测字段**:`"Poseidon_D1":"libhydeviceid.so|hooked:libhydeviceid.so|hooked:libc.so|"`, `Poseidon_D3:"zygisk|"`, `Poseidon_D5:"/product/bin/su\n|"`, `adb_input_list:"device_name=Virtual|"`(huyaudb 读 /proc/self/maps 检测 hook) -- collection 每轮变化(触摸事件/权限/网络/反检测结果/扫描二进制) - -### 2. keystream 每轮变化,但跨轮/跨会话有强共享结构(非纯随机!) - -多 session(6+ 个 pipeline 证据文件)9+ 轮 ks[0..553]=cipher[:554]^JSON 分析: -- **跨会话模板复用**:相同 10B 前缀在多个独立 session 反复出现! - - `07f970f9b8bc50be774e` ×3(155601-r1, 160316-r1, 160316-r2) - - `17f97019b93c6cbe7764` ×3(155601-r3, 160316-r3, 160855-r2, 170031-r2) - - `17f97ef9b83c6cbe774a` ×3(args session 多轮) - - `07f870f9b8bc50be774e` ×2(v2) -- 同一 session 连续轮次可完全相同(160316: r1 = r2 = `07f970f9b8bc50be774e`) -- 每位置 ks 值域很小:8 轮样本中大部分位置只有 2~8 个不同值(随机期望 8),前 6 字节仅 2~3 个值 -- **高共享对**:r2(160855,t=18273) vs r5(170031,t=100816) 共享 140/554B(25%;随机期望 0.4%),最长连续 20B;r0 vs r4 共享 80B -- 结论:**keystream 不是每会话随机种子动态生成,而更像 固定模板库 + 状态选择**(生成器需进一步确认,但"所有字节每轮全新随机"的假设被推翻) - -### 3. 反 frida 检测: hook 了 libhydeviceid 的事件, collection JSON 的 Poseidon 字段值相应变化(每轮检测结果不同) - -### 4. 骨架 pipeline hook 已稳定(轻量,符合约定) -- `scripts/hook_dfp_pipeline.py` 只 hook 6 函数(topA_20e924/midA_5a938/cryptoA_5b1f4/copy_61098/pktA_7f744/pktB_92128) -- **必须遵守 hook 约定**:spawn → bypass x2 → resume+11s → patch_guard → **最后**加载主 hook JS(顺序颠倒会卡启动界面,已踩坑多次) -- 事件每轮:copy_61098 ×2(MAGIC 缓冲)、pktA/pktB(MAGIC + taf 段)、wire(密文全长)、jsonhits(内存搜 JSON 完整 554B) -- **GOT 桩方案已放弃**:144 个 br x17 桩 hook 太重,App 卡启动/卡点击(动态间接调用表 0x3ba000/0x3b9000 运行时才解密,静态也解不出) - -### 5. 本地 capstone 反汇编(ELF vaddr==fileoff) -- cryptoA_5b1f4: 16 个 bl 目标(0x5f1b4 首调、0x45aa0×26、0x76ab0×9、0x461c0×6、0x6ec68×4、0x7398c×3、0x1f07ec×2…) -- 0x461c0/0x45aa0 = **间接调用桩**(adrp x16;ldr x17,[x16,#off];br x17),GOT 表运行时解密失败静态解析 -- 0x5f1b4 = 字符串构建函数(状态机混淆),非生成器;cryptoA 主体无标量/NEON XOR 循环 → XOR 内核在间接调用或查表中 - -### 6. 下一步(铸造路线评估,优先!) -- 服务端旧结论:重放任意 wire body → HTTP 200,hdid/device_id = 明文回显 -- 若 keystream 真是模板库,铸造新设备 = 改明文 JSON(hdid/deviceId) + 复用任意一轮 keystream XOR → 重放 -- **行动**:纯 Python 构造新设备 t2(改明文 + 模板 ks)重放验证 200 → 若成功,铸造路线打通,生成器破解降级为可选 - ---- - -## 九、铸造路线实验(本会话最新,部分成功!) - -### 1. 纯 Python 重放 100% 可行(不需要 keystream!) -- `scripts/forge_replay.py`:取证据里一帧完整 wire → 重构 HTTP/1.1 POST(Content-Length 等长) → socket+TLS 直连 `wsapi.huya.com:443` → **原样重放返回 `HTTP/1.1 200 OK` + 344B 响应**(稳定复现) -- **XOR 自反性铸造**:`新密文 = 原密文 XOR (原JSON XOR 新JSON)`,JSON 区(554B)等长替换 hdid/deviceId/appkey,collection 区不动 → 不需要 ks、不需要生成器、不需要设备! -- 服务端**确实解析 t2 明文**(见下),不是纯透传 200 - -### 2. 响应结构(344B taf/wup 包) -- 4B 总长 + 头 + `huyaudbwebui` `dfpReport` + 字段: - - **t1 (tag22)**: `865a4924a40897ac1fcfe6b4c2cbb045` + `&` + base64(~43B,每次变化,像会话加密数据) - - **tag70**: `actionV(<40hex>` (设备标识回显) -- 40hex 之后有 taf 尾字段(0x0b 8c... 未解析) - -### 3. 服务端行为(受控实验) -| 请求 | actionV(40hex) | -|---|---| -| baseline 原样 | `7c5387e0...17385ee`(**= 明文 hdid!**) | -| 只改 hdid | 新值(≠ 新 hdid,每次实验都不同) | -| 只改 deviceId | 新值(≠ 新 deviceId) | -| 用 actionV1 当 hdid 再上报 | 又一个新值(不稳定) | - -- actionV 对 hdid/deviceId 变化均敏感 → **服务端解密解析了 t2 明文** ✓ -- baseline 时 actionV == 明文 hdid(服务端回显/认可当前设备) -- 改身份后 actionV 每次都是新值且不稳定 → 未确认"下发新设备身份"机制,可能 actionV 含时间戳/会话随机,或需配合后续登录流程 - -### 4. 待验证决策点 -1. actionV 是否 = HMAC/带时间戳编码(需要多次同明文请求对照:两次同明文 → actionV 相同则与明文确定性相关;不同则含随机) -2. 需要抓完整登录流程(登录请求也用同一 hdid?)确认铸造出的新身份能否通过登录 -3. 若 actionV 含随机/时间戳 → 服务端可能靠 t1 或其它字段做设备绑定 → 需重新审视铸造入口 - ---- - -## 十、29 轮 keystream 全量分析(决定性结论!) - -合并 6+ session、29 轮 ks[0..553] 分析: - -### 1. keystream 前缀模板复用(铁证) -``` -17f97019b93c6cbe7764 x4: 160316r3, 160855r2, 170031r2, 175740r2 ← 4 个不同 session 同轮次位置! -07f970f9b8bc50be774e x3: 155601r1, 160316r1, 160316r2 -17f97ef9b83c6cbe774a x3: args.j r3/4/5 -07f870f9b8bc50be774e x2: v2 r1/r2 -``` -- 完整 10B 前缀跨 session 精确重复,**不是随机** - -### 2. 同 session 完全重复 -- **args.j r3 = r4 = r5: ks[0..553] 全部 554 字节相同!**(shared=554) -- v2 r1≈r2(51 共享),160316 r1≈r2(36 共享) - -### 3. 高共享对(跨 session) -- 160855r2 vs 170031r2: **140/554 共享,最长连续 20B** -- 170031r3 vs 175740r3: 97 共享 -- 170031r2 vs 175740r2: 70 共享,最长连续 31B -- 160855r1 vs 160855r4: 80 共享(同 session 首尾) - -### 4. 结论 -- **keystream 与"轮次序号/状态"强相关,同一序号位置在多个 session 复用相同(或高相似)ks** -- 不是每 session 随机种子;更像 **固定生成器 + 状态/序号选择**(同轮次位置 → 同 ks) -- 对铸造的含义:任意一帧真实 ks 即可加密伪造明文(服务端行为见下节,这是关键限制) - -### 5. 服务端铸造验证(forge_replay.py 实测,重要!) -- **原样重放**:HTTP 200,344B 响应稳定,actionV = `7c5387...`(= 真 hdid,稳定回显) -- **改明文 JSON 任何身份字段(hdid/deviceId/appkey)**:200 OK,但 actionV 变为 - `[32hex 类MD5] + "10" + [6hex]` 动态 token,同明文两次上报 token 不同 -- 响应 t1(tag22)恒定 `865a4924a40897ac1fcfe6b4c2cbb045&base64`(base64 部分每次变化) -- **推论**:服务端识别"真设备"靠 明文三元组 (hdid, deviceId, appkey) 与库匹配; - 纯改明文不匹配 → 动态 token → **铸造新设备需同时伪造三元组一致的完整设备身份** - (可能还要 collection 里 qimei/device_id 对应)否则服务端不认可 - -### 6. 下一步两个方向 -A. **铸造**:找出服务端设备三元组的匹配逻辑(抓真实 App 首次注册流程/或抓登录后的完整身份链), - 纯 Python 全真伪造(JSON+collection 齐改)重放验证 actionV 稳定性 -B. **生成器**:既然 ks 与轮次序号相关,继续定位生成函数(0x5f1b4 初始化 + cryptoA 调用链), - 复现 keystream 生成,任意轮次任意长度可算 - ---- - -## 十一、服务端设备绑定校验(差分实验定论!) - -对 t2 密文做逐区差分修改重放,观察服务端响应: - -| 修改内容 | actionV 结果 | -|---|---| -| baseline 原样 | **稳定回显真 hdid** `7c5387...` | -| JSON 非身份字段(widthPixels 1080→1081) | **稳定回显真 hdid**(不受影响!) | -| JSON 身份字段 hdid | 动态 token `[MD5样式32hex]10[6hex]`,每次不同 | -| JSON 身份字段 deviceId | 动态 token | -| JSON 身份字段 appkey | 动态 token | -| collection 区任意 1 字节(@560/@617/@753/@953 等) | 动态 token(波及全 collection) | - -**结论:服务端绑定校验 = (hdid, deviceId, appkey 三元组) + (collection 完整性)** -- 服务端解密 t2 → 解析 JSON 三元组 + collection → 与库中设备匹配 → 匹配则回显设备 hdid,不匹配则返回动态 token -- **动态 token 不可复用**(同明文两次上报不同)→ 不是"注册下发",更像"未知设备拒绝标记" -- 铸造的真正瓶颈不是加密(已破解),而是**服务端以 (三元组, collection) 识别设备** -- collection 含 qimei16/36、scanned_appname 等指纹 → 需与三元组自洽才能被服务端认可 - -### 后续(铸造完整方案待定) -1. 抓真实 App **首次注册/重置设备后**的第一帧 dfpReport(看服务端如何初始分配设备 id) -2. 或逆向 collection 的指纹算法(哪些字段参与绑定) -3. 验证"设备上等长 patch hdid + App 自行生成 t2"路线(修复 patch 崩溃:可能因 JSON 长度校验/内存写坏) - ---- ---- - -## 十二、请求链捕获定论(冷启动全链,决定性!) - -`scripts/hook_reqchain.py`(全请求链捕获器)冷启动一次,抓全 SSL_write/SSL_read: - -### 1. dfpReport 是冷启动自动触发,非登录专属 -- 启动后 ~7.6s / 14.7s / 17.6s **自动连发 3 次** dfpReport(无需任何用户操作) -- 每次 dfpReport 后紧接 **dckey/check**(744B 请求,响应 665-667B),两者强关联 -- 三次 dfpReport 密文均不同(len 4010/4137/4187/4262),但响应 actionV **全部同一** `7c5387...` - -### 2. dfpReport 响应 = 344B 稳定 taf(关键!) -- 三次上报,每次返回 **606B HTTP(200 OK) + 344B body**,actionV 恒 = 真 hdid -- 与 wsapi 重放实验完全一致:body 就是 actionV(40hex) + t1 + base64 会话字段 - -### 3. 服务端识别机制最终定论 -- **服务端识别设备 = 只靠明文三元组 (hdid, deviceId, appkey) 是否在设备库** -- 三次上报 collection/keystream 全不同,但 actionV 恒同 → **collection 不参与设备识别** -- 三元组在库 → 回显真 hdid;不在库 → 动态随机 token(拒绝) -- **这也推翻了文档第十一节 "collection 参与校验" 的猜测**:实际是三元组唯一主导 - -### 4. 对铸造的真正阻碍 -- **不是加密**(XOR 已破解,任意帧 ks 可加密伪造明文) -- **不是 collection**(不参与识别) -- **是服务端设备库**:只有 (hdid, deviceId, appkey) 已在库的组合才会被认可为设备 -- 光改明文三元组 → 不在库 → 拒绝。铸造需要伪造"库中已存在"的三元组,或走设备首次注册流程 -- **行动**:抓"全新设备首次 dfpReport"(换新设备/清 huyaudb 数据后第一帧),看服务端是否为 unknown 三元组分配 hdid、分配后该 hdid 是否入库可复用 → 判断铸造是否可行 - -### 5. 清数据后的冷启动验证(新结论!) -- `adb shell pm clear com.duowan.kiwi` 后冷启动:**dfpReport 与 dckey 均未自动触发** -- 14 个请求全是杂项(cfg/日志/广-告/exapp/audid 等),无任何虎牙身份链请求 -- 结论:设备身份链(dfpReport→dckey)只在 **App 数据已含身份 或 用户触发登录** 时才发,清数据后启动不会自动重建身份 -- 完整看清全新设备首次 dfpReport 需 **用户触发登录**(clear 数据 → 登录 → 抓 dfpReport+登录链) - -### 6. 再次验证:清数据后 dfpReport 仍自动触发且 actionV 恒同(重大修正!) -- **推翻上一节结论**:清数据后冷启动, dfpReport 在 t=1708/8695/11126/12274 **仍自动连发 4 次** -- 且 **4 次 actionV 全部 = `7c5387...`(真 hdid)!** -- 真相: 清数据只是清 App 本地缓存, **明文三元组 (hdid/deviceId/appkey) 由固定密钥/算法派生,不受清数据影响** -- 服务端 4 次都识别为同一设备 → 设备身份不是"注册分配",而是**固定派生 + 服务端查库匹配** -- 铸造含义: 三元组无法通过"重新注册/清数据"刷新; 服务端认的是已入库的真正设备的新三元组变化 → 铸造难度进一步确认(见节十一/十二) - -### 7. 决定性结论: 设备三元组 = 固定派生, 铸造的生死线 -- **证据**: 三次清数据(pm clear)后的冷启动, dfpReport actionV **全部恒 = `7c5387...`**(跨 3 个独立 session) -- **含义**: - 1. 三元组 (hdid=`7c5387...`, deviceId=`02df...`, appkey=`865a...e3`) **完全固定**,与本地数据无关 - 2. 服务端把"这三元组"当已知设备 → 回显 hdid; 任何与这三元组不同 → 动态随机 token(拒绝) - 3. **铸造新设备 = 需要一组服务端已入库的 (hdid, deviceId, appkey)**; 或逆向三元组派生算法, 但即便派生成功, 服务端也不认(不在库) -- **因此铸造可行性取决于**:能否获得/伪造一组"服务端库中已存在"的三元组。若不能, 纯改明文铸造不可行 -- **可验证方向**: - A. 抓服务端对**从未见过的新三元组**的初始动作(需真·全新设备——如换一台真机/模拟器首次启动), 看是否为 unknown 三元组分配 hdid 并入-库 - B. 逆向三元组派生产生逻辑(hdid/deviceId/appkey 从何而来), 看是否能构造"合法派生的新三元组" -- 备注: "登录超时"现象可能与捕获/清数据导致的会话状态有关, 与三元组恒定无直接关系 - ---- ---- - -## 十三、三元组派生逆向 (静态, OLLVM 混淆) - -### 1. 静态线索 -- libhydeviceid.so 内 .rodata @0x36c190 有明文 **MD5 初始消息种子** + A/B/C/D 常量 -- MD5 特征移位 `ror #7` @0xc37c0(全 SO 唯一), 附近为大量循环左移/加法/状态机(`mov w#; cmp; b.eq` 状态分发) -- 0x36c230 有 hex 小写表 `0123456789abcdef` + 大写表 `0123456789ABCDEF`(hash 输出转 hex) -- **结论**:三元组派生用**自实现散列(MD5 系)+ OLLVM 扁平化混淆**,非标准库,常量被魔改/内联 -- 三元组已知: hdid=40hex, deviceId=40hex, appkey=32hex, 见第 7 节 - -### 2. 静态快速尝试 -- 常见设备因子组合(M2102J2SC/30/11/xiaomi/包名/等)拼接 MD5/SHA1 均不匹配 hdid/deviceId -- → 派生混入随机盐或复杂组合, 纯猜测难破 - -### 3. 效率判断 -- 纯静态解 OLLVM 混淆成本高, 性价比低 -- **更优: 运行时 hook 派生点** → 直接抓 (输入, hash输出) 真实对应关系, 避开解混淆 -- 待办: hook MD5 实现(0xc37c0 附近) 的入参/出参, 抓三元组派生的真实输入 - ---- ---- - -## 十四、模拟器探索(多号路线) - -### 目标 -- 用户想"每个号一个独立设备身份"避免风控, 尝试用模拟器作为"全新设备"环境 - -### 已就绪 -- 模拟器(型号 PHU110, Android 12 API32, arm64, 已 root) -- 装了 huya apk (`apks/huya-13.4.22-115315-live.apk`) -- frida-server 15.2.2 (14.2.18 在模拟器上 spawn 超时; 15.2.2 spawn 正常) -- python frida 升级到 15.2.2 (uv 装到 venv) -- adb forward `tcp:31878 -> 31878` (模拟器 frida 独立端口, 不与真机 31877 冲突) - -### 关键坑 -1. **spawn 导致 EGL 崩溃**: App `RenderThread` 报 `Failed to create context, error = EGL_NOT_INITIALIZED` SIGABRT - - 这是 spawn 挂起导致渲染时序问题, **非反调试/frida 检测** - - 非 spawn 正常启动 App 则稳定存活 - - → 需用 attach 而非 spawn (但 attach 慢会错过冷启动 dfpReport 窗口) -2. **反调试**: attach + SSL_write hook 未触发崩溃(与真机不同, 模拟器检测较弱) -3. 模拟器 adb/frida 连接可能因模拟器窗口关闭而中断 (端口探测全失败) - -### 当前状态 (被模拟器连接中断卡住) -- 待模拟器重新连接后: 用 attach 抓冷启动 dfpReport, 验证"全新模拟器"服务端给的 actionV 是否为新 hdid - ---- ---- - -## 十五、模拟器全新设备验证 —— 决定性突破! - -### 结论: 全新模拟器 = 独立设备身份 (服务端认可!) -- 模拟器(未登录过的全新环境)spawn+frida bypass 后, 冷启动自动发 dfpReport -- **dfpReport 响应 actionV = `e97dcfc3e46b0b4278d8cab1044c5fb01198c7fd`** -- 与真机 hdid `7c5387e0539c023c31c4ff0e807e7256117385ee` **完全不同** -- → **服务端把模拟器识别为一台独立设备, 返回了独立稳定的 hdid** -- → "一个运行环境 = 一个设备身份" 成立 - -### 多号方案的技术基础确认 -- 每个干净模拟器实例 = 一个独立三元组 → 独立 hdid → 设备身份隔离就不同 -- 用户"很多号"可分配在不同模拟器/环境, 每个号用独立设备身份 -- 不再共用真机的一个固定三元组 → 风控关联大幅降低 - -### 技术要点 (模拟器 frida) -- USB真机已拔, 只用模拟器 (PHU110, Android12, arm64, root, adb 127.0.0.1:5555) -- frida-server 15.2.2 (`/data/local/tmp/fs152`, 端口 31878); python frida 15.2.2 (uv 装) -- **spawn 必须【立即 resume】**, 挂起太久 App 的 RenderThread EGL 崩溃 (`EGL_NOT_INITIALIZED`) - - 挂起加载 bypass 后**要快速** resume, 加载本身没问题(用户提示 "不要自己制造困难") -- bypass 三件套 (bypass_msaoaid + mask_frida + patch_guard) 可直接复用 (lib 内偏移, 同 apk 版本) -- 脚本: scripts/hook_emu_spawn_bypass.py (OUT=evidence/emu_spawn_bypass_dfp.json) - -### 待办 -- [ ] 提取模拟器三元组 (hdid/deviceId/appkey): 用 XOR 差分或 hook 加密前明文 -- [ ] 验证 e97d 身份是否多次稳定 (非动态 token) -- [ ] forge_replay 用模拟器三元组纯 Python 重放验证 (跨机器可重放性) - ---- ---- - -## 十六、多环境独立设备身份验证(核心成果!) - -### 验证: 每台(重置)模拟器 = 独立设备身份, 且可纯 Python 重放 - -| 环境 | hdid (actionV) | 说明 | -|---|---|---| -| 真机 M2102J2SC | `7c5387e0539c023c31c4ff0e807e7256117385ee` | 基准 | -| 模拟器1 PHU110 (Android12) | `e97dcfc3e46b0b4278d8cab1044c5fb01198c7fd` | 已验证纯Python重放 | -| 模拟器2 SM_G998B (Android13) | `3a13955c404c1eea620c75f59b5c6de211863f2f` | 已验证纯Python重放 | - -### 关键验证(决定性) -- 三台环境, **actionV 完全不同** → 服务端按环境分离设备身份 -- 每台 spawn 抓一帧 dfpReport wire → **纯 Python socket+TLS 原样重放 → 服务端返回相同 actionV** -- **证明"每台干净模拟器 = 一个纯 Python 可重放的独立设备身份"成立** - -### 模拟器2 (Android13 SM-G998B) 技术要点 -- spawn + **纯 OpenGL(Vulkan)渲染** 解决 EGL_NOT_INITIALIZED 崩溃(src: 用户在模拟器设置选"只有 OpenGL") -- Android 12 那台靠"3-6s窗口"抓; Android13 这台纯OpenGL后 App存活久, 第一次尝试就抓到 -- frida 15.2.2 spawn + bypass_msaoaid_maps_skip_cleanup.js(frida反调试全过) → 抓 frame -- frame 文件: evidence/frame_221833.json (dfp_wire + actionV=3a13955c...) - -### 多号方案落地路径(成立) -1. 每台(重置)模拟器 → spawn 抓一帧 dfpReport → 获得该环境设备身份(actionV) -2. 之后纯 Python 用该身份与虎牙交互(可重放,已验证) -3. 每台配不同账号 → 各设备独立身份 ∈ 不同 hdid → 风控关联大降 - -## 十七、设备身份派生因子 排查(克隆模拟器) -### 测试: 改各种设备标识, 观察 hdid(actionV)是否变化 - -| 变更 | 方法 | hdid 变化? | -|---|---|---| -| IMEI | 用户手动改 `869390046034281`→`866084040768472` | ❌ 不变 (e97d) | -| Android ID/SSAID | `settings put secure android_id` 改 | ❌ 不变 (e97d) | -| ro.serialno | magisk resetprop 改 | ❌ 不变 (e97d) | - -- 结论: clone 继承源身份(必然); 改常规标识无效 → 身份派生源是更底层字段(待 frida hook 定位) -- 延申: 真机/Android12/Android13 三环境 actionV 全不同 → 环境级标识决定身份. - -## 十八、完整设备身份登记表(全部经纯Python重放验证) - -| 环境 | hdid (actionV) | 状态 | -|---|---|---| -| 真机 M2102J2SC | 7c5387e0539c023c31c4ff0e807e7256117385ee | ✓重放 | -| 模拟器 PHU110 (Android12) 原机+clone | e97dcfc3e46b0b4278d8cab1044c5fb01198c7fd | ✓重放, clone继承相同 | -| 模拟器 SM_G998B (Android13) | 3a13955c404c1eea620c75f59b5c6de211863f2f | ✓重放 | -| **模拟器 GC3VE (Android12, 全新实例)** | **234548246275beedf8d93a242ef50e8f1178c3b2** | ✓重放 | - -### 已验证的确定性规律 -- **全新(非clone)模拟器实例 = 独立新 hdid**(GC3VE 产生第4个唯一身份) -- **clone 复制 = 继承源实例身份**(共享 e97d) -- 纯 Python 重放可稳定代表各环境身份(不依赖模拟器存活) - -### 换身份(在单台上)已穷尽失败 -- 改 IMEI / Android ID / ro.serialno / 整机型号(fingerprint) / 删App身份文件 / pm clear -- 全部不影响 hdid → 身份钉在模拟器底层硬件标识(qemu实例), 上层改动无效 - -## 十九、DeviceInfo(wup) 结构逆向 —— libudbauthunify.so (真机运行时确认) - -### 关键: libudbauthunify.so 符号未混淆, 是正确逆向载体 -libhydeviceid.so 是其下层的"采集+加密"黑盒; libudbauthunify.so 负责 wup 打包,符号清晰可逆。 - -### createWupDeviceInfo(0x2746a0) 输出结构 (真机 M2102J2SC 运行时 dump) - -| DeviceInfo偏移 | 值(真机) | 含义 | -|---|---|---| -| +0 (dword) | 1 | DeviceInfo type | -| +8 std::string | M2102J2SC | deviceName/model (来自全局 4915C8) | -| +32 std::string | (heap, xxTeaAndBase64) | 加密设备字段(deviceId等, byte_491998==0时) | -| +56 std::string | android | systemInfo (来自全局 491610) | -| +80 std::string | M2102J2SC,30,11 | systemVer (来自全局 491628) | -| +104 | 2120 | heightPixels (来自全局 4915E0) | -| +128 | 1080 | widthPixels (来自全局 4915F8) | -| +152 std::string | BusinessCfg::getHdid() | **hdid** | - -### 分支逻辑 (byte_491998) -- byte_491998==1: DeviceInfo[8/32/56/80] = 静态全局字符串(运行时datadiv解密后) -- else: 走 UdbUserFilterUtils::xxTeaAndBase64 编码 (加密设备字段, 可能是deviceId/appkey加密) - -### 运行时解密全局(真机确认值) -- 4915C8='M2102J2SC' 491610='android' 491628='M2102J2SC,30,11' -- 4915E0='2120' 4915F8='1080' 491720=1 - -### getHdid(0x26a484) / getSafeDeviceId(0x26a3c4) / setSafeDeviceId(0x26a2e0) -- 三者都在 BusinessCfg 单例(getInstance)上操作 -- setSafeDeviceId: 写 this+1008=safeDeviceId, this+1088=hdid -- getHdid: 读 this+1088 (锁保护) -- hdid 是提前经 setSafeDeviceId 存进 BusinessCfg, 再由 createWupDeviceInfo 取出填 DeviceInfo+152 -- setSafeDeviceId 调用者 none(动态注册) → 真正 hdid 派生在 libhydeviceid(混淆区) - -### 确认: keystream 每帧独立随机(同设备连续多帧密文仅27/4031字节巧合相同) -→ 帧间差分不可行; 恢复明文只能靠已知明文锚点+值推断 - -### hook 经验(真机 frida 15.2.2) -- 真机 spawn+G2-0055 bypass 稳定(nont crash), DeviceInfo 可反复抓到 -- setSafeDeviceId 在 spawn窗口不触发(更早或另一路径) -- getHdid __usercall(a2@X8) 的 x8 在 frida onLeave 难取, 改读 this+1088 更实用 - -## 二十、真机完整明文 JSON 恢复成功 (libudbauthunify 逆向成果) - -### 确认: 明文 JSON 完整模板 (真机 M2102J2SC, 双重验证) -- 前34B ({"appId":"5008","appVer":"13.4.22") XOR 恢复的 ks 前缀与模板完全一致 -- hdid = 7c5387e0539c023c31c4ff0e807e7256117385ee 与文档真机 actionV 一致 (双重验证) - -### 真机完整明文 JSON (586B): -{"appId":"5008","appVer":"13.4.22","appkey":"865a4924a40897ac1fcfe6b4c2cbb0e3","channel":"xiaomi","deviceId":"02df398797432eadefcc12767119ad5e80999389","deviceName":"M2102J2SC","hdid":"7c5387e0539c023c31c4ff0e807e7256117385ee","heightPixels":"2120","isCloud":0,"isForbidLog":1,"isHome":0,"isPre":0,"openAppId":"","savePath":"/data/user/0/com.duowan.kiwi/files/huyaudb","sdkVer":"1.0.80138","servantName":"huyaudbwebui","shareAppDataPath":"/data/user/0/com.duowan.kiwi/files/huyaudb","systemInfo":"android","systemVer":"M2102J2SC,30,11","terminalType":1,"testEnv":0,"widthPixels":"1080"} - -### 三元组 (真机): -- appkey = 865a4924a40897ac1fcfe6b4c2cbb0e3 (32hex) -- deviceId = 02df398797432eadefcc12767119ad5e80999389 (40hex) -- hdid = 7c5387e0539c023c31c4ff0e807e7256117385ee (40hex) - -### JSON 结构推导 (字段偏移, 真机模板): -appkey 值区间 [44,77] 32hex; channel [89,96]; deviceId [109,150] 40hex; -deviceName [165,175] 变长; hdid [184,225] 40hex; systemVer [518,534] 变长 -其余字段值固定(savePath/sdkVer/servantName/systemInfo='android'/heightPixels='2120'/widthPixels='1080' 等) - -### 重要结论: -- 所有设备 JSON 结构/固定值一致, 仅 hdid/deviceId/appkey/channel/deviceName/systemVer 不同 -- 因此"python 零设备自生成"的 JSON 部分: 已完全确定结构, 只需能生成三元组+(deviceName/systemVer 可读自系统) -- ⚠️ 真正的唯一障碍 = **XOR keystream 生成器** (tag2 的 571882cf664bb39401ee 魔数后那段密文) - -### dfp 加密路径归属判定: -- createWupRequestData/createWupPackage → 标准 taf/wup + base64 (通用业务请求) -- dfpReport 的 tag2 加密(XOR keystream) → 在 libhydeviceid.so (OLLVM混淆), 与 wup 路径不同 -- getOtp/hyudb_otp_encrypt → 登录 OTP (AES + nonce), 非 dfp 路径 - -### createWupPackage 事务流 (37B020, FindPassword示例): -createWupReqHeader + createWupDeviceInfo + createWupProtoInfo → UniPacket → createWupPackage -→ put → doEncode → CBase64::Encode (标准wup二进制+base64) - -### hook 经验补充: -- setSafeDeviceId 在 spawn 极早期一次性调用(dlopen后立即), 轮询命中不到 -- 内存扫描找明文JSON太重(SSL_write触发时scan来不及), 弃用 -- 真机 frida15.2.2 spawn+G2-0055 稳定抓 dfp wire (4298-4316B 每帧) - -## 二十一、dfpReport 完整 wire 格式确认 (重要, 修订此前的"MAGIC后全XOR密文"理解) - -### 真实 wire 结构 (真机抓到, SSL_write是明文!): -``` -POST / HTTP/1.1 -i-ver: 1 -Content-Type: application/octet-stream -Host: wsapi.huya.com -User-Agent: okhttp/3.14.9 -(blank) -body = taf/wup 二进制包 (4111B) -``` -→ **SSL_write 抓到的就是明文 HTTP** (dfp 用 OkHttp Java TLS 发送, 调用栈 libjavacrypto.so) - -### body (taf/wup TLV) 结构: -``` -00 00 10 0f 4B 大端总长 -0c "huyaudbwebui" servantName -0f "dfpReport" functionName -0d "tReq"→ "android" 字段/参数 -2d 00 01 0f ... -[ M MAGIC: 57 18 82 cf 66 4b b3 94 01 ee ] (0x57=tag2类型标记) -[ cw: 明文JSON^keystream(586B) + collection^keystream(3445B) ] -``` - -### cw 分两段: -- cw[0:586] = 明文JSON XOR keystream (JSON结构已全解, 见第二十节) -- cw[586:] = collection XOR keystream (3445B, 动态: 每帧不同, 长度也变3445/3460) - -### triple in 明文JSON (真机): -hdid=7c5387e0539c023c31c4ff0e807e7256117385ee -deviceId=02df398797432eadefcc12767119ad5e80999389 -appkey=865a4924a40897ac1fcfe6b4c2cbb0e3 -channel=xiaomi deviceName=M2102J2SC systemVer=M2102J2SC,30,11 - -### 唯一黑盒剩余: -- XOR keystream 生成器(JSON可解586B, collection明文未知不可解) 在 libhydeviceid.so(混淆) -- collection 的内容/构建(动态随机, 含appkey? deviceId? 时间戳?) -- 三元组派生(在 libhydeviceid 混淆区) - -### 模型脚本: scripts/dfp_model.py (wire解析+重建验证) - -## 二十二、★★ XOR keystream 生成器定位 (重大静态突破) ★★ - -### 在 libhydeviceid.so (OLLVM混淆) 找到了 keystream 生成链: -sub_1D1A68 (caller 0x1d3ab0) 是 keystream 种子生成器, 其解码后关键调用: -``` -sub_8ECCC(buf, a1, 0); // 用a1初始化随机状态 -v9 = time(nullptr); srand(v9); // ★ 种子 = time(NULL) (秒级时间戳!) -v58 = rand(); // rand() 取首随机值 -sub_1D0BB8(v58, keystream_state) // 用 rand() 扩展成keystream -``` - -### 链: srand(time) → rand() → sub_1D0BB8 → sub_1D14C4 (ostream格式化) → 输出keystream字节 - -### 关键意义: -- keystream 种子 = time(NULL) 秒级时间戳 → **理论上若能复现 sub_1D0BB8 对 rand() 输出的扩展, 即可预测 keystream** -- libhydeviceid 内置 `.srand`(0x464d0)/`.rand`(0x46060) — 非 libc, 自有实现 -- sub_1D0BB8: rand()初始值 经 sub_1D14C4(ostringstream, num_put格式化) + 大量C++ std, 生成流 -- 动态验证受挫: libhydeviceid 延迟加载, spawn窗口 hook不到 base; 需 wait 模块 + 对内部 .srand/.rand 地址 hook - -### 待逆: -- sub_1D0BB8 具体如何把 rand() 一个 u32 扩展成 4031B 字节流 (核心) -- keystream 是否 = rand()连续值 的某种字节拼接, 或经 ostream 格式化的十进制/hex文本 - -## 二十三、确认 keystream 使用 libc bionic 的 srand/rand (决定性) - -### objdump 证实: 0x46060=rand@plt, 0x464d0=srand@plt -- libhydeviceid.so 内 `.rand`/`.srand` 是标准 PLT thunk, br 跳转到 GOT → 实际解析到 bionic libc 的 rand/srand -- 因此 sub_1D1A68 里 "srand(time); rand()" 调用的是 **libc 标准 rand** (glibc/bionic 兼容, 可预测) - -### 完整 keystream 生成链 (已确认): -sub_1D1A68 (由 0x1d3ab0 调用): - sub_8ECCC(buf, a1); // 预初始化 - srand(time(NULL)); // w9 = time(nullptr) 秒级 - v58 = rand(); // 第一个 rand() - sub_1D0BB8(v58); // 扩展成 keystream: sub_1D14C4(ostringstream) + ostream输出 - -### 意义: -- 因为 rand() 输入是 time(秒), 而 bionic rand() 序列是确定性的 -- 若能逆出 sub_1D0BB8 如何把 rand() 序列(或首值)变成 4031B 字节流, keystream 可完全复现 -- 关键: 需知道 keystream 每个字节来自 rand() 哪个输出/如何取低字节 - -### 运行时 hook 受阻(hook libc srand/rand 的 libhydeviceid 调用过滤): -- libc srand export attach "unable to intercept" (地址被当作不可拦截对象) -- 改用编译期 + 在真机调 libmsaoaidsec 时机对齐难 - -## 二十四、本轮(round 5)成果: keystream 生成链 + rand/srand 归属确认 - -### 1. sub_1D1A68 是 libhydeviceid 里的"种子/RNG 初始化函数" -- 链: sub_1D1A68 ← sub_1D3AB0 ← sub_1D5844 递归调用 -- 内部: time(nullptr) -> srand(v9=time 秒) -> v58=rand() -> sub_1D0BB8(v58) -- sub_1D0BB8: rand()种子v58 经 C++ iostream(num_put/ostringstream/sub_1D14C4十进制格式化)+sub_8F66C 展开成字节流 -- nm 确认: rand/srand 都是 `U rand@LIBC` (undefined) → 解析到 Bionic libc 标准 rand/srand (可预测) - -### 2. 运行时 hook 观察 (真机): -- libc srand export 无法 Intercept ("unable to intercept at 0x791a282e78", 页面边界问题) -- libc rand 可 hook(rd_ok),但 dfp 期间未捕获到来自 libhydeviceid 的 rand() 调用 -- 结论: sub_1D1A68 可能跑在 spawn 极早期(非每帧), 或 dfp 的 keystream 不直接=该路径 -- 待 subagent 静态还原 sub_1D0BB8/sub_1D14C4 确定 keystream 是否 = rand() 流的某种变换 - -### 3. 离线 LCG 测试: -- keystream 每4字节LE组 不匹配 glibc rand LCG → keystream 经变换(非裸rand输出) -- 无法离线验证 rand-seed(缺秒级时间戳) → 需真机抓 seed+同帧 - -### 4. 完整 wire 格式(最终): -HTTP POST(okhttp) + taf(body: huyaudbwebui/dfpReport/tReq/android) -+ MAGIC(5718..) + [JSON^ks(586)] + [collection^ks(3445, 动态)] - -### 5. 结论: -- 明文JSON/taf结构/加密模型 已全解(多轮双向验证) -- 唯一黑盒 = XOR keystream 生成器的精确算法 + collection 内容 -- subagent 正在静态还原 sub_1D0BB8(rand->keystream展开) 的精确字节生成 - -## 二十五、重要修正: sub_1D1A68 (srand(time)/rand) 不是 dfp keystream 路径 - -### 运行时证据 (真机 hook sub_1D1A68@0x1d1a68): -- 真机反复 spawn 抓 dfp,KS_ENT(入口) 一次都没触发 -- 但 dfp SSL_write 每次都触发 (tid 21165/22283/22627/23944) -- libc rand/time 频繁被调(其他SDK组件), 但都不是来自 dfp keystream 上下文 -→ **sub_1D1A68 是其他功能(RNG初始化), 不参与 dfp 每帧 keystream** - -### 修正结论: -- 之前的 "srand(time)->rand()->sub_1D0BB8 生成 keystream" 假设**不适用于 dfp** -- dfp 的 XOR keystream 每帧独立随机的真正生成器在别处(libhydeviceid 混淆区, 未定位) -- MAGIC(571882cf664bb39401ee) 在二进制里**无连续字面量** → 全部运行时从.datadiv解密构造 -- collection 段(3445B) 完全随机, 无明文头结构; 是纯 XORK(明文未知) - -### 对 subagent 深挖的校准: -- 需确认 sub_1D0BB8 是否真是 dfp 相关; 若是则要 hook 真实入口 -- 或重新定位 dfp 加密函数(通过 hook 构造 tag2/MAGIC 的内存写入) - -## 二十六(round6) 进展: 逆向路径重要修正 + 新切入点 - -### 1. sub_1D1A68 (srand(time)->rand) 确认不是 dfp keystream 路径 -- 真机反复 hook, KS_ENT 从不触发, 但 dfp 每次都发 -- 该函数是 SDK 其他 RNG 功能, 排除 - -### 2. dfp 上报入口: HandlerReport::_report (libudbauthunify@0x3cbba0) -- 签名: _report(this=a1, string& a2=payload) — payload 在 a[1] -- 内部: UdbLog "HandlerReport report data is %s" + std::string::operator=(v33,a2) + UdbBusinessWraper::CreateSession(0x1031...) + UdbContext -- 但这可能不是 dfp 加密路径(它读a[1]为SSO string,garbage) - -### 3. 关键: MAGIC(571882cf664bb39401ee) 在二进制里无连续字面量 -→ 完全运行时从 .datadiv 解密 + 构造; 无法字符串定位加密函数 - -### 4. collection 段确认纯随机每帧 (无明文头) - -### 5. 结论: dfp XOR keystream 生成器仍在 libhydeviceid 混淆区深处, - 未通过"字符串/导出/MAGIC/rand"定位到; 加密可能是自实现流密码 - (subagent 曾发现 mask 0x7070.../0x8C/0x0B/0x05 的解码线索但被中断) - -## 二十七(round7) 进展: 模型精确定位 + 模板发现 + 新攻击面 - -### 1. ★决定性模型确认: cw = [2B会话前缀] + [JSON^ks 586B] + [collection^ks ~3445B] - -**8帧独立spawn对齐测试(决定性)**: -``` -各偏移下 "8帧全同位"(keystream在该偏移全帧相同) 数量: - off=0: 2 个 [0,1] ← cw[0:2]=前缀, 恒定 - off=1: 1 个 - off>=2: 0 个 ← JSON密文从这里开始, keystream全随机 -``` -- **JSON 密文起点 = cw[2]** (不是cw[0]!), cw[0:2]=`7cdb`类2B前缀是会话/编码头 -- dfp_pair 黄金配对验证: `cw[2:588] XOR plain[:586] == JSON` 100% True -- 修正此前所有基于"cw[0]对齐"的ks推导 (应使用off=2) - -### 2. ★跨会话模板 A/B 重复 (fastsame vs full8 对照) -- 模板A `7cdb1189c175289c4d5621de6e647a90`: - - fastsame run3(ts=1787764797)/run4(4811)/run6(4838) + full8 run8 (跨~10分钟, 不同spawn) -- 模板B `7cdb8188c875341d72d5e1dd90ceda9e`: - - fastsame run2(4784)/run5(4825)/run7(4851) + full8 run4 -- 但**完整4035B跨帧仅0.5-1.1%相同** → 头部14B有确定性模板, 主体每帧随机 -- 解释: **app缓存了少量历史dfp帧, 跨会话复用头部模板** (或短周期PRNG); 不能证明keystream主体确定 - -### 3. collection 段结构 (长度每帧变 3443-3558) -- frame_real: coll len = 3443/3458/3453; dfp_pair = 3558; full8 = 3449 -- collection 密文自相关=随机水平, 无周期; 明文未知, 但字段名已从内存字符串确认: - `adb_input_list/event/upload_motion_event/deviceinfo/touch_position/is_applist_upload/installed_appname/scanned_appname/script_push/screenrecord/display_info/update_display_info` -- → collection 是动态采集数据(触摸/输入/App列表等), 与采集状态相关 - -### 4. ★GetDfpConfig 流程发现 (重要新攻击面) -- 内存字符串区含 `wup.GetDfpConfigReq` / `wup.GetDfpConfigResp` / `wup.DfpReportReq` / `wup.DfpReportResp` -- 运行时内存可扫描到 GetDfpConfigReq@0x78145c3594, GetDfpConfigResp@0x78145c3a44 -- **假设流程**: 客户端先 GetDfpConfigReq 取配置(可能含keystream种子/密钥) → 再 DfpReportReq -- 若 config 响应含种子, hook 该请求即可拿到每会话 keystream 种子! (待验证) - -### 5. libhydeviceid 数据区密钥/身份线索 (0x78133bf220 区 4600B dump) -``` -7jbF5IiqqIDdgEtiNQLzJSuTZmRk8sbc ← 28字符, 疑似固定密钥/盐 -udb_deviceID / udb_deviceID_encode ← deviceID 编码函数 -/sdcard/duowan/d2b08ce4-1e6c-4515-b320-680ecdd98da2.rt ← GUID缓存文件 -/sdcard/duowan/64a33427-f53c-4162-8738-81aa9117b950 ← 另一UUID -/files/hydevice/64a33427-f53c-4162-8738-81aa9117b950 -cdid / cdid_version / HUAWEI / 1.13.11 ← ctid相关 -java/util/UUID randomUUID ← GUID用Java UUID生成 -version.create_time.type / table / udb_guid -961c151d2e87f2686a955a9be24d316f1362bf21 3.11.3 ← commit hash + SDK版本(3.11.3) -sdid: / hdid: ← sdid/hdid 日志前缀 -``` - -### 6. ★明文 collection 类 JSON 完整样例 (自生成模板!) -- 内存扫描 `"device_id":"` 模式抓到多个完整明文JSON (不同uri): -- **hycredLogin 变体(552B)**: `{"app_ver":"13.4.22","appid":"5008","bypass":3,"carrier_type":4,"channel":"xiaomi","credLoginType":0,"device_id":"02df3987...","device_name":"M2102J2SC","hdid":"7c5387...","level":1,"local_time":1787721670153,"net_state":0,"net_type":4,"pid":9057,"requestId":4115362,"rescode":"0","rsp_time":222,"sdk_ver":"1.0.80138","sign":"","system_info":"android","system_ver":"M2102J2SC,30,11","term_type":1,"trace_id":"0b8f098ff64a5bdc-9057-2880590787721669925","uid":1199666918455,"uri":"hycredLogin"}` -- loadcred变体(453B)/selectOperator变体/native init变体(全部含 device_id/device_name/hdid/level/local_time毫秒/term_type/trace_id/uid 字段) -- local_time 为**毫秒时间戳**; trace_id 格式 `hex32-pid-timestamp` -- → 这些是其他udb上报的明文模板, 证明**明文JSON家族结构**, dfp collection可能类似 - -### 7. 模块清单 & wire 协议确认 -- dfp wire = **明文HTTP POST**(无TLS!): `POST / HTTP/1.1\ni-ver: 1\nContent-Type: application/octet-stream\nContent-Length: 4111\nHost: wsapi.huya.com\nUser-Agent: okhttp/3.14.9` + body -- 模块: libudbauthunify@0x77bd589000, libhydeviceid@0x7813009000, libhycrypto@0x77e1b40000(纯OpenSSL静态库2512符号), libhyssl, libhyquic -- SSL_write backtrace 显示 Java 层(libjavacrypto/libart) → 部分请求走Java TLS; dfp明文HTTP走 libc send (但send hook未抓到dfp, 疑走 okhttp 内部路径) - -### 8. libudbauthunify 导出加密函数 (nm -D, 12378符号) -``` -0x330058 encode_xxtea / 0x3306cc decode_xxtea (std::string版) -0x32e83c hyudb_crypt_util::xxtea_encrypt / 0x32ece8 xxtea_decrypt -0x252700 hyudbxxt::xxtea_encrypt(Pjj S0) raw版 / 0x252b84 xxtea_decrypt -0x330218 encode_aes / 0x330498 decode_aes -0x27f820 getRandomSession ← 随机会话, 高度可疑 -0x2815f8 genRandomTrace / 0x280c8c genTraceId -0x331560 IdGenerator::encode / 0x331970 IdGenerator::decode (id编码, 疑hdid/deviceId派生) -0x32fa24 hyudb_otp_encrypt / 0x331d58 hyGenUnionId / 0x332478 hyGenUnionIdNew -0x26871c AESkeyMgr::getkey / 0x24eb08-0x250138 UdbAESUtil全套(自实现AES) -0x24b430 Base64::encode / 0x24b6b0 decode / 0x24dca8 HuyaMd5::encode / 0x24dea0 decode -``` -- **注意**: frida Module.getExportByName 找不到这些符号(运行时可能被strip/混淆) → 用固定地址 base+offset hook (已尝试但 findModuleByName 报 nomod, 需修正: 模块名大小写/延迟加载) - -### 9. 遗存问题/下一步 -- [ ] hook GetDfpConfigReq 响应抓 keystream 种子 (最优先) -- [ ] 用 base+offset 固定地址 hook getRandomSession/xxtea (修正模块查找) -- [ ] 深挖 IdGenerator::encode (hdid/deviceId 派生链路) -- [ ] 确认 cw[0:2] 前缀与 GetDfpConfig token 的关系 -- [ ] 需要再抓至少2-3组"同秒/同分钟"独立spawn对, 验证模板A/B是否=缓存帧完整复用(而非仅头部) - -## 二十八(round7补) ★★ cw 尾部 10B 固定 footer 发现 (重大结构修正) ★★ - -### 铁证: 13/13 独立捕获 (真机8+模拟器6+dfp_pair) cw 尾部 10B 完全相同 -``` -3600400c0b8c980ca80c -``` -- 覆盖: frame_real 3帧(23:44), dfp_pair(13:28), dfp_coll_1/2/3, identity_223140~225933 6份(模拟器) -- 跨设备/跨时间/跨会话全部一致 → **固定 footer, 不参与 keystream XOR** - -### ★修正后的完整 cw 结构 (模型终版): -``` -cw = [2B会话前缀 7cdb/6cdb/1cda...] - [JSON明文586B ^ keystream] - [collection明文 ~3433B ^ keystream(同一条)] - [10B固定footer: 3600400c0b8c980ca80c] -``` -- 长度验证: dfp_pair cw=4146 = 2+586+3548+10 ✓ (collection=3548) -- frame_real cw=4031/4046/4041 → collection 3433/3448/3443 -- 尾部距尾偏移0-9全帧同值, 偏移10起随机 (已用距尾偏移正确验证) - -### taf 层结构 (dfp_pair): -``` -00 00 10 82 <- 4B 大端长度 4226 -10 03 2c 3c 4c 56 0c "huyaudbwebui" 09 "dfpReport" <- servant+func 区 -7d 00 01 10 56 <- ? -08 00 01 06 04 "tReq" 1d 00 01 10 48 <- tReq 字段 -0a 06 00 16 07 "android" 2d 00 01 10 32 <- android 字段头 -57 18 82 cf 66 4b b3 94 01 ee <- MAGIC(10B) -cw(4146B) <- 2B前缀+JSON^ks+collection^ks+10B尾 -``` - -### 修正引发的重新推导: -- **此前所有"cks 全段随机"结论需按新边界复核**: - - JSON段(586B) keystream 全随机 ✓ (不变) - - collection段(3433-3548B) keystream 全随机 ✓ (不变, 但尾部10B除外) - - **10B 尾部是常量, 不用生成!** -- 对 python 自生成的意义: 尾部硬编码 `36 00 40 0c 0b 8c 98 0c a8 0c` 即可 - -### 待解问题(更新): -- [ ] collection 明文内容 + 长度可变机制 (3433 vs 3548差115B) -- [ ] keystream 生成器 (仍是唯一大黑盒) -- [ ] cw[:2] 前缀 vs GetDfpConfig token 关系 -- [ ] 尝试: 若尾部固定+2B前缀固定, 是否服务端只校验 JSON 部门分字段? - -## 二十九(round7补) ★★★ GetDfpConfig 完整请求/响应抓到 + wup 协议尾确认 ★★★ - -### GetDfpConfig 请求 (67B, CL=67, 明文HTTP POST /, wsapi.huya.com): -``` -0000 0043 <- 4B 大端总长 67 -10 03 2c 3c 4c 56 <- 魔数(与dfp同) -0c "huyaudbwebui" <- 0c=长度12 servant -0c "getDfpConfig" <- func -7d 00 00 15 <- ? -08 00 01 06 04 "tReq" 1d 00 00 08 -0a 06 04 "5008" <- appId -0b 8c 98 0c a8 0c <- ★wup 协议尾! (与 dfp cw 尾部共享 8c980ca80c) -``` - -### GetDfpConfig 响应 (190B, multipart-formdata, 99ms后返回): -``` -0000 00be <- 4B 长度 190 -1003 2c3100804c56 <- 魔数变体 -0c "huyaudbwebui" 0c "getDfpConfig" -7d 00 01 00 8d 08 00 02 06 00 1d 00 00 01 -0c 06 04 "tRsp" <- 响应字段 -1d 00 00 79 <- -0a 08 00 01 06 04 "5008" <- appId -16 "lNLlGtxbxSB2FhJgLec91a5YuecmZ11IBaQTB/AfWiWlKFI74ciBoJnRUmAiOYb4/Bi99jRoCduKcAEgGKsjitY/g0e43bEEkcti5Ek0Lv30=" <- ★base64 81B 数据 -0b 8c 98 0c a8 0c <- wup尾 -``` - -### ★wup 协议尾确认: `0b 8c 98 0c a8 0c` -- 出现于: getDfpConfig 请求尾、getDfpConfig 响应尾 -- 与 dfp cw 尾部 `36 00 40 0c 0b 8c 98 0c a8 0c` 共享 `0b8c980ca80c` -- 结论: **`0b8c980ca80c` 是 wup/taf 固定协议尾, 不是 dfp 特有** -- dfp cw 尾部 10B = `36 00 40 0c`(dfp特有) + `0b8c980ca80c`(wup协议尾) - -### Config 响应 base64 数据 (81B): -``` -94d2e51adc5bc520761612602de73dd5ae58b9e726675d4805a41307f01f5a25 -a528523be1c881a099d15260223986f8fc18bdf6346809db8a70012018ab238a -d63f8347b8ddb10491cb62e449342efdf4 -``` -- 全随机 → 加密配置(疑 XXTEA/AES), 与帧头无直接字节关联 -- 未解: 解密后是否含 keystream 种子/前缀 - -### dfpReport 响应 (344B, 抓到了! 读#48): -``` -00000158 10032c3100804c56 0c "huyaudbwebui" 09 "dfpReport" -7d 00 01 2a 08 00 02 06 00 1d 00 00 01 -0c 06 04 "tRsp" 1d 00 01 15 -0a 06 02 "10" <- 某字段 "10" -16 "865a4924a40897ac1fcfe6b4c2cbb045" <- ★appkey 回声 (cbb045 变体!) -26 "PQwemAN9NHkZKoMqXq7sMRIypqMTaQEOrmXr37xQVhQZqrP5jkKEQ11xvE02qahX9cjIxHA4cI+3n5jHKeMMlkpumIIMJh5IPEXN0CjJHY07KeKx0VQQmCPvAEOFqjj2qF+X7zJGVLQlxK1tj9X6ZOHFr/FHL56Q5qHyTdsYCl8fYjpkmVX32" <- base64 token -00 00 a8 c0 46 06 "actionV" "7c..." -``` -- dfpReport 响应也返回 base64 token (172B)! 与 config 的 81B 不同 -- appkey 回声是 cbb045 (非 cbb0e3) → appkey 变体机制待确认 - -### 新发现: getDfpConfig 不是每次 spawn 都发! -- 有缓存: 清数据(pm clear)后重新拉取 -- 正常 spawn 时可能不发 → 之前的 hook 一直没抓到是因为缓存 - -### 结论更新: -- ★dfp 流程 = GetDfpConfig(取加密配置) → 用配置生成 dfpReport(含 keystream) -- 若 config 解密后含种子 → hook 解密点即得 keystream -- appkey 变体 cbb045/cbb0e3/abc798 机制待解 - -### 待办(更新): -- [ ] ★★Config 81B 解密 (xxtea/aes? 种子在其中?) -- [ ] config 响应变异性测试 (多 spawn 对比) -- [ ] appkey 变体机制 -- [ ] dfpReport base64 token 用途 - -## 三十(round7补) ★★★★★ 差分实验: 服务端不校验 cw 内容! python 零设备生成达成 ★★★★★ - -### 网络通路验证 (python ssl socket POST https://wsapi.huya.com/): -- 直接重放真实 wire body → **HTTP 200 + dfpReport tRsp 响应 (606B)** -- 响应: appkey回声(865a4924a40897ac1fcfe6b4c2cbb045变体) + base64 token(每次不同!) + actionV=真机hdid(7c5387e0...) -- **确认纯 python(无设备/无frida) 可发 dfp 并获响应** - -### ★差分实验结果 (改 body 不同部位看服务端反应): -| 修改 | 结果 | -|---|---| -| 改为原样 | ✅ 606B 200 OK | -| 改 MAGIC 1B | ✅ 200 OK (MAGIC不校验) | -| 改 cw[0] (JSON段) | ✅ 200 OK | -| 全随机 JSON段 586B | ✅ 200 OK | -| 全随机 collection 3548B | ✅ 200 OK | -| 随机 2B 前缀 | ✅ 200 OK | -| **整 cw 全随机(保留尾)** | ✅ **200 OK** ← 决定性! | -| cw 长度变 4000/4290 | ❌ 0B 拒绝 | -| 尾部 10B 改动 | ❌ 拒绝 | -| taf 总长字段(body[0:4])错 | ❌ 拒绝 | -| 截掉尾部 | ❌ 拒绝 | - -### ★★★ 最终结论 ★★★: -1. **服务端不校验 cw 内容** (JSON^ks/collection^ks/前缀/MAGIC 全随意) -2. **必须保证**: taf 总长 4B 正确、cw 长度=4146(2B字段@body[68:70]=0x1032)、尾部10B固定 3600400c0b8c980ca80c -3. **keystream 逆向不再必要** -> python 生成 = 随机数填 cw + 固定结构! -4. cw 长度必须 4146 -> 服务端按固定块长度解析 - -### body 完整结构 (4226B): -``` -[0:4] 0000 1082 4B 大端总长 -[4:10] 1003 2c3c4c56 wup魔数 -[10:23] 0c "huyaudbwebui" servant (len-prefixed) -[23:36] 09 "dfpReport" func -[36:40] 7d 00 01 10 56 -[40:47] 08 00 01 06 04 "tReq" -[47:52] 1d 00 01 10 48 -[52:59] 0a 06 00 16 07 "android" -[59:68] 2d 00 01 集合字段头 (tag 0x2d?) -[68:70] 10 32 ★cw长度 2B (0x1032=4146) -[70:80] 571882cf664bb39401ee MAGIC 10B -[80:4226] cw 4146B = [2B前缀]+[586B JSON^ks]+[3548B coll^ks]+[10B尾] -尾 = 3600400c0b8c980ca80c (固定) -``` - -### python 零设备生成公式: -``` -body = taf_header(len+servant+func+tReq+android) + magic + os.urandom(4136) + tail -``` -- 或最小改动: 复制模板 body[:80], cw=os.urandom(4136)+tail, 长度字段写死 -- **已验证: 随机 cw 直接 200 OK!** - -### 待办更新: -- [x] Config 解密 (不再必要) -- [x] keystream 逆向 (不再必要!) -- [ ] python 生成器代码 (含三元组随机化) -- [ ] 三元组独立性验证 (换随机 hdid/deviceId/appkey 服务器接受?) -- [ ] collection 长度 3548 是否必须 (cw 总长 4146 限制) -- [ ] hdid 与 actionV 关联机制 (服务器从何处知 hdid?) - -### 三十一(round7补) actionV 来源确认 + nonce 机制: -- 响应 actionV = 服务端从 cw JSON段解密出的 hdid (hex 40) -- 改 cw 中 JSON 段 hdid 位置 -> actionV 变化 (任意 off 2/16/32 都变) -- 改 2B 前缀 -> actionV 变化 -> ★前缀参与解密重建 (nonce/IV 角色) -- 改 JSON 前16B -> actionV 变 -- 但全部仍 200 OK! -> 服务端对解密结果宽容,不拒绝乱码 -- 三处改动 actionV 均为 40-hex: 服务端把解密块 hex 化显示 - -### 服务端解密模型 (猜想): -- cw = [2B nonce] + [586B ...] + [3548B coll] + [10B 尾] -- 服务端: keystream = F(前缀nonce, 固定密钥/种子) -> XOR 解密 JSON 段 -- 密钥来源疑: GetDfpConfig 固定 81B 或设备密钥 7jbF5Iiq...(28字符) -- nonce 使每帧密文不同但可解密 -> 解释"keystream 每帧随机"现象! -- 风控可能后续用 actionV/hdid 与登录流程对账 (未验证) - -### ★结论升级: python 生成两个层次 -1. 格式层 (已达成): taf框架+随机cw+固定尾 -> 200 OK -2. 语义层 (待定): 若风控对账 hdid, 需能构造"服务端可解密出指定hdid"的cw - - 需逆向 keystream 生成器 (F(prefix, key)) - - 或验证: 服务端根本不核对 hdid 一致性 -> 随机即可 - - ★实验: dfp 上报后用"另一个hdid"登录测试风控 -> 决定层次2是否必需 - -## 三十二(round7终) ★★★ 最终交付: python 零设备自生成 dfpReport 达成 ★★★ - -### 生成器: tools/dfp_gen.py + tools/dfp_cli.py -- 用法: cd tools && .venv/bin/python dfp_cli.py -n 5 -o out.json -- 输出: 随机三元组 (hdid/deviceId/appkey) + body hex + 发送结果 - -### 验证记录 (5/5 全部 200 OK): -``` -[1] ok=True actionV=2fe5ddbd0211f39daf2ba3c2e4d7699810d920b1 ... -[2] ok=True actionV=5bf5fce774f8e1bff235de5f733196301011f61e ... -[3] ok=True actionV=..., [4] ok=True, [5] ok=True -``` - -### 生成公式 (零设备, 纯 python): -``` -body = 4B总长 + TAF_HEAD(66B) + MAGIC(10B) + cw(4146B) -cw = os.urandom(2) + os.urandom(586) + os.urandom(3548) + 3600400c0b8c980ca80c -``` -- TAF_HEAD 含: wup魔数+servant(huyaudbwebui)+标记66+func(dfpReport)+tReq+android+cw长度0x1032 -- ★服务端不校验 cw 内容 (整段随机已证 200 OK) -- ★必须保持: 总长4B 正确 / cw长度固定4146 / 尾部10B固定 - -### 服务端可解密 cw 但宽容: -- actionV = 服务端从 cw JSON段解密出的 hdid (改cw该段->actionV变) -- 2B前缀参与解密(改前缀->actionV变), 疑为 nonce -- 但解密乱码不回绝 -> python 随机 cw 完全可用 -- ★后续若风控校验 hdid一致性(登录用), 需做语义层: 构造服务端可解出指定hdid的cw(逆向ks生成器) - 当前阶段(上报)无需; 待用户测试登录流程后定 - -### 剩余待办 (记录备查): -- [ ] 服务端 hdid 一致性校验验证 (dfp 上报 vs 登录) - 决定是否需要语义层 -- [ ] 跨设备/跨号风控实测 (多三元组轮换) -- [ ] 速率限制/阈值观察 (连发5次未限) -- [ ] 真实 flow: 生成器三元组 → hycredLogin 等登录链路对接 - -## 三十三(round7续) ★ 登录链路对接验证: 生成器注册链端到端出 cred ★ - -### 完整链路 (全部纯 python, 已验证): -``` -[零设备生成器] [wsapi.huya.com] [wup.huya.com] -getDfpConfig(67B) -> 190B 配置(固定,含key材料) -selectOperator(自定义sd+fp) -> 187B 运营商索引 -dfpReport(随机cw 4146B) -> t1(恒=appkey变体回声) + t2(action 新签发) + t5(device_id) - ↓ 登录帧字段 -hypasswordLogin(hdid金样本 + t2 + t5 + 独立画像) -> cred 114B ✅ / safe_auth滑块(可自动过) ✅ -``` - -### ★关键实证 (本轮新增): -1. **t1 恒不变** = 865a4924a40897ac1fcfe6b4c2cbb045: - - selectOperator 全改(sd+fp+机型) 都不影响 t1 → **t1 不是设备锚** - - t1 = appkey 变体回声 (服务端应用配置; dfp JSON 里 appkey=cbb0e3 变体) -2. **t5/actionV = 服务端从 cw 解出的 40hex hdid**: - - 金样本 cw → t5=7c5387e0(真机hdid); 随机 cw → t5=随机值 - - 服务端对"解密乱码"宽容(仍 200 + 签发 t2) → 随机 cw 完全可用 -3. **登录帧 32hex hdid (ed0db8...) 是唯一不可铸造硬锚**: - - 换任意新值 → APP_SIGN_NOT_MATCH 硬错(652B, 无滑块) - - 来自 libhydeviceid.so 本地生成 + native setDeviceInfo(0xb000021) 上报, 不走 HTTP -4. **服务端校验矩阵(登录)复验**: - | fingerprint 随机 | safe_auth 滑块 → solver 自动过 → cred ✅ | - | device_id 换值 | 直接通过 ✅ | - | hdid 换值 | APP_SIGN_NOT_MATCH ❌ | -5. **生成器注册链端到端**: - - 随机 cw 的 dfpReport → 新签发 action/device_id → 登录出 cred (金样本hdid) - - 纯 python 零设备! 设备身份字段(t2/t5)全部服务端新签发 - -### 对"每账号独立设备"的最终答案: -- 可达: 每账号随机 fingerprint/device_id/机型/vendor/screen + 独立 sdid(action 每账号新拉) -- 不可达(纯代码): 新 32hex hdid (libhydeviceid.so 黑盒 + native setDeviceInfo 无 HTTP 通道) -- 共用金样本 hdid 登录无碍, 但服务端可设备维度关联账号 → 若需真隔离须真机设备池 - (tools/huya_device_profile.py 已实现画像池, 端到端验证过) - -### 本轮验证记录: -- 生成器随机cw + 金样本hdid + 金样本fp → cred 114B ✅ (2次) -- 生成器随机cw + 金样本hdid + 随机fp → safe_auth滑块 ✅ (预期, solver可过) -- selectOperator 全改实验 → t1恒不变, t5随cw变 (证 t1非设备锚) - -## 三十四(round7终) ★★★ 登录链路对接完成: 零设备注册链 → cred 全链路实测 ★★★ - -### 最终端到端 (tools/huya_device_register.py --gen-login): -``` -注册链: t1(appkey回声)=865a4924a40897ac1fcfe6b4c2cbb045 - t2(action)=PQwemAN9NHkZKoMq... len=180 ← 服务端新签发 - t5(device_id)=de440c0a1c5e26949c52d485152366bc102118d4 ← 服务端新签发(随机cw解出) -登录结果: cred 0a40d5396b76568c... ★★★ (金样本 hdid ed0db8...) ★★★ -``` - -### 最终链路图 (100% 纯 python, 无设备/hook): -``` -tools/dfp_gen.py 生成随机cw(零设备) - ↓ -wsapi.huya.com: getDfpConfig(67B) → selectOperator(可自定义sd/fp) - ↓ -dfpReport(随机4146B cw) → t1(appkey回声) + t2(action,新) + t5(device_id,新) - ↓ -wup.huya.com: hypasswordLogin(金样本hdid + t2 + t5 + 每账号画像) → cred 114B ✅ -``` - -### 服务端校验全矩阵 (最终定论, 全部实测): -| 环节 | 校验点 | 能否铸造 | -|---|---|---| -| dfpReport | cw 内容 | ★完全不校验 (随机即可) | -| dfpReport | MAGIC | 不校验 (任意) | -| dfpReport | cw 长度 4146 | 必须 (固定块解析) | -| dfpReport | 尾部 10B | 必须固定 3600400c0b8c980ca80c | -| dfpReport | 总长字段 | 必须正确 | -| 登录 | hdid 32hex | **否** (APP_SIGN_NOT_MATCH; libhydeviceid.so 黑盒) | -| 登录 | fingerprint | 可换 → 滑块(自动过) | -| 登录 | device_id | 可换 (注册链签发) | -| 登录 | safedeviceid | 可换 (注册链签发) | -| 登录 | 机型/屏幕/UA | 可换 (自洽即可) | - -### 最终交付物: -- tools/dfp_gen.py —— 零设备 cw/body 生成 (5/5 200 OK) -- tools/dfp_cli.py —— dfp 生成 CLI -- tools/huya_device_register.py --gen / --gen-login —— 注册链+登录一体 (零设备) -- tools/huya_device_profile.py —— 每账号设备画像 - -## 三十五(round7终) ★★★ 全局复盘: 用户两问完整答案 + 未搞定清单 ★★★ - -### Q1: "零设备自生成 dfpReport, 当初不是想获取 hdid 吗? 怎么现在 hdid 还是固定的?" - -#### ★核心澄清: 虎牙有两套独立的 hdid, 长期被混淆! - -| 体系 | 值(真机) | 长度 | 来源 | 位置 | 能否铸造 | -|---|---|---|---|---|---| -| **dfp 链 hdid** | `7c5387e0539c023c31c4ff0e807e7256117385ee` | 40hex | 明文 JSON 字段(可任意写) | dfpReport 明文 JSON + 响应 actionV/t5 | **能!! 零设备已达成** | -| **登录帧 hdid** | `ed0db8334cadd236c00cadf7e11ab5a5` | 32hex | libhydeviceid.so 本地生成 + native setDeviceInfo(0xb000021) 上报, 不走HTTP | WUP hypasswordLogin t1.t0 | **不能** (APP_SIGN_NOT_MATCH) | - -- **当初"想获取的 hdid" = dfp 链 40hex hdid** —— 这个**已经完全实现**: JSON 里写任意 40hex, - 随机 cw 加密(rnd), 服务端 200 OK + 回显。零设备自生成 ✅ -- **"现在还是固定的 hdid" = 登录帧 32hex hdid** —— 这是**另一套设备ID**, 与 dfp 上报无关: - libhydeviceid.so 本地算的, 走 native 通道(0xb000021)上报, **没有 HTTP 等价物** → 纯代码无法铸造 -- 用户看到的"固定"是因为: 登录(cred)必须用金样本 32hex hdid `ed0db8...`, - 换任意新值 → APP_SIGN_NOT_MATCH (实测: 随机32hex/dflcollect的40hex/截断 全被拒) - -#### 为什么 dfp 40hex hdid 可铸造但不影响登录? -- dfp 上报的 40hex hdid 只是"上报数据", 登录时服务端**不核对**它 - (登录帧 device_id 来自注册链 t5, hdid 32hex 单独一套) -- 差分铁证: 随机 cw(JSON 里 hdid 是随机乱码) → 服务端 200 OK, 还签发 t2/t5 → 登录出 cred - -### Q2: "是什么困了这么久没搞定? 还有哪些没搞定?" - -#### ★历史复盘: 最大的"困住" = 路径错误(先逆向加密, 后差分) -前期(round1-7 上半)假设"必须逆向 keystream/加密才能铸造", 投入大量精力: -srand/rand 链路(sub_1D1A68→sub_1D0BB8, 被证伪) / MAGIC 无字面量 / OLLVM 扁平化混淆 / -导出符号 0 调用 / 模板 A/B 复用分析 / GetDfpConfig 种子假设(config 固定无种子) ... -**直到 round7 差分实验**: 整段 cw 随机(4136B os.urandom) → 200 OK!! -→ 服务端根本不校验内容 → 之前所有"破解 keystream"的努力全部变成非必要。 - -**教训: 先差分(服务端校验什么)再逆向(客户端怎么生成), 可省 90% 工作量。** - -#### ★未搞定清单 (全部, 含影响评估): - -| # | 未搞定项 | 状态 | 对交付影响 | -|---|---|---|---| -| 1 | **登录帧 32hex hdid 铸造** (libhydeviceid.so OLLVM + native setDeviceInfo) | 未破 | **唯一实质缺口**: 多账号共用一个 32hex hdid → 服务端可设备维度关联账号 | -| 2 | **XOR keystream 生成器精确算法** | 未破(已放弃) | **无影响** —— 服务端不校验内容, 随机 cw 即可 | -| 3 | **三元组(40hex hdid/deviceId/appkey)派生算法** (IdGenerator::encode/OLLVM MD5) | 未破 | **无影响** —— dfp JSON 三元组服务端不校验; 任意随机即可 | -| 4 | **collection 明文/构建** | 未全解 | **无影响** —— 服务端不校验 collection 内容 | -| 5 | **GetDfpConfig 81B 配置解密** (XXTEA/AES? 密钥候选 `7jbF5Iiq...`) | 未破 | **无影响** —— 已实证 config 固定(3/3), 不含每会话种子 | -| 6 | **appkey 变体机制** (cbb0e3→cbb045→abc798?) | 未解 | **无影响** —— t1 恒=cbb045 回声, 登录不校验 | -| 7 | **dfpReport 响应 172B base64 token** | 部分解 | t2 = 登录帧 safedeviceid(已用); token 其余细节未知, 不影响 | - -#### ★真正达成的能力 (本轮全链路实测): -``` -[零设备] dfp_gen.py 随机 cw → 注册链(wsapi) → t2 action + t5 device_id - → hypasswordLogin(金样本32hex hdid + t2/t5 + 每账号画像) → cred 114B ✅ -``` -- 登录所需字段: **32hex hdid 固定复用**(唯一硬锚) + 其余全部可每账号独立 -- dfp 上报链: **100% 零设备可生成**, 功能完整(200 OK + 签发设备字段) - -#### ★对"每账号独立设备身份"的最终诚实结论: -1. **dfp 上报层独立**: ✅ 每账号可独立 40hex 三元组/随机 cw/注册链(零设备) -2. **登录层独立**: ❌ 32hex hdid 无法跨账号隔离(纯代码); 服务端能在设备维度关联 -3. **如果要真隔离**: 两条路(均不纯代码): - a. 多台真机/模拟器各采一套 {32hex hdid + 画像} 建设备池轮换 (已实证: 每台模拟器=独立身份) - b. 破解 libhydeviceid.so 32hex hdid 生成算法 (OLLVM, 高成本, 另有 native 上报通道未知协议) -4. **多账号单设备是否真被风控**: 登录层无碍(新账号直出 cred, 实测) — 风险仅在服务端 - 设备维度关联策略(外部不可见), 若担心可用设备池轮换规避 - diff --git a/docs/虎牙/模拟器存活闪退诊断报告.md b/docs/虎牙/模拟器存活闪退诊断报告.md deleted file mode 100644 index 8bccc63..0000000 --- a/docs/虎牙/模拟器存活闪退诊断报告.md +++ /dev/null @@ -1,207 +0,0 @@ -# 模拟器闪退/卡启动/验证码不加载 —— 存活与崩溃诊断报告 - -> 日期: 2026-08-27 -> 环境: 模拟器 GC3VE (Android 12 API32, arm64, MuMu系, Kitsune Mask 26.4 rooted) -> App: 虎牙 com.duowan.kiwi 13.4.22 (115315) -> frida: server 15.2.2 (fs152, tcp:31878) + RE仓库 .venv (frida-py 15.2.2) -> 本文档只回答一件事: **"App到底什么时候活着、什么时候闪退、为什么"**, 判断方法全部可复核。 - -## 〇、先纠正一个根本性错误: 如何判断"是否存活/是否闪退" - -之前(包括文档既有结论)用以下方式判断进程状态, **全部不可靠**: - -| 旧方法 | 为什么错 | -|---|---| -| `frida enumerate_processes()` 里 pid 存在 | spawn 后枚举有延迟/漏报; 进程死后 agent 会话残影还在; **多次实测"显示死"但 /proc 里明明活着** | -| `pidof com.duowan.kiwi` 取第一个 pid | 会混入子进程 `com.duowan.kiwi:cloudpatch` / `:logcat`(它们的 cmdline 以包名开头); **把子进程当主进程 attach → 白费** | -| 脚本结束后看到黑屏就以为闪退 | 屏幕回桌面 ≠ 进程死: **App 有 KeepAlive, UI Activity 被崩掉后进程会被系统保活重启**, pid 可能换新 | -| `d.kill(pid)` / force-stop 后说"App死了" | 是我们自己杀的, 不算闪退 | - -**本次统一的事实来源 (evidence/diag_lifecycle/diag_*.json 均按此采集):** - -1. **主进程存活** = `/proc//cmdline` 读出来**精确等于** `com.duowan.kiwi`(排除 `:cloudpatch`/`:logcat` 子进程)。`pidof` 每一 pid 都过 cmdline 过滤。 -2. **UI 是否闪退** = `dumpsys window | grep mCurrentFocus` 里是否还包含 `com.duowan.kiwi`(回到 `app.lawnchair` 即 UI 已退出)。 -3. **是否发生崩溃** = `logcat -d -b crash` 是否有 `Fatal`/`signal`/`Abort message`。native 崩溃(如 EGL)会写 tombstone, msaoaidsec 静默 `_exit` 则 crash buffer 无记录——**两种死法必须分开看**。 - ---- - -## 一、四组对照实验结果 (2026-08-27 实测) - -脚本: `scripts/diag_lifecycle.py [a|b|c|d]`, 证据: `evidence/diag_lifecycle/diag_{a,b,c,d}.json` - -| 组 | 做法 | 主进程存活 | UI 前台 | crash buffer | 结论 | -|---|---|---|---|---|---| -| **A** | 无 frida, monkey 正常启动 | ✅ 全程存活 (pid 不变) | ✅ Homepage | 0 | **无 frida 完全正常, 不闪退** | -| **B** | frida spawn, **挂起 0.11s 立即 resume, 不加载任何脚本** | ✅ 全程存活 | ✅ Homepage | 0 | **spawn 本身不杀 App!** | -| **C** | frida spawn, 挂起**加载 2 个 bypass 脚本** (art_callsite+mask_frida) 再 resume | ❌ 进程死 | ❌ 回桌面 | 1 条/轮: EGL | **挂起加载脚本 → EGL 崩** | -| **D** | 正常启动 7s 后 **attach 主进程**(裸, 无脚本) | ❌ 进程死(静默) | ❌ 回桌面 | 0 | **attach 主进程 → msaoaidsec 静默 _exit** | - -### 可重复性: B、C 各单独跑 3 轮 (2026-08-27 06:22~06:26) - -| 轮次 | B (零挂起 spawn) | C (挂起+加载2脚本) | -|---|---|---| -| r1 | ✅ 存活 25.8s+ UI=Homepage crashes=0 | ⚠️ 存活 26s 但 UI 解析异常 (focus=?), crashes=0 | -| r2 | ✅ 存活 25.8s+ UI=Homepage crashes=0 | ❌ +8.5s 进程 None, UI 回桌面, crashes=1 | -| r3 | ✅ 存活 25.8s+ UI=Homepage crashes=0 | ❌ +8.4s 进程 None, UI 回桌面, crashes=1 | -| 汇总 | **3/3 稳定存活** | **2/3 明确 EGL 崩, 1/3 存活但异常** | - -C 组死亡时间线 (r3 完整轨迹): `+2.1s proc 存活 UI=Homepage → +4.2s pid 换新(KeepAlive 重启) UI 回桌面 → +8.4s 无主进程`; crash buffer: -``` -signal 6 (SIGABRT) Abort message: 'Failed to create context, error = EGL_NOT_INITIALIZED' -tid RenderThread, pid <原pid> → com.duowan.kiwi -``` -注: 脚本 OUT 固定为 diag_c.json 每轮覆盖, 最终文件是最后一轮的数据; rounds 输出见 /tmp/diag_{b,c}.log 系列与 evidence/diag_lifecycle/。 - -### 关键崩理解读 - -**C 组 / 今天早上 v8/v9 的 tombstone (tombstone_24~27) 全部同型:** -``` -signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x74206665722060 (ASCII 内容!) -#00 __strchr_aarch64+36 / libc.so -#01 strstr+24 / libc.so -#02 android::egl_display_t::initialize+824 / libEGL.so -#03 EglManager::initialize / libhwui.so -#04 RenderThread::requireGlContext / libhwui.so -``` -以及 `signal 6 (SIGABRT) Abort message: 'Failed to create context, error = EGL_NOT_INITIALIZED'` -→ 崩溃线程是 **RenderThread(EGL 初始化)**, 和 msaoaidsec 无关。 - -**为什么会这样 (经 E6/E7 修正):** frida `spawn` 会把进程挂起在非常早期(zygote fork 后、JVM/GL 尚未初始化)。**关键实验 E6/E7: 挂起只 10~18ms、仅加载 1 个补丁脚本, attach 后 RenderThread 仍 100% EGL 崩 (Bad access / Trace/BPT trap, 4/4 轮)** → 说明触发 EGL 崩的**不是挂起时长, 而是 attach 注入本身**(frida agent 初始化与 EGL 显示初始化竞争)。纯 spawn 不 attach (B组) 则完全无此问题。 - -**D 组: attach 主进程 = 真 frida 检测。** 裸 attach 无任何 hook, 2~4s 静默死, crash buffer 无记录 → `libmsaoaidsec.so` 检测到 agent 后 `_exit`(RE 仓库 STATUS: `EXIT_SELF`)。这与 EGL 崩是**两条完全独立的死因**。 - -### 补充实验 E0~E7: attach 与死因的精确归因 (2026-08-27 06:30~06:36) - -脚本: `scripts/diag_egl_strategy.py`, 证据: `evidence/diag_egl_strategy/egl_strategy.json` - -| case | 做法 | 挂起 | 结果 | 死因 | -|---|---|---|---|---| -| E0 | spawn→attach(空)→resume | 3ms | ❌ ~2.07s pid换新(KeepAlive) | 静默_exit (msaoaidsec) | -| E1 | spawn→attach+mask_frida→resume | 15ms | ❌ ~2.07s pid换新 | 静默_exit (mask无效) | -| E3 | spawn→resume (不attach) | 0ms | ✅ 存活30s+ ×2 | — (B组复现) | -| E4 | spawn→resume→1.6s后attach | 1673ms | ❌ attach后~2.5s死 | 静默_exit | -| E5 | spawn→resume→5s后attach | 5183ms | ❌ attach后~4.1s死 | 静默_exit | -| E6 | spawn→attach+art_callsite→resume | 11~18ms | ❌ 4.1s死 ×2 | **EGL崩 (RendererThread)** | -| E7 | spawn→attach+art_callsite+mask→resume | 10~12ms | ❌ 4.1s死 ×2 | **EGL崩 (RendererThread)** | - -**结论:** -1. **attach 必定触发检测/崩溃**, 无论时机(挂起中 3ms 还是延迟 5s)、无论加载什么脚本、无论挂起多短。空 attach → msaoaidsec 静默 `_exit`; attach+bypass 脚本 → msaoaidsec 的清理路径被补丁掉后转为 EGL 崩溃。 -2. `mask_frida_maps_only.js` 单独使用**无效**(E1 仍静默死)——RE 仓库的 maps 遮蔽思路在此版 msaoaidsec 的模拟器行为下失败, 仅真机组合(G2-0055 多面)才有效。 -3. **唯一无外挂存活路径 = 纯 spawn→resume 不 attach**(B组/E3), 代价是无任何 hook 能力。 - -### 抓包三策略实测 (v11: scripts/emu_hook_zero_suspend.py, 2026-08-27 06:40) - -| 模式 | 做法 | 结果 | -|---|---|---| -| zero | 纯spawn→resume (无hook) | ✅ 存活30s+ 但**0事件** (抓不到协议) | -| stale | 挂起中加载art_callsite+mask→resume→+0.5s挂hook | ⚠️ 存活~4.2s: **55事件 + actionV=234548246275beedf8d93a242ef50e8f1178c3b2** (GC3VE真实身份) → 随后EGL崩 KeepAlive换pid | -| race | resume后2s attach+2脚本+hook | ❌ attach后~2.2s静默死: 17事件(早期RESP) 无actionV | - -**→ 抓包能力排序: stale > race > zero。** stale 在死前窗口(约4s)能完整抓到 dfpReport+actionV+dckey 链路(evidence: /tmp/v11_stale.json, 55事件), 与历史成功的 `hook_emu_stable.py`/`emu_spawn_bypass_dfp.json` 一致——**"抓帧即成功, App 随后崩是固有代价"**。race 模式(B组存活后延迟 attach)被 msaoaidsec 静默杀, 抓不到关键帧。 - ---- - -## 二、历史"成功"记录的真相 (为什么之前以为 attach 能用) - -- `evidence/emu_attach_full.json` (08-26 20:33, 48 事件) 是**当时 attach 脚本的产物**——但当时脚本用 - ```python - for p in d.enumerate_processes(): - if 'kiwi' in p.name or 'duowan' in p.name: # ← 致命 - ``` - frida 枚举里**主进程 name 显示为 "虎牙直播"**, 只有 `com.duowan.kiwi:cloudpatch`/`:logcat` 含 "kiwi" 字样。 - **→ 当时 attach 到的是 cloudpatch 子进程, 从未真正 hook 主进程。** 所谓"模拟器 attach 不崩"是子进程的假象。 -- `hook_emu_stable.py`/`spawn_g2055_survival.py` 的 spawn 成功: 实质是 **spawn 后挂起很短(只 load 2 个极快脚本)就 resume**, 命中"低挂起存活窗口"; 但 `spawn_survival.json` 也记录了 4 轮里 6s/6s/6s/3s 全死——大部分轮次仍触发 EGL 崩, 只是脚本在死前刚好抓到 dfp 报文就算"成功"。 -- **结论校准**: spawn 路线历史上"成功即抓到 dfp"并非稳定存活, 而是抢在 EGL 崩之前的窗口抓到数; 本次 C 组 3 轮 2 崩也验证了这一点。真正稳定的是 B 组(零挂起)。 - ---- - -## 三、"验证码无法正常加载"的机制与实测 - -### 3.1 登录验证码的真实链路 (本次实测) - -1. 账号密码页点"立即登录" → 弹协议 → 同意 → 进入 **`OakVerifyActivity`** (com.huya.kiwi.crossplatform.common.webview.verification) -2. 内部是 **WebView 加载极验/数美 H5 验证页**, 日志可见验证码 SDK 类 `cn.fly.verify.*` (数美 fly verify), UI 出现 **"安全验证 / 请拖动下方滑块完成拼图"** -3. 单次流程可见 dfpReport(3800B) → actionV → dckey(744B) 链路照常上报 - -### 3.2 实测结论 (模拟器 GC3VE) - -- **无 frida (A 组)**: 账号密码登录 → 滑块验证码**完整加载**(UI dump 出现"请拖动下方滑块完成拼图"+ WebView 容器)。→ **验证码不加载不是模拟器环境的锅**。 -- 之前"验证码不加载"发生的场景 = App 在 **frida 挂起/attach 被检测**的状态下启动或运行, 或 msaoaidsec 检测到 agent 后 App 处于被风控标记状态(行为上报 Poseidon/dfp collection 带 `hooked:...` 字段, 服务端不下发验证 H5)。 - - 推论: **登录流程不要带 frida**; 需要 hook 取证时, 用"零挂起 spawn + 抓完即杀"。 - -### 3.3 模拟器检测面 (本次静态+动态确认) - -| 层 | 检测内容 | 证据 | -|---|---|---| -| Java 层 | `/dev/qemu_pipe`, `/dev/socket/qemud`, `/sys/qemu_trace`, `/system/bin/qemu-props`, `libc_malloc_debug_qemu.so` | classes.dex 字符串; `ryxq.nuj.F()` | -| Java 层 | `Build.HARDWARE/PRODUCT/FINGERPRINT/BRAND` goldfish/sdk/generic, `ro.kernel.qemu` | `ryxq.xzj.c()/q()`, `ryxq.nuj.L()/N()` | -| Java 层 | `/system/build.prop`, `/proc/tty/drivers`, `/proc/cpuinfo` goldfish | `ryxq.nuj.J()` | -| Java 层 | su 文件 (`/system/bin/su` 等 5 路径) | `ryxq.xzj.e()` | -| Native | `KH_NSDKThreadDe` 线程读 `/system/etc/mumu-configs/currentApp` 等 MuMu 特有路径 (logcat avc 可证) | libnsdt.so | -| UI | 服务端下发 `EmulatorCard` (HUYA.EmulatorCard) 组件 | classes8/9 dex | -| 配置式 | "Get emulator files feature from config / model feature from config" (可从远程配置增删检测项) | classes6.dex | - -本机现状: MuMu 的 `fakeProductInfoIfNeed` 对 com.duowan.kiwi **未匹配**任何伪装模板 → App 看到的 build 属性是**原始 qemu 泄漏**: -``` -ro.build.hv.platform=qemu ← 明显泄漏 -qemu.hw.mainkeys=1 -``` -其余 (ro.kernel.qemu 等) 已由 MuMu 内核隐藏。**Java 层 goldfish/su 检查当前在 GC3VE 上不命中**(brand=Google, fingerprint=release-keys, su 被 Magisk hide 处理), 所以无 frida 时 App 照常给验证码。 - ---- - -## 四、最终结论与可用操作路径 - -### 事实矩阵 (v11 实测修正: 2026-08-27 06:40) - -| 想要做的事情 | 正确做法 | 实测 | -|---|---|---| -| App 在模拟器上正常启动/正常登录/验证码加载 | **完全不要 attach frida** | A 组: 100% 正常, 验证码完整加载 | -| 仅在模拟器取证抓 dfpReport / hdid / 协议帧 | **stale 模式**: spawn 挂起中加载 art_callsite+mask → resume → 尽快挂 SSL hook | v11 stale: 存活~4.2s, 55事件+actionV 完整链路, 随后 EGL 崩属固有代价 | -| 用 frida attach 主进程长时 hook | **当前不可行** (任何 attach 均死: 空attach静默_exit / attach+bypass=EGL崩) | E0~E7 全灭, 与挂起时长/时机无关 | -| 纯 spawn 不 attach | 可稳定存活(无 hook 能力), 适合验证 App 本身状态 | B组/E3: 3/3+2/2 存活 | -| 长时间 hook 主进程 | 未达成 | 需解决 masaoaidsec (RE 仓库 B-G2-0001 阻塞仍开放) 或换非 frida 注入 | - -### 给登录/验证码流程的操作要求 - -1. **登录页面(UIs): 全程不启 frida。** 验证码走 `OakVerifyActivity` 原生 WebView, 与 hook 无关。 -2. **取证抓包: 用 stale 模式单独一次 spawn** (`emu_hook_zero_suspend.py --mode stale`), 死前窗口(约4s)即可抓到 dfpReport/actionV, 抓完 App 自然崩/被杀, 再正常启动走登录。**不要用 race/attach 方式**(静默死还抓不到关键帧)。 -3. 若见"验证码空白/不加载": 先 `am force-stop` + 正常重启 App 再试(清除 frida 痕迹); 若仍空白, 查 `logcat -b crash` 是否 EGL tombstone(挂起残留), 不是的话多半是服务端风控(行为上报带 hook 标记), 需要换干净设备身份。 - -### 复用脚本 (本次新增/更新) - -- `scripts/diag_lifecycle.py` — 四组存活/闪退/崩溃对照诊断器 (A/B/C/D), 输出 JSON 证据 -- `scripts/diag_egl_strategy.py` — E0~E7 attach 归因实验 (空attach/mask/art_callsite/延迟attach) -- `scripts/watch_proc_v2.py` — 主进程存活监控 (cmdline 精确匹配, 不用 frida enumerate) -- `scripts/emu_hook_zero_suspend.py` — **v11 三策略抓包**: `--mode zero|race|stale` (推荐 stale 抓帧) -- `scripts/emu_spawn_captcha_test.py` — spawn+bypass 下验证码加载对照 (结论: spawn 挂起偶发 EGL 崩, 登录勿用) -- `scripts/emu_attach_captcha_test.py` — attach 三件套对照 (结论: attach 主进程必死, 勿用于登录) -- `scripts/hook_emu_stable.py` — 既有: spawn+极短挂起抓 dfpReport (stale 模式同款, 可用) - -### 下一步(按价值排序) - -1. **P0 ✅完成** 抓包脚本 v11 三策略落地: stale=抓帧正解(zero存活不可抓, race被静默杀) -2. **P1** 验证纯 Python 登录链再次打通 (不依赖模拟器 UI, 项目已有零设备链路) — 作为不碰 frida 的正路 -3. **P2** 若确需长时 hook: 深入 masaoaidsec (RE 仓库 G2 系列, 模拟器与真机版本差异), 或换用非 frida 注入 (ptrace-free/gdb), 或尝试 masaoaidsec 检测线程启动前的时间窗 (数据: msaoaid 检测在 App 启动 ~2s 内完成, attach 窗口<2s 理论存在但 E4 已证 1.6s attach 仍死, 需 <1s 超早 attach 验证) - ---- - -## 附录 A: 本次使用的判定代码要点 - -```python -def main_pid(): - # 唯一可信: /proc//cmdline 精确等于包名 - for p in adb pidof 的所有 pid: - if cat /proc/

/cmdline == "com.duowan.kiwi": return p - -def focus_ok(): - m = re.search(r"mCurrentFocus=Window\{([^}]+)\}", dumpsys window) - return PACKAGE in m.group(1) # 注意 u0/窗口hash在花括号内 - -# 崩溃: logcat -d -b crash | grep -E 'Fatal|signal|Abort message' -``` - -## 附录 B: 相关既有文档 - -- `docs/dfpReport破解进度.md` 第十四~十六节 (模拟器探索/EGL 崩溃原始记录) -- RE 仓库 `STATUS.md` B-G2-0001 (msaoaidsec EXIT_SELF 阻塞) / G2-0055 (maps/ELF 部分绕过) \ No newline at end of file diff --git a/docs/虎牙/虎牙App密码登录-WUP明文抓包.md b/docs/虎牙/虎牙App密码登录-WUP明文抓包.md deleted file mode 100644 index dcd2b95..0000000 --- a/docs/虎牙/虎牙App密码登录-WUP明文抓包.md +++ /dev/null @@ -1,309 +0,0 @@ -# 虎牙 App 密码登录 — WUP 明文抓包实证(最终) - -> 状态:**密码登录完整 WUP 请求已抓到(TLS 加密前明文)** -> 证据:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/evidence/derived/huya-wup-full3.jsonl` -> 方法:hook `createWupRequestData` onLeave 拿到序列化后的 WUP 字节 - ---- - -## 一、密码登录 WUP 请求(核心结论) - -| 项 | 值 | -|---|---| -| **servant** | `huyaudbwebui` | -| **func** | `hypasswordLogin` | -| **协议** | WUP / TAF(JCE) 二进制序列化 | -| **msgType** | 4097 (0x1001) = `MsgLogin.mMsgId` | -| **请求大小** | 1356 字节 | -| **密码** | SHA1(明文) hex,无盐(`772ed992b0e161276f44ec63671e60155c506294`) | -| **name** | 虎牙号为 `hy_<虎牙号>`;手机号登录时为 11 位手机号原值 | - ---- - -## 二、WUP 帧完整结构(已 100% 逆向定稿) - -> 金标准文件:`evidence/wup_passwordlogin_taf.bin`(1015 字节 TAF 二进制)+ `evidence/wup_passwordlogin_base64.bin`(1356 字节 base64,即 HTTP body) -> HTTP:`POST https://udblgn.huya.com/open/hy/passwordLogin`,`Content-Type: application/x-wup` - -### 2.1 外层:WUP RequestPacket(标准 JCE/TAF 序列化) - -``` -[0:4] uint32 BE 总长 = 1015 - -JCE RequestPacket 字段(head = (tag<<4)|type): - tag1 iVersion = 3 (INT8: 10 03) - tag2 cPacketType = 0 (ZERO: 2c) - tag3 iMessageType = 0 (ZERO: 3c) - tag4 iRequestId = session(3251699) (INT32: 42 00 31 9d f3) - tag5 sServantName = "huyaudbwebui" (STR1: 56 0c + 12B) - tag6 sFuncName = "hypasswordLogin" (STR1: 66 0f + 15B) - tag7 sBuffer = MAP{...} (SIMPLE_LIST: 7d 00 01 03 c1 + 961B) - tag8 iTimeout = 0 (ZERO: 8c) - tag9 context = {} (空map) (MAP: 98 0c) - tag10 status = {} (空map) (MAP: a8 0c) -``` - -> 关键澄清:之前误认为 `'f'(0x66)` 和 `'}' (0x7d)` 是分隔符,实为 JCE 字段头: -> - `0x56` = 字段5(sServantName)+STRING1 -> - `0x66` = 字段6(sFuncName)+STRING1 -> - `0x7d` = 字段7(sBuffer)+SIMPLE_LIST - -### 2.2 中层:sBuffer(961 字节)是一个 MAP - -``` -MAP (2条目): - "_wup_data" → 915 字节 (内层登录请求 struct) - "wupudbrequest_v0" → 5 字节 = 02 00 31 9d f3 (版本2 + session INT32) -``` - -### 2.3 内层:_wup_data(915 字节)登录请求 struct(完整字段映射) - -``` -_wup_data = STRUCT { - field0 = STRUCT { # 元数据+风控 - tag0 = 0 - tag1 = "1.0" # 协议版本 - tag2 = 207B JSON (associationId/funcName/group/id/session/step/stillLogin/traceId/type/uid/userContext) - tag3 = "5008" # bizAppid - tag4 = 3 (INT8) # = type - tag5 = 180B safedeviceid # 设备指纹(base64) - tag6 = "" - tag7 = "" - tag8 = 210B userAction JSON # 风控行为轨迹 - tag9 = "" - } - field1 = STRUCT { # 设备基础信息 - tag0 = "ed0db8334cadd236c00cadf7e11ab5a5" # hdid (32 hex) - tag1 = "13.4.22" # App版本 - tag2 = "1.0.80138" # SDK版本 - tag3 = "" - tag4 = "127.0.0.1" # 占位 - tag5 = "xiaomi" # 厂商 - tag6 = "" - } - field2 = STRUCT { # 设备详情 - tag0 = 1 (INT8) - tag1 = "M2102J2SC" # 型号 thyme - tag2 = "02df398797432eadefcc12767119ad5e80999389" # 设备唯一ID(40 hex) - tag3 = "android" - tag4 = "M2102J2SC,30,11" # 型号,SDK,版本 - tag6 = "1080" # 屏宽 (tag5缺失) - tag7 = "2120" # 屏高 - tag8 = "7c5387e0539c023c31c4ff0e807e7256117385ee" # 另一设备ID(40 hex) - } - field3 = STRUCT { # 登录凭证 - tag3 = "hy_300023887" # name (虎牙号示例;手机号登录时直接放手机号) - tag4 = "772ed992b0e161276f44ec63671e60155c506294" # password = SHA1(明文) - tag5 = LIST["5008"] # appid列表 - tag6 = 1 (INT8) - tag7 = {} (空map) - tag8 = [] (空simplelist) - } -} -``` - -### 2.4 原始可读字符串(完整) - -TAF 序列化,可读字符串全量提取如下(关键字段用换行标出): - -``` -AAAD9xADLDxCADGd81YMaHV5YXVkYndlYnVpZg9oeXBhc3N3b3JkTG9naW59... ← WUP 头 - └─ "huyaudbwebui" ← servant - └─ "hypasswordLogin" ← func - -_wup_data 字段(关联元数据 JSON): - {"associationId":8193, - "funcName":"hypasswordLogin", - "group":1, - "id":4097, ← msgType - "session":3251699, - "step":1, - "stillLogin":false, - "traceId":"0b8f098ff64a5bdc-23473-94783833787595172451", - "type":3, - "uid":0, - "userContext":""} - -业务数据: - 5008 ← bizAppid (appId) - PQwemAN9NHkZKoMqVT... ← safedeviceid (设备指纹, base64) - -userAction JSON: - {"curl":"登录页", - "furl":"我的", - "latitude":"-1.0", - "longitude":"-1.0", - "ssid":"", - "user_action":[ - {"id":"24","time":"1787595171950","x":"277","y":"1057"}, - {"id":"11","time":"1787595172444","x":"296","y":"952"}]} - -设备信息字段: - ed0db8334cadd236c00cadf7e11ab5a5 ← hdid (硬件ID) - 13.4.22 ← App 版本 - 1.0.801386 ← 某版本号 - 127.0.0.1 ← 回环地址(占位) - xiaomi ← 厂商 - M2102J2SC ← 设备型号 (thyme) - M2102J2SC,30,11 ← 型号,SDK版本,版本 - android ← 系统 - 1080 / 2120 ← 分辨率 - 7c5387e0539c023c31c4ff0e807e7256117385ee ← 另一个设备ID - -登录凭证: - hy_300023887 ← name (虎牙号示例;手机号登录时直接放手机号) - 772ed992b0e161276f44ec63671e60155c506294 ← password (SHA1) - -结尾: - wupudbrequest_v0 ← WUP 请求类型标记 - AAFAgAxnfOMmAyoDA== ← base64 尾巴(签名/校验) -``` - ---- - -## 三、WUP 请求的三层结构 - -``` -┌──────────────────────────────────────────────────────┐ -│ 第1层: WUP/TAF 外层 │ -│ servant = "huyaudbwebui" │ -│ func = "hypasswordLogin" │ -│ _wup_data = { associationId, funcName, group, id, │ -│ session, step, stillLogin, traceId, │ -│ type, uid, userContext } │ -├──────────────────────────────────────────────────────┤ -│ 第2层: 业务数据 (AppLgnPasswordLoginReq) │ -│ bizAppid = 5008 │ -│ name = "hy_300023887" │ -│ password = SHA1(明文) │ -│ userAction = 风控行为 JSON │ -│ safedeviceid / hdid / 设备信息 │ -├──────────────────────────────────────────────────────┤ -│ 第3层: 网络传输 │ -│ TLS 1.3 → 116.162.209.x:443 (udblgn.huya.com) │ -│ (自定义 BoringSSL / hyssl) │ -└──────────────────────────────────────────────────────┘ -``` - ---- - -## 四、网络层(实证) - -- **密码登录走 TLS 443**(不是之前看到的 80 端口图片 CDN) -- 目标 IP:`116.162.209.83-86`(`udblgn.huya.com`,kunluncan CDN) -- TLS 层:libhycrypto.so = 定制 BoringSSL(hyssl),符号已 strip -- 连接池:App 启动时预建立,登录时复用 -- **HTTP body = 原始 TAF 二进制(1015字节),NOT base64**(base64 是 hook 层返回值的表示形式,发送前已解码) - ---- - -## 四·五、纯 Python 重放验证(✅ 成功) - -**关键结论:纯 Python 可以复刻密码登录请求,服务端返回完整登录响应。** - -``` -POST https://udblgn.huya.com/open/hy/passwordLogin -Content-Type: application/x-wup -Body: 原始 TAF 二进制 (1015字节, 非base64) -``` - -重放金标准请求(`wup_passwordlogin_taf.bin`)后,服务端返回 **1514 字节登录响应**: - -| 字段 | 值 | -|---|---| -| **uid** | `1199666914671` | -| **name** | `hy_300023887` | -| **手机号掩码** | `001******7524` | -| **token** | 346 字节 base64(已存 `evidence/huya_token.txt`) | -| **uc_states** | `1`(成功标记) | - -这证明: -1. ✅ 请求格式 100% 正确(服务端无 BAD_REQUEST) -2. ✅ 设备指纹(safedeviceid/hdid)可复用(重放旧值仍通过) -3. ✅ userAction 可复用(旧轨迹未被风控拒绝) -4. ✅ TLS 指纹不阻断 - -> 注:此验证是「重放」,即复用已抓到的完整请求。要批量登录新账号,还需编码器动态构造新请求(替换 name/password/session/traceId/userAction)。 - ---- - -## 四·六、编码器 + 动态构造验证(✅ 完整成功) - -**编码器已实现,逐字节复现金标准,且能动态构造全新请求。** - -1. **`tools/huya_wup_encoder.py`** — 自检通过:用已知字段构造,输出与金标准逐字节一致(1015 字节) -2. **动态构造测试** — 用新 session(3251799)、新 traceId 构造全新请求(1009 字节),发送后返回: - -| 字段 | 值 | -|---|---| -| **uid** | `1199666914671` | -| **name** | `hy_300023887` | -| **手机号掩码** | `001******7524` | -| **token** | 全新 token(`XAQBRJ3H...`) | - -这证明 **纯 Python 可以完整复刻虎牙密码登录**: -1. ✅ 帧格式(JCE/WUP)逆向定稿 -2. ✅ 编码器逐字节复现 -3. ✅ session/traceId 可自由生成(服务端接受新值) -4. ✅ 设备指纹可复用 -5. ✅ 动态构造请求返回有效登录响应(uid + token) - -### userAction 随机化(✅ 已实测) - -`userAction` 只是请求里的一个普通 JSON 字符串字段,编码器已暴露 `user_action_json` 参数。实测**随机坐标 + 随机时间戳**同样能通过登录(返回 uid + token),服务端对该字段不做严格校验。 - -编码器提供辅助函数: -- `make_user_action(now_ms=None)` — 生成随机 userAction JSON(坐标/时间戳/点击间隔随机) -- `make_trace_id(pid=0)` — 生成 traceId - -> 未实测定论:批量高频登录时,服务端是否按**账号/设备/IP 维度**限流或风控。这需要实测才能确认,与 userAction 无关。 - ---- - -## 五、对「纯 Python 复刻」的启示(已实证更新) - -**TLS 指纹已实测:不阻断。** 普通 Python `urllib` 直连 `udblgn.huya.com` 能正常收到业务响应。 - -要脱离 App 用 Python 发密码登录请求,需要: - -1. ✅ **密码**:`sha1(明文)` —— 已破解,纯 Python 可做 -2. ✅ **name**:`hy_虎牙号` —— 已知 -3. ✅ **servant/func**:`huyaudbwebui` / `hypasswordLogin` —— 已知 -4. ✅ **WUP 帧格式**:标准 JCE RequestPacket,已 100% 定稿(见第二节) -5. ✅ **Content-Type**:`application/x-wup`,body = 原始 TAF 二进制 -6. ✅ **重放验证**:服务端返回 uid + token(纯 Python 可行) -7. ✅ **TAF/JCE 编码器**:`tools/huya_wup_encoder.py`(自检通过,逐字节复现) -8. ✅ **_wup_data 元数据**:`session`/`traceId` 可自由生成(服务端接受新值) -9. ✅ **设备指纹(safedeviceid/hdid)**:可复用旧值(缓存一次即可) -10. ✅ **userAction**:随机坐标/时间戳即可通过(编码器内置 `make_user_action()`) - -> 唯一待实测项:批量高频登录时的账号/设备/IP 维度限流(与 userAction 无关)。 - ---- - -## 六、关联证据文件 - -- `huya-wup-plain.jsonl` — 第一次明文抓包(sendMsg JSON 层,305 事件) -- `huya-wup-full3.jsonl` — 完整 WUP 序列化(含本次密码登录 1356 字节) -- `wup_passwordlogin_taf.bin` — 金标准:1015 字节 TAF 二进制(= HTTP body,编码器自检依赖) -- `wup_passwordlogin_base64.bin` — 金标准:1356 字节 base64(hook 返回值) -- `wup_response_badreq.bin` — 服务端响应帧(383 字节,returnCode=104) -- `tools/huya_wup_encoder.py` — WUP 帧编码器(自检通过,逐字节复现金标准,含 `make_user_action`/`make_trace_id`) -- hook 脚本:`hook_huya_wup_full.js` -- runner:`run_huya_netcapture.py` - -> 含有效登录 token 的响应文件(`wup_response_login.bin`、`wup_response_new_session.bin`、`huya_token.txt`)已加入 `.gitignore`,不提交。 - ---- - -## 七、结论与下一步 - -已完成并验证: -1. ✅ **TLS 指纹**:实测不阻断(Python urllib 正常收到业务响应) -2. ✅ **TAF 编码器**:`tools/huya_wup_encoder.py`,逐字节复现金标准 -3. ✅ **纯 Python 登录**:重放 + 动态构造均返回 uid + token -4. ✅ **userAction 随机化**:随机坐标/时间戳通过 - -待实测(唯一未定论项): -1. **批量高频登录的限流/风控**:需用编码器连续发请求,观察账号/设备/IP 维度是否触发限制(错误码、频率阈值) -2. **集成进 `core/huya/login.py`**:把 APP 密码登录(`/open/hy/passwordLogin`)作为 Web 登录被风控阻断时的替代路径 diff --git a/docs/虎牙/虎牙App密码登录协议-抓包实证.md b/docs/虎牙/虎牙App密码登录协议-抓包实证.md deleted file mode 100644 index c3d1931..0000000 --- a/docs/虎牙/虎牙App密码登录协议-抓包实证.md +++ /dev/null @@ -1,131 +0,0 @@ -# 虎牙 App 密码登录协议 — Frida 抓包实证 - -> 状态:**密码登录完整链路已抓包实证** -> 方法:spawn + libmsaoaidsec 绕过 + hook sendMsg/jbyteArray2str/str2jbyteArray(TLS 加密前的明文层) -> 证据文件:`/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0/evidence/derived/huya-wup-plain.jsonl` -> Java 反编译:`/tmp/hy_jadx5/sources/com/huyaudbunify/` - ---- - -## 一、密码登录请求(msgType = 0x1001 = 4097) - -`MsgLogin.mMsgId = 4097 (0x1001)`,Java 层 `HuyaAuth.login()` → `sendMessage(4097, json)` → native `sendMsg(JNIEnv, jclass, jlong, jbyteArray)`。 - -**请求 JSON**(TLS 加密前明文,抓包实证): -```json -{ - "bizAppids": [], - "isAuthLogin": false, - "lgnExtParam": {}, - "name": "hy_300023887", - "password": "772ed992b0e161276f44ec63671e60155c506294", - "userAction": "{\"curl\":\"登录页\",\"furl\":\"我的\",\"latitude\":\"-1.0\",\"longitude\":\"-1.0\",\"ssid\":\"\",\"user_action\":[{\"id\":\"24\",\"time\":\"1787593636012\",\"x\":\"253\",\"y\":\"1573\"},...]}" -} -``` - -| 字段 | 值/规则 | 说明 | -|---|---|---| -| `name` | `"hy_" + 虎牙号` | 虎牙号 = 纯数字账号 | -| `password` | **标准 SHA1(明文密码) hex,无盐** | 40 字符小写 hex | -| `isAuthLogin` | false | 普通密码登录 | -| `bizAppids` | [] | 目标业务 appId 列表(空=主 app) | -| `lgnExtParam` | {} | 扩展参数 | -| `userAction` | 风控行为 JSON | 点击轨迹、时间戳、坐标、curl/furl、经纬度 | - -**密码哈希**(用户确认 + 抓包实证): -``` -password = sha1(明文密码).hexdigest() -例如 sha1("某密码") = "772ed992b0e161276f44ec63671e60155c506294" -``` -> 无盐、无 HMAC、无 RSA,就是裸 SHA1。 - ---- - -## 二、登录响应(msgType=0x1001 返回) - -```json -{ - "lastLgnTime": 1787593640035, - "token": "9QDzAAEDAAAAb2W4URcBAAABAGNI4jSgAQQANTAwOCgAMDJkZjM5ODc5NzQzMmVhZGVmY2MxMjc2NzExOWFkNWU4MDk5OTM4OQQANTAwOKIAAhKJiMHWd/m1LAKSxxpg1cjDxJEIG+ooj8BGSC5NBXb23Trpcz85MOqpZxhLmSeQ9c2MGzBk0oU4iR2PR0Ak6/t50Ej9sdzHjDLa2wcDiRofNyEZzuU1KcIQqrGDwwwfouEwuagTPI0ZrP5vAFJe8uccW5BKRVoqDDsXc09nIW7HX5Yp+9cT0nr8t/8+/3IdTXhFOZYmDN6gHaQI+ToQivXIAAAAAA==", - "tokenType": 2, - "uid": 1199666914671 -} -``` - -| 字段 | 说明 | -|---|---| -| `token` | Base64,内含 TAF 二进制(`9QDzAAED...` 头),是后续凭据登录/接口调用的 token | -| `tokenType` | 2(密码登录产生的 token 类型) | -| `uid` | 1199666914671(该账号 uid) | -| `lastLgnTime` | 上次登录时间戳(ms) | - ---- - -## 三、完整登录调用链(Java → native → 网络) - -``` -Java: HuyaAuth.login(name, password, isAuthLogin, bizAppids, callback) - └─ MsgLogin (mMsgId=4097=0x1001) - └─ ReqLogin.toString() → JSON(含 SHA1 密码) - └─ HuyaAuthMgr.sendMessage(4097, jsonBytes) - └─ HuyaAuthCore.sendMsg(jlong, byte[]) [native JNI] - └─ sendMsg(JNIEnv, jclass, jlong msgType, jbyteArray) - └─ jbyteArray2str → 明文 JSON - └─ UdbHandlerMgr.getPlatFormMsgHandler() 处理 - └─ TAF/WUP 包装(servant + func) - └─ TLS (libhycrypto 自带 BoringSSL) - └─ 目标: udblgn.huya.com / udb3lgn.huya.com (443) -``` - -**关键符号地址**(libudbauthunify.so): -| 函数 | 地址 | 作用 | -|---|---|---| -| `sendMsg` | 0x26244c | Java→native 消息入口(msgType + jbyteArray) | -| `jbyteArray2str` | 0x262b00 | jbyteArray→std::string(请求明文) | -| `str2jbyteArray` | 0x262be8 | std::string→jbyteArray(响应明文) | -| `hyudb_otp_encrypt` | 0x32fa24 | OTP/dckey 加密(XXTEA+AES) | - ---- - -## 四、msgType 观察(本次抓包 305 事件) - -| msgType | 次数 | 含义 | -|---|---|---| -| `0xb000003` | 92 | getLoginData(读取登录态/token),高频轮询 | -| `0xb000021` | 4 | setDeviceInfo(hdid + safedeviceid) | -| **`0x1001` (4097)** | 1 | **密码登录**(MsgLogin) | -| `0xb000001` | 1 | 登录后 getLoginData | -| `0xb000024` | 1 | 登录后获取数据 | -| `0xb000013` | 1 | getConfig(TAF 二进制,servant=mobileuif) | - ---- - -## 五、与 OTP 加密链的关系 - -密码登录时序(抓包时间线): -``` -1787593639831 sendMsg msgType=0x1001 ← 密码登录 JSON 提交 -1787593639988 crypto-otp b=2 ← 紧接着触发 OTP/dckey 加密(AESkeyMgr b=2) -1787593639996 sendMsg msgType=0xb000001 ← 登录成功后的凭据流程 -``` - -即:**密码登录本身用 SHA1 密码 + WUP 明文 JSON;登录成功后,native 层用 OTP 加密链(XXTEA+AES)生成 dckey 凭据,供后续业务接口(token/cred)使用**。 - -之前破解的 OTP 加密链(`tools/huya_app_crypto.py`)对应的是**登录后的凭据续期/设备绑定**,不是密码本身。 - ---- - -## 六、servant / URL - -- 密码登录 CGI:`/{servantName}/hypasswordLogin` -- URL:`https://udblgn.huya.com/open/hy/passwordLogin`(国内版) -- servant 名运行时传入,登录类走 `huyaudb`/`huyaudbwebui` -- 设备信息:`getCgi()` → `/{servantName}/setDeviceInfo`(hdid + safedeviceid) - ---- - -## 七、待确认项(剩余,影响较小) - -1. **userAction 是否必填/可复用**:风控行为采集(点击轨迹),批量登录时能否固定/复用同一份,还是每次都要真实模拟。 -2. **登录接口是否校验 OTP dckey**:密码登录请求本身是否需要先拿到 dckey(本次抓包 OTP 在密码登录之后触发,说明密码登录可能不依赖 dckey)。 -3. **token 的 TAF 结构解析**:响应 token 的 Base64 内是 TAF 二进制(`9QDzAAED...`),需解析出 uid/appid/时间戳等字段。 diff --git a/docs/虎牙/虎牙App登录-APK静态分析.md b/docs/虎牙/虎牙App登录-APK静态分析.md deleted file mode 100644 index c8738c6..0000000 --- a/docs/虎牙/虎牙App登录-APK静态分析.md +++ /dev/null @@ -1,194 +0,0 @@ -# 虎牙 App 登录 — APK 静态逆向分析(libudbauthunify.so) - -> 目标:`apks/huya-13.4.22-115315-live.apk`(158MB,versionCode=115315) -> 分析对象:`lib/arm64-v8a/libudbauthunify.so`(4.7MB,UDB 认证统一库) -> 方法:`nm -D` 导出符号 + `objdump` 反汇编 + `strings` 字符串提取(纯静态,无需 hook) -> 结论:**App 登录 = WUP 长连接 + XXTEA/AES 双层加密**,全部加密函数与 servant 已定位。 - ---- - -## 一、关键结论(一句话) - -虎牙 App 登录的**设备风控加密**由 `libudbauthunify.so` 实现,算法链是: - -``` -明文 → cred_pack 打包 → XXTEA 加密 → cred_pack 拼装 → MD5 派生 key → AES-128-ECB 加密 → 密文 -``` - -`dckey` = **Login Credential(登录凭据)**,通过 `MsgGetCred` / `MsgRequestLgnCred` 获取,加密后经 `udbdf.huya.com/dckey/check` 上传。 - ---- - -## 二、关键 native 库定位 - -| so 库 | 大小 | 作用 | -|---|---|---| -| **`libudbauthunify.so`** | 4.7MB | **UDB 认证统一库(核心)**,含全部登录/加密逻辑 | -| `libhycrypto.so` | 1.9MB | BoringSSL(AES/RSA 标准实现) | -| `libhydeviceid.so` | 4.1MB | 设备 ID 生成 | -| `libtscsdk.so` | 1.5MB | 数美(TrustKernel)设备指纹 SDK(对应 zztfly 请求) | -| `libtea.so` | 409KB | XXTEA 算法 | -| `libmtpencrypt.so` | 809KB | MTP 加密 | - ---- - -## 三、加密体系(已 100% 破解,来自反汇编) - -### 3.1 加密函数清单 - -| 函数 | 地址 | 说明 | -|---|---|---| -| `hyudb_crypt_util::xxtea_encrypt` | 0x32e83c | **XXTEA 加密**(第一层) | -| `hyudb_crypt_util::xxtea_decrypt` | 0x32ece8 | XXTEA 解密 | -| `hyudb_crypt_util::md5_char16` | 0x32e71c | **MD5 派生 16 字节 key** | -| `hyudb_crypt_util::md5_char32` | 0x32e7ac | MD5 派生 32 字节 | -| `UdbAESUtil::encrypt` | 0x250038 | **AES-128-ECB 加密**(第二层) | -| `UdbAESUtil::decrypt` | 0x2501a4 | AES 解密 | -| `AESkeyMgr::getkey` | 0x26871c | **AES key 缓存管理**(map>) | -| `hyudb_packet_util::cred_pack` | 0x45xxxx | **cred(dckey) 二进制打包** | -| `hyudb_packet_util::cred_unpack` | — | cred 解包 | -| `encode_xxtea` / `decode_xxtea` | 0x330058/0x3306cc | XXTEA 编解码封装 | - -### 3.2 加密模式确认(反汇编证据) - -**AES 是 ECB 模式 + ZeroPadding**,证据来自 `UdbAESUtil::encrypt`(0x250038)反汇编: - -```asm -; 计算长度 & 0xf,非 16 倍数则 push_back(0) 补齐(ZeroPadding) -and w11, w11, #0xf -... -bl push_back(0) ; 补零 -; 每 16 字节独立调用 _encrypt(单块),无 IV 参与 → ECB 模式 -loop: - bl UdbAESUtil::_encrypt(ptr16, out) ; 逐块 AES - add x24, x24, #0x10 ; +16 -``` - -### 3.3 完整加密调用链(`hyudb_otp_encrypt` 0x32fa24 还原) - -``` -hyudb_otp_encrypt(x0=key, w1=2, w2=len, x3..x5=字段, w6=4, x7=buf, out) - ├─ cred_pack(sp+0xb0, x7) # 打包第一个字段 - ├─ xxtea_encrypt(sp+0xc8, sp+0xe0, x24) # XXTEA,key = x0 参数 - ├─ cred_pack(sp+0x98, ) # 打包 header - ├─ cred_pack(sp+0x98, sp+0xc8) # 打包 XXTEA 密文 - ├─ cred_pack(sp+0x98, x23) # 打包第5参数字段 - ├─ AESkeyMgr::getkey(i, i) # 取 AES key 字符串 - ├─ string::insert(...) # key 拼接 - ├─ md5_char16(key_str) # MD5 派生 16 字节 key - ├─ UdbAESUtil::C1(md5key) # 用 MD5 结果构造 AES - └─ UdbAESUtil::encrypt(packed, md5key) # AES-128-ECB 加密 -``` - -调用方:`BusinessCfg::getOtp(uid, otp, ...)`(0x268bbc~0x268ca0)→ `hyudb_otp_encrypt(...)`。 - ---- - -## 四、硬编码 key 候选(rodata 段) - -| 字符串 | 长度 | 备注 | -|---|---|---| -| **`HuyaUdb1928374650qwertyuiop`** | 27B | **最强候选**("HuyaUdb" + 数字 + 字母键盘序) | -| `275d0ff676c0d65114acdefbd2ad87a3` | 16B | 32 hex,正好 AES-128 key 长度 | -| `0123456789ABCDEF` / `0123456789abcdef` | 16B | 常见默认 key | - -> XXTEA key 与 AES key 源需 hook 最终确认(hook `xxtea_encrypt` 第3参、`md5_char16` 输入)。 - ---- - -## 五、dckey = Login Credential - -符号证据链: - -``` -MsgGetCred / MsgRequestLgnCred / MsgResponseLgnCred -HandlerGetCred / HandlerRequestLoginCred / HandlerResponseLoginCred -AutoCredLogin(自动凭据登录) -BusinessCfg::getCred / getCredEx / setSafeDeviceId -AUTO_CRED_LOGIN_TIMER -``` - -即:**dckey 就是「登录凭据(credential)」**,App 启动后用设备指纹换取,登录时作为风控凭证提交。 - ---- - -## 六、完整登录 servant 清单(TAF/WUP 结构名) - -从 so 的 rodata 提取全部 `wup.*` 结构名: - -### 登录类(核心) -| servant/结构 | 用途 | -|---|---| -| **`AppLgnPasswordLoginReq/Resp`** | **App 密码登录** | -| **`AppLgnOtpLoginReq/Resp`** | **App 一键(OTP)登录** | -| **`AppLgnCredentialLoginReq/Resp`** | **dckey 凭据登录** | -| **`AppLgnSmscodeLoginReq/Resp`** | 短信验证码登录 | -| `AppLgnMobileSendSmsReq/Resp` | 手机号发短信 | -| `AppLgnSessionSendSmsReq/Resp` | 会话发短信 | -| `AppLgnSecondAuthLoginReq/Resp` | 二次认证 | -| `AppLgnAntiViolentLoginReq/Resp` | 反暴力登录 | -| `AppLgnRefreshPicReq/Resp` | 刷新验证码图 | -| `AppLgnThirdLoginReq/Resp` | 第三方登录 | - -### 密码/注册类 -``` -AppPasswordLoginReq/Resp # 密码登录(另一组) -AppCredentialLoginReq # 凭据登录 -AppAnonymousLoginReq/Resp # 匿名登录 -AppMobileRegisterReq # 手机注册 -AppRegSendSms / AppRegCheckMobile / AppRegCheckUser -AppRegSmscodeRegister / AppRegSmscodeModifyPassword / AppRegSmscodeResetPassword -AppRegTokenRegister / AppRegTokenModifyPassword / AppRegTokenResetPassword -AppRegUidVerifySmscode -AppBindLoginMobile / AppBindNewMobile(绑定手机) -AppBypassRouteReq/Resp # 旁路路由 -``` - -### servant 名(TAF 服务名) -``` -huyaudb # UDB 主服务 -huyaudbwebui # 抓包里 wsapi.huya.com 的 servant -huyaudbnimoui # NIM UI 服务 -passport # 护照服务 -``` - ---- - -## 七、其他关键 token/字段 - -``` -violentToken / sessionToken # 反暴力 token / 会话 token -enpack_header / depack_header # header 打包/解包(含 NEWKEY) -key_version[%d] / key_id[%d] # key 版本/ID 管理 -lgnExtParam / lgnProtect # 登录扩展参数 / 登录保护 -loginAppId / loginAppName / loginDeviceName / loginStrategy / loginType -``` - -`enpack_header` + `NEWKEY` 解释了抓包里 dckey body 的 **100 字节固定头**:那是打包的 header(key 版本、key_id、固定字段),加密只作用于变长数据体。 - ---- - -## 八、给 hook 的精确落点(静态分析直接给出) - -| 目标函数 | hook 什么 | 得到什么 | -|---|---|---| -| `hyudb_crypt_util::xxtea_encrypt` | 第3参(XXTEA key) + 第2参(明文) + 返回(密文) | **XXTEA key + 明文结构** | -| `hyudb_crypt_util::md5_char16` | 输入 string + 输出 16B | **AES key 源**(MD5 前明文) | -| `AESkeyMgr::getkey` | 返回值 string | **AES key 字符串** | -| `UdbAESUtil::encrypt` | 入参(明文) + 返回(密文) | 验证 AES 模式/填充 | -| `hyudb_packet_util::cred_pack` | 各字段 | **dckey 二进制结构** | -| `BusinessCfg::getOtp` | 入参 | OTP 生成逻辑 | -| `MsgRequestLgnCred` 序列化前 | WUP 结构 | **登录凭据请求明文** | -| OkHttp `CertificatePinner.check` | — | SSL unpin,解 cdn.wup 长连接 | - ---- - -## 九、抓包 ↔ so 对应关系(验证闭环) - -| 抓包现象 | so 证据 | -|---|---| -| dckey body 前 100B 固定 | `enpack_header`/`cred_pack` 打包的固定 header | -| dckey body 后段变长 | XXTEA+AES 加密的数据体 | -| dfpReport t2 魔数 `571882cf664bb39401ee` | cred_pack 的固定标识 | -| selectOperator.t5 / dfpReport.t2 同前缀 13B | 同一固定 header(非 RSA,是打包结构头) | -| 登录走 cdn.wup.huya.com 抓不到 | App 登录 servant 走 WUP 长连接 + SSL Pinning | diff --git a/docs/虎牙/虎牙App登录逆向笔记.md b/docs/虎牙/虎牙App登录逆向笔记.md deleted file mode 100644 index a51d6bc..0000000 --- a/docs/虎牙/虎牙App登录逆向笔记.md +++ /dev/null @@ -1,212 +0,0 @@ -# 虎牙 App 登录逆向笔记 - -> 抓包来源:`/Users/yml/Desktop/抓包/hy/8.23/`(登录-1.har / 登录-5.har) -> 分析工具:`tools/taf_decode.py`(TAF/JCE 纯字节流解码)、`tools/hy_har_parse.py`(HAR 批量解析) -> 状态:App 登录**前置风控链路**已完整解明;**登录提交(最后一跳)走 cdn.wup.huya.com 长连接 + SSL Pinning,尚未解出明文**。 - ---- - -## 一、结论速览 - -1. **App 登录 ≠ Web 登录**,是两套完全不同的协议栈: - - **Web 登录(旧,appId=5002)**:`udblgn.huya.com/web/v2/passwordLogin` + `udbrtt.huya.com/auth/pt/*` 滑块 + `df.huya.com/web/df/token|collect`。已在 `core/huya/login.py`、`e语言/huya/登录/虎牙操作.txt` 实现。 - - **App 登录(新,appId=5008)**:`wsapi.huya.com`(servant=`huyaudbwebui`)走 `getDfpConfig → selectOperator → dfpReport` 设备指纹,`udbdf.huya.com/dckey/check` 拿 dckey,运营商取号,最后登录提交进 `cdn.wup.huya.com` 长连接。 - -2. **Web 登录"风控加强"的本质**:Web 纯脚本无法伪造 App 侧的**设备态**(dckey + dfp 指纹 + appId=5008),被风控拦下。登录接口本身没变,是**前置设备校验变严**。 - -3. **抓包抓不到登录的原因**(两层锁): - - TLS 层:`cdn.wup.huya.com` 与裸 IP(`211.91.243.102` 等)有 SSL Pinning,Reqable 中间人证书被拒,只留 CONNECT 隧道。 - - 应用层:`statwup.huya.com` 的部分 body 是 `877de5c4...` 纯二进制(应用层加密),即使破 TLS 也还要再解一层。 - ---- - -## 二、App 登录完整链路(已解明的普通 HTTP 部分) - -按时间顺序(登录-5.har): - -``` -① 数美安全 SDK 初始化(App 启动) - cfgc.zztfly.com/v6/gcf · cdn-api-auth.zztfly.com/api/initSecCdn · upc.flysleep.cn/v6/gcl -② 设备指纹配置 - wsapi.huya.com huyaudbwebui.getDfpConfig ← 只传 appId=5008,返回 80 字节加密配置 -③ 选运营商(一键登录前置) - wsapi.huya.com huyaudbwebui.selectOperator ← 传设备信息 + RSA 加密段,返回运营商索引 -④ 设备指纹上报 ×4(循环) - wsapi.huya.com huyaudbwebui.dfpReport ← 上报加密设备指纹,返回 32 位指纹 + action - udbdf.huya.com/dckey/check ← 加密 body,返回 dckey -⑤ 运营商取号(联通) - auth.wosms.cn/dro/netm/v1.0/qc - nisportal.10010.com:9001/api?appid=2074469246116 -⑥ 登录提交(❌ 未抓取) - → 进入 cdn.wup.huya.com / 裸 IP 长连接(被 SSL Pinning 挡住) -``` - -`wup.huya.com` 上看到的 `mobileui.getDynamicConfigHotFixUpdateInfo` / `reportMobileUpdateResult` 只是 **App 热更配置**,与登录无关。`statwup.huya.com` 上 `metric.reportDetailV2` 是埋点。 - ---- - -## 三、各接口字段级结构 - -### 1. getDfpConfig(wsapi,servant=huyaudbwebui) - -- **请求 tReq**:`{"t0": {"t0": "5008"}}` —— 只传 appId。 -- **响应 tRsp**:`{"t0": {"t0": {"5008": ""}}}` —— 按 appId 返回配置。 -- **base64 配置解码后 = 80 字节**(64+16 结构,疑似密钥材料): - -``` -hex: 34b946b716f1481d8584980b79cf756b962e79c999d752016904c1fc07d689 - 694a148ef872206826745498088e61be3f062f7d8d1a0276e29c0048062ac8e2 - b58fe0d1ee376c412472d8b9124d0bbf7d -``` - -### 2. selectOperator(wsapi,servant=huyaudbwebui) - -请求 key 是 `_wup_data`(注意不是 tReq),三层嵌套结构: - -``` -_wup_data.t0.t0 = { # 会话上下文 - t0: 0, - t1: "1.0", # 协议版本 - t2: "hyudb_1787583730402", # 会话 id = "hyudb_" + 毫秒时间戳 - t3: "5008", # appId - t4: 0, - t5: "PQwemAN9NHkZKoMqV...", # RSA 公钥加密段(与 dfpReport 响应 t2 同前缀) - t6:"", t7:"", t8:"", t9:"" -} -_wup_data.t1 = { # SDK 信息 - t0:"", t1:"13.4.22"(App版本), t2:"2.0.8"(SDK版本), t3:"",t4:"",t5:"",t6:"" -} -_wup_data.t2 = { # 设备信息 - t0: 1, - t1: "M2102J2SC", # 机型 - t2: "02df398797432eadefcc12767119ad5e80999389", # 40 hex = SHA1 设备指纹 - t3: "android", - t4: "M2102J2SC,30,11", # 机型,Android版本,SDK版本 - t6:"", t7:"" -} -_wup_data.t3 = [0, 1, 3] # 运营商列表 -``` - -- **响应**:`_wup_data.t0.t1 = 1`(选中运营商索引 1)。 - -### 3. dfpReport(wsapi,servant=huyaudbwebui) - -- **请求 tReq**: - -``` -{t0: {t0:"", t1:"android", t2:"<加密体>", t3:"", t4:12}} -``` - - - `t2` 加密体结构:**10 字节固定魔数 + 变长加密数据** - - 魔数(跨 6 次请求完全一致):`571882cf664bb39401ee` - - 加密数据长度 3300~4100 字节(每次递增,说明是**累积式采集上报**,每次追加新采集数据) - -- **响应 tRsp**: - -``` -{t0: { - t0: "10", - t1: "865a4924a40897ac1fcfe6b4c2cbb045", # 32 hex = 稳定设备指纹(4 次响应一致) - t2: "PQwemAN9NHkZKoMqV...", # base64 = RSA 公钥加密的 action(每次变) - t3: 43200, # 过期时间(秒)=12h - t4: "action", - t5: "7c5387e0539c023c31c4ff0e807e7256117385ee" # 16 hex = 稳定 session hash(4 次一致) -}} -``` - -### 4. dckey/check(udbdf.huya.com,okhttp/3.14.9,application/octet-stream) - -- **请求 body 结构**:**100 字节固定头 + 变长加密体**(总长 543B 或 547B,两种长度差 4 字节) - - 100 字节固定头(跨 5 个样本、两个会话完全一致): - -``` -32f1990eb8f46c503c3b5db81be58ae84dd3e961b991dd9e9c593aba8c78881c -f1281c555198710a1009f1ec897c46be8b26d81e8eab98f27242de3444315cab8 -d617eae189d597e296099f0e5bc817a7554456c1af99515254ab7665a2a3f861a -7d941b -``` - - - 变长加密体(543/547B 减 100B = 443/447B)每次变化。 - - 同一长度下,末尾 32 字节可复现(同设备+同 session 重复调用时)。 - -- **响应**:`{"dckey":"AAAAAMC1eP4iV43WYoI57ZOu0...","status":"success"}` - - dckey 固定前缀 `AAAAAMC1eP4iV43WYoI57ZOu0`(base64 前 16 字节),后续每次变。 - -### 5. 运营商取号(联通一键登录) - -- `POST auth.wosms.cn/dro/netm/v1.0/qc` -- `POST nisportal.10010.com:9001/api?appid=2074469246116&ret_url=` - ---- - -## 四、加密体分析汇总 - -| 加密体 | 位置 | 结构 | 猜测算法 | -|---|---|---|---| -| dfpReport t2 | wsapi | 10B 魔数 `571882cf664bb39401ee` + 变长加密 | 自定义格式,魔数疑似协议版本/类型标识 | -| dckey/check body | udbdf | 100B 固定头 + 443/447B 变长 | 固定头疑似 RSA/ECDH 公钥或设备态加密快照 | -| getDfpConfig 配置 | wsapi 响应 | 80B = 64B+16B | 疑似 dfpReport 加密密钥 + IV/nonce | -| selectOperator t5 / dfpReport t2(action) | wsapi | base64,同前缀 `PQwemAN9NHkZKoMqV` | **RSA 公钥加密**(同公钥 → 密文前缀相同) | -| statwup #7 | statwup | 400B 纯二进制 | 应用层加密(session-key) | - -**关键判断**:`selectOperator.t5` 和 `dfpReport 响应 t2` 前缀相同 → **同一个 RSA 公钥**加密,明文大概率是「设备信息 + 会话 key」的协商包。拿到这个公钥(APK 内嵌)就能解密。 - ---- - -## 五、稳定字段清单(可直接复用的常量) - -| 字段 | 值 | 用途 | -|---|---|---| -| appId(App 场景) | `5008` | 替代 web 的 5002 | -| App 版本 | `13.4.22` | versionCode=115315 | -| SDK 版本 | `2.0.8` | selectOperator.t1.t2 | -| dfpReport 魔数 | `571882cf664bb39401ee` | t2 加密体前缀 | -| dckey 固定头 | 100B(见上) | dckey/check body 前缀 | -| dckey 响应前缀 | `AAAAAMC1eP4iV43WYoI57ZOu0` | dckey 前 16B | -| 设备指纹(selectOperator) | `02df398797432eadefcc12767119ad5e80999389` | 40 hex = SHA1 | -| 设备指纹(dfpReport) | `865a4924a40897ac1fcfe6b4c2cbb045` | 32 hex,服务端下发 | -| session hash(dfpReport) | `7c5387e0539c023c31c4ff0e807e7256117385ee` | t5 | -| 会话 id 格式 | `hyudb_<毫秒时间戳>` | selectOperator.t2 | -| appSign | `60576904`(md5 取前 8,见 login.py 注释) | App 场景签名 | - ---- - -## 六、与现有 Web 登录(appId=5002)的差异 - -| 维度 | Web(5002) | App(5008) | -|---|---|---| -| 登录入口 | udblgn.huya.com/web/v2/passwordLogin | cdn.wup.huya.com 长连接 | -| 设备指纹 | df.huya.com/web/df/token+collect | wsapi getDfpConfig+dfpReport + dckey/check | -| 验证码 | udbrtt pt/get3+verify3(滑块/点选) | 运营商取号 + (未解明) | -| 传输 | JSON over HTTP | TAF/WUP + 部分应用层加密 | - ---- - -## 七、待破解项(按优先级) - -1. **[最硬] dckey/check 的加密算法**:100B 固定头 + 变长体。需 hook APK 里 dckey 生成方法。 -2. **[核心] dfpReport t2 加密体**:魔数 + 累积式采集数据。需 hook dfp 采集模块。 -3. **[核心] 登录提交的 WUP servant/func + 参数**:被 SSL Pinning 挡住,需 Frida unpin 或 hook 登录方法。 -4. **[可解] RSA 公钥**:APK 内嵌,解出 selectOperator.t5 / dfpReport action 明文。 -5. **[可解] getDfpConfig 80B 配置**:确定是否 dfpReport 加密密钥。 - ---- - -## 八、Frida hook 点建议(供 hook 阶段参考) - -1. **dckey 生成**:hook `udbdf` 请求发出前,dump 加密前明文。搜 `com/duowan/kiwi` 下 dckey / dcKey 相关类。 -2. **dfp 采集**:hook dfpReport 的 t2 字节流写入点(`writeTo`/`toByteArray`)。 -3. **登录提交**:hook `com.duowan.kiwi.base.login.UdbNetHelper`(appSign 已逆向自此类),以及登录 servant 的 `writeStruct("tReq", ...)` 前的参数。 -4. **SSL unpin**:hook OkHttp `CertificatePinner.check` + conscrypt `TrustManagerImpl.verifyChain`,让 Reqable 能解 cdn.wup.huya.com。 - ---- - -## 九、工具使用 - -```bash -# 解码某个 HAR 里的所有 WUP(TAF) 请求/响应字段 -.venv/bin/python tools/hy_har_parse.py "/Users/yml/Desktop/抓包/hy/8.23/登录-5.har" - -# 底层 TAF 解码器(可单独 import) -from tools.taf_decode import Taf, decode_struct_bytes -``` diff --git a/docs/虎牙/虎牙hyCred签发-侦察笔记.md b/docs/虎牙/虎牙hyCred签发-侦察笔记.md deleted file mode 100644 index 5213775..0000000 --- a/docs/虎牙/虎牙hyCred签发-侦察笔记.md +++ /dev/null @@ -1,589 +0,0 @@ - - -# hyCred 签发协议 — 侦察笔记(2026-08-25 傍晚) - -> 目标:任意账号取得有效 hyCred(114B),配合 tools/cert_forge 实现全自动多账号。 -> 当前状态:**铸证已闭环**(见《虎牙证书算法闭环-差分实证.md》),本文记录签发链侦察结果。 - -## 已确认事实 - -1. **cred 是长寿命凭证**:早上抓的 hyCred 在下午(且中间用户重新登录过一次后) - 依然能通过 bind 铸证(实验C)。=> 每账号只需采集一次,非会话级短时效。 -2. **cred 每次登录轮换值但旧的仍有效**:今晨 `0a80a762...` ≠ 午后 `0a50ec12...`,两者均可用。 -3. **密码登录响应不含 cred**:`evidence/wup_response_login.bin`(1514B) 无 114B/152b64 结构。 -4. **cred 存于本地登录态文件**:`BusinessCfg::saveLoginData` (@0x265fb0 / 0x265a6c) - -> JSON(含"cred"字段) -> UdbAESUtil AES 加密 -> `UdbFileUtil::writeFileEx` 写盘。 - `BusinessCfg::getCred(uid)` @0x265524 纯本地读(UdbLock+日志),无网络。 -5. **签发走 cdn.wup.huya.com 长连接隧道**:HAR 里只有 CONNECT,载荷不可见; - 相关消息族符号齐全: - - `MsgRequestLgnCred` / `MsgResponseLgnCred`(请求/响应) - - `HandlerRequestLoginCred`(@0x3a1820) / `HandlerResponseLoginCred`(@0x3a182c) - - 已知方法: `handlerCredLoginOverTimeMsg` @0x3a00d0 - - `UdbObjCreator_Msg{Request,Response}LgnCred`、`AppLgnCredentialLoginReq/Resp`(wup JCE) - - `MsgGetCred`/`HandlerGetCred`、Anony 变体、`MsgResponseUpdateCred` -6. **dckey/check ≠ hyCred**:udbdf.huya.com/dckey/check 返回设备钥 dckey - (固定前缀 AAAAAMC1eP4iV43WYoI57ZOu0),与账号 cred 不同物。 - -## 本次抓取(evidence/cred_issue_trace.json) - -用户重登时捕获 saveLoginData bean 内存头: -`..."hy_300023887"...len前缀"1199666914671"..."4C5fozan"...` -(bean 为 BusBeansLoginData 结构体,cred 字段在更深偏移,待下次 dump 全量) - -vtable 探测结论:slot2/3 是析构/通用槽(打中 MsgLoop::doMSG/sendMessage), -真正 onHandler 未命中 —— 下次应挂 `ResponseMsgHandler` 派生 onHandler 或 -直接挂 `hyudb_packet_util::cred_pack/cred_unpack`(@0x333170) 收发两端。 - -## 三条候选路线(按性价比) - -| 路线 | 做法 | 成本 | 备注 | -|---|---|---|---| -| A 混合采集(立即可用) | 手机登目标号一次,Frida 自动抓 cred 入库;此后该号永久全自动铸证 | 低 | 已验证可行,脚本齐备 | -| B wsapi 短连接复刻 | 若 MsgRequestLgnCred 可经 huyaudbwebui servant 走 wsapi.huya.com HTTPS | 中 | 需 msgId+JCE 结构+OTP加密链(已有) 组包试验 | -| C 长连接完整复刻 | 实现 cdn.wup.huya.com WSP 握手/心跳/加密隧道 | 高 | 最后手段 | - -## 下次继续的抓手 - -1. 重跑 hook_cred_issue.py 增强版:saveLD_bean dump 640B 定位 cred 字段偏移 + - backtrace 10 帧(找推送来源 handler)+ 挂 `cred_unpack`@0x333170 抓网络侧明文。 -2. 静态:`MsgRequestLgnCred::getClassName`@0x2a37d0 / 各消息 getClassName 取类名串, - 在 rodata 找 类名→msgId 表(参考 MsgGetH5InfoEx=184549387 的命名规律)。 -3. 若走 B:用 tools/huya_wup_encoder.py 的信封 + OTP 加密链组 RequestLgnCred 发 wsapi 试探。 - -## ★ 2026-08-25 晚 · 关键突破:cred 来源定位 - -增强 hook(bean 768B 全量 + backtrace)在用户重登时抓到决定性调用链: - -``` -BusinessCfg::saveLoginData ← _Z13saveLoginDataiR17BusBeansLoginData - ← HandlerResponseLoginPassport ★ - ← udbjce::JceInputStream (wup/JCE 反序列化) -``` - -- **cred 随"护照登录"响应一次性下发**(BusBeansLoginData 内),不是单独消息。 -- 手机 App 的登录主通道是长连接上的 LoginPassport;我们的 HTTPS - `/open/hy/passwordLogin` 是简化通道,响应(1514B)只有 uid/name/mask/token, - 无 cred(已二次扫描确认:无 `0a 80` 高熵段、无 114B 结构)。 -- bean 内存可见字段样例: "hy_300023887"、uid串、"01******7524"掩码、 - user_action JSON 片段 —— 与 BusBeansLoginData 字段对应。 -- 证据: /tmp/credissue2.log + evidence/cred_issue_trace.json(第二次覆盖保存) - -### 路线修正 - -- 路线 B 修正为「补全 HTTPS passwordLogin 的请求字段」:App 端若也存在 - HTTPS 登录路径,其请求参数比我们的最小实现多 ⇒ 响应可能就含 cred。 - 下一步:静态对比 HandlerRequestLoginPassport 组包 vs huya_wup_encoder 差异; - 或 mitmproxy 抓一次 App 真实密码登录流量对照。 -- 若确认必须长连接 → 路线 C(WSP 客户端)或路线 A(混合采集)兜底。 - -## ★ B1 静态第一铲(rodata 字符串集群 @0x1c36db) - -登录态 JSON 字段名: appId / sdkVer / cred / timestamp / biztoken_vec -新发现 24 字符密钥: ViDjMAKmkzvYEUFmrMdk9YPd (@0x1c3704, 疑似登录态文件AES钥或密钥表第4条) -消息类名串: MsgRequestLgnCred(@0x1c385a) / hyAppOtpLogin / LgnOtpLogin -下一步: 反汇编 BusBeansRequestLoginPassport::toString(JsonUtil)@0x2a6118 提取请求全字段, -对照 huya_wup_encoder 补齐 → 试发 HTTPS 端点看响应是否带 cred。 - -## ★ BusBeansLoginData 完整字段表(B1 第二铲) - -toString 委托链: LoginData(0x26ca64) -> BusBeansAppLoginData::toString(0x26d518) -AppLoginData 字段(JCE 序列): - regOrigin userIdState uid hyid passport cred cookie mobileMask - emailMask userId token timestamp subUid hyOpenId isHuya status - biztoken_vec thirdParams -=> 护照登录响应一次含 cred/cookie(biztoken)/token; 铸证所需全在响应内。 -剩余: RequestLoginPassport 六字段的 JCE tag 号 + 传输通道(wsapi or 长连接)。 - -## ★ Round2: 载荷格式=JSON(非二进制JCE)! - -UdbRequestMsg::unPackageMsg(@0x2a57cc) = - UdbContext::unPackageContext(str) -> JsonUtil::loadFromString -=> 消息上下文为JSON字符串, 字段名即线上的键: - 请求: {name,password,userAction,isAuthLogin,lgnExtParam,bizAppids} - 响应bean: {regOrigin,...,cred,cookie,token,...,biztoken_vec} -剩余: msgId定位 + servant/endpoint映射(huyaudbwebui?) + 密码格式(sha1?) - -## Round3: msgId 线索 - -类名串引用点在 UdbObjCreator_Msg*::C2 注册构造器内: -- "MsgRequestLoginPassport" 邻近 movz #0x1001 -- "MsgResponseLoginPassport" 邻近 movz #0x2001 -=> 疑似 (方向类型<<12)|序号 或注册type常量; 完整id需反汇编 - UdbObjCreator_MsgInit::C2 的 registerMsg(id,className,creator) 调用序列。 -已知参照: MsgGetH5InfoEx=184549387(0x0B00000B)。 - -## Round4: 注册常量=0x20 - -Request/Response LoginPassport 两个 Creator::C2 均以 movz w0,#0x20 构造 -(UdbClassFactory), 地址 0x289b28/0x289c2c。0x20 与证书 key_idx 字节(0x20)同值! -=> 猜测: 0x20 是该消息族的类型号, 线上完整 msgId 需结合工厂注册表; - 或 key_idx 本就是消息族类型号的镜像(服务端按它选处理分支/密钥)。 -捷径备选: 设备hook UdbMsgHandler::sendMessage 抓真实线上id(需用户重登一次)。 - -## ★ Round5: msgId 实锤 - -@0x289b48: mov w1,#0x1001; x2="MsgRequestLoginPassport"; - bl UdbMsgFactory::RegisterMsg(uint, char const*) -=> MsgRequestLoginPassport id=0x1001(4097), Response=0x2001。 - (前轮0x20为operator new大小, 红鲱鱼) -剩余: 传输端点+servant、password编码、活体发送测试。 - -## Round6: 传输通道确认 - -servant 字符串 huyaudbwebui 与 Handler 族类名同池出现在 libudbauthunify.so -(另见 wupudbrequest_v0 / KFWAH2cbkkNgFAgQPzriFPcU 等邻串)。 -=> MsgRequestLoginPassport(0x1001) 经 huyaudbwebui servant 走 WUP; - dfp 的 func 名不在本库(getDfpConfig 计数=0), func 名或由Java层/运行时拼接。 -下一步(需用户重登一次): hook UdbMsgHandler::sendMessage 抓真实线上包 -(id/servant/func/JSON载荷), 一次拿全所有待定参数。 - -## Round7: sendMessage(0x1001) 实锤触发! - -hook UdbMsgHandler::sendMessage 双重载: 登录时 id=0x1001 出现2次 -(另有 0x0B000003/0x21/0x25 长连接族 + 0x102b/0x1072 等)。 -wire id 双体系: 0x0Bxxxxxx=业务推送族; udb请求用裸id(0x1001...)。 -payload读取bug: 参数是char*非std::string, 下轮改readCString重抓即得完整JSON包。 -证据: evidence/cred_issue_trace.json (sendmsg:260 domsg:17) - -## ★★ Round8: LoginPassport 完整请求包到手! - -sendMessage(0x1001, json) 载荷(char*直读): -{"bizAppids":[],"isAuthLogin":false,"lgnExtParam":{}, - "name":"hy_300023887","password":"","userAction":"{...}"} -- password=SHA1 hex(40) 与 tools/huya_password_sha1 一致 -- 六字段与静态预测完全吻合; 空对象/空数组原样传 -剩余: 响应侧(doMSG a2偏移待修, cred响应未读到) + 信封包装层。 -证据: evidence/cred_issue_trace.json - -## Round9: bean为指针对象, 改挂getCred出口 - -saveLoginData bean(1536B dump)=C++对象, cred在堆上经指针引用, 非内联。 -最直接抓点: BusinessCfg::getCred(ulong,string&out)@0x265524 - -> onLeave 读 out(x2) std::string 即152字符hyCred原文。 -scripts/hook_beandump.py 已建(时序模板可复用), 下轮改钩子地址重跑一次登录即得。 -另: 响应JSON键已确认含"cred"键(0x1c36e8)。 - -## ★ 终局验证(全链路) - -活体采集(桥调getCred,零重登) -> hyCred(CiAXHUiF.../114B) --> cert_forge 铸新证书 -> bindQrLoginUser stage=2 --> tryQrLogin uid=1199666914671 + biztoken344B ✅ -多账号流程定型: 每号设备登录一次+桥采cred入库, 此后永久全自动。 -纯Python长连接签发客户端(0x1001+JSON)留作后续可选优化, 参数已全部备齐。 - -## ★ 纯Python路线启动(用户需求: 密码进cookie出, 零手动) - -wsapi信封实测(getDfpConfig 67B): [len][10 03][,LV]["huyaudbwebui"]["getDfpConfig"][}][params] -rodata: wupudbrequest_v0 与 huyaudbwebui 同池 => 任意udb消息HTTPS透传func名。 -实施: tools/huya_passport_client.py - step1 复刻信封(huya_wup_encoder同构) - step2 内层帧: id=0x1001+JSON, 盲发试错读错误码定位格式 - step3 响应解JSON取cred -> cert_forge -> bind -> cookie - -## 纯Python路线·传输层修正 - -登录6.har实证: 护照登录走 CONNECT https://wup.huya.com TLS隧道(代理不可见), -wsapi/wupudbrequest_v0 仅用于dfp等短连流程。 -内层帧突破口: createWupPackage(wup::UniPacket)@0x274ea4 - - 函数体内引用 wupudbrequest_v0 与 huyaudbwebui -下步: 反汇编该函数+其调用链(经PLT), 提取长连接帧格式(msgId+JSON的JCE摆放), -然后 Python直连 wup.huya.com:443 组 TAF UniPacket 发送取cred。 - -## 新目标R1: createWupPackage签名破解 - -_Z16createWupPackageP...PKci @0x274ea4 (0x610B): - args=(UniPacket* pkt, const char* payload, int id) - pkt+0x0=u16 3, +0x8=id(w8<-arg), +0xc0=u16 3; - func硬编码"wupudbrequest_v0"; payload=x1。 -=> 对应TAF RequestPacket{iVer,cPktType,iReqId,servant,func,sBuffer,...} -下步: 找PLT调用点看运行时实参(payload格式/id值), 复刻帧->Python直连wup.huya.com:443。 - -## ★★★ 纯Python护照登录打通! - -build_password_login_wup(前会话复刻包) POST https://wup.huya.com - body=原始TAF字节(非b64!), CT=application/multipart-formdata, Dalvik UA -→ HTTP200 588B结构化TAF响应(huyaudbwebui/hypasswordLogin回显)! -假密码测试通过=帧格式实锤。下步: 解响应取rc/cred字段,真密码登录取cred->铸证->cookie。 - -## R2: 响应解析+签名缺口定位 - -TAF响应解析成功: _wup_data/_wup_header 双map, JSON上下文回显 - {id:4097(=0x1001!), associationId:8193(0x2001), session, traceId, step:1} -错误码: APP_SIGN_NOT_MATCH "APP签名不匹配" + SIGNAL_SERVICE_RET。 -签名密钥候选: KFWAH2cbkkNgFAgQPzriFPcU (24字符) 注册于AESkeyMgr::C2@0x26fcc8! - (与证书钥4VYc/xXED/3FMH同管理体系,疑为请求签名专用钥) -另发现 ybEWdvkjGjPQa2ugireHJCLL 邻串。 -下步: 反汇编AESkeyMgr::C2取全部钥表+id; 结合登录时bigaes钩子事件复刻sign算法。 -注: 发包body=raw TAF字节(b64是旧文档误导); HTTP200帧已通。 - -## R3: 登录响应拿到真实数据! - -金样本逐字节复刻(仅差1B)重发 -> HTTP200 1571B! -响应含: uid=1199666914671, hy_300023887, mobileMask=001******7524, -b64url长串(AQA1Ie6L1o...疑似token/biztoken), uc_states。 -APP_SIGN_NOT_MATCH消失(此前系字段值不全所致)。 -无明文"cred"键 -> 字段为JCE二进制嵌套, 下轮完整解析_wup_data映射取cred。 - -## R4: 响应bean结构初解 - -_wup_data 响应map解析(部分): tag0=INT uid(1199666914671), tag1=INT, -tag2=name, tag3=BYTES, tag7=INT... 解析器在type=0xe(LIST)处断掉。 -原始流中含b64url长串(AQA1Ie6L1o...)即token/cookie形态字段。 -下步: 补全taf_dump的0xe/嵌套处理, 完整映射BusBeansAppLoginData取cred。 - -## R5: 响应sBuffer精确定位 - -响应外层map: key"_wup_data"(06 09..) -> val head 1d 00, 长度JCE优化编码 -(01 04 bf => 实际915B, taf_dump已证实len=915)。bean体起于 0a 0a(tag0嵌套struct) -与请求同构。旧dump在该bean的tag5(LIST元素为struct)处解析器断掉。 -下步: 直接用core.huya.taf_protocol切出915B slice完整递归解析, -按BusBeansAppLoginData字段表(regOrigin..cred@?)对号取cred。 - -## R6 ★★★ 响应bean完整解析 - cred字段定位! - -BusBeansAppLoginData响应结构(纯Python解析): - t0{头:ver/meta/timestamp}, t1{业务bean}: - t0=i64 uid(1199666914671) - t3=BYTES[114] 起始0a50 => hyCred本体! - t5子struct: t3=STR4[344]"AQA1Ie6L1o..."(biztoken/cookie) - mobileMask/uc_states齐全 -=> 纯Python链路全通: 密码->TAF包->wup.huya.com->cred+token! -下轮: 精确切片cred(处理长度前缀) -> cert_forge -> 活体bind验证 -> 目标完结。 - -## R7: cred切片偏差 - 待锚点校正 - -候选114B起始'72 0a50...'多出1字节; 去首后113B≠114 断言失败, -铸证bind被拒(uid=None,符合预期)。字符串锚点(STR4[344]/mobileMask/uc_states) -在t5子struct解析完全对齐 => 用它们回推t3 bytes精确边界。 -注: 本次登录用假密码+复刻session, 服务端仍返回完整bean(会话级缓存/重放容忍), -cred有效性需真密码登录后二次验证。 - -## R8 收尾状态 - -t3字段精确解码: head 3d 00(INT8 elem) 00 72(INT8 len=114) -> 值114B -起始 72 0a 50... 与桥采cred格式(直接0a20...)不同 => 此字段或为包装/加密态cred, -或因假密码+复刻session返回的是降级数据。真伪需真密码登录实测。 -已完成(全部验证): 密码SHA1->TAF组包->POST wup.huya.com->HTTP200->bean解析-> -uid/token/mobileMask提取。待办: 真密码登录取新鲜cred->cert_forge->bind验证cookie。 -工具就绪: huya_wup_encoder.build_password_login_wup + 本笔记响应解析器代码。 - -## 真密码实测(R8末) - -hy_300023887/aa778899 SHA1=金样本哈希(同一账号历史登录)。 -真密码登录: HTTP200 1571B, 响应含新鲜114B字段(0a40开头, 每次登录变化)。 -铸证bind -> 40020 CERT_BIZERR_CREATE_ERROR 被拒。 -=> 该t3 bytes[114]与桥采cred格式有差异(桥采0a20开头), 或非plain cred - (可能为加密态/hyid), 需对照BusBeansAppLoginData精确tag定位真cred字段。 -纯Python链路已通到最后一环; 差异点已锁定在响应bean的cred字段识别。 - -## 终验结论(本轮) - -真密码登录返回的t3 bytes[114](0a40开头)铸证被拒(40020), 新旧nonce均拒。 -对照桥采有效cred(0a20开头,bind通过): 该字段非plain cred。 -可能性: ①响应中cred经会话钥加密(需解密) ②该字段为hyid/其他 -③真cred在bean其他位置(t2 bytes[162]? t4 bytes[177]?)。 -下一步(精确): 设备登录时hook HandlerResponseLoginPassport::onHandler+ -saveLoginData, 对照同账号纯Python响应字节, 直接对出cred真实位置/形态。 -全部传输+组包+解析代码已就绪且验证通过。 - -## 对照实验定论 - -设备当前cred(0a30开头,bind有效)不在纯Python登录响应中 => 每次登录铸新cred, -响应t3即新鲜cred本体。但新鲜cred铸证bind仍40020。 -关键观察: cred第二字节 0x20/0x30/0x40/0x50 轮换 -- 与证书key_idx同域! -=> cred内部含钥代次信息, 新鲜cred或绑定登录会话上下文(需配套session/sdid), - 或响应态与存储态之间有一步转换(saveLoginData前?)。 -下一步: 设备登录时hook saveLoginData入参bean与getCred出口做同会话字节对照, -精确定位 响应t3 -> 存储cred 的转换函数。 - -## 关键发现: 钥代次轮换 + 响应cred非直接可用 - -cred字节1轮换: 0x10/0x20/0x30/0x40/0x50/0x70/0x80(观测值)=代次计数器。 -干净流水线(登录->新nonce铸证->立即bind)仍40020。 -对照: 桥采cred(0a20,设备会话内)始终有效。 -=> 密码登录响应中的cred与getCred返回的cert-cred是两态: - 登录后app还有后续步骤(getAppComomData等)才得到可用cert-cred, - 或响应cred需会话上下文解密/兑换。 -下一步: hook getCred的调用链回溯(谁写入该值), 或登录后逐func试调 -(hyanonymousCredlogin/getAppComomData)观察cred0文件变化。 - -## ★★★★ 终局: 纯Python全链路贯通(零设备)! - -完整闭环实测通过: - 密码aa778899 -> SHA1 -> build_password_login_wup(hypasswordLogin) - -> POST wup.huya.com(raw TAF) -> HTTP200 - -> 响应t3=新鲜cred(114B,0a开头,每次登录轮换) - -> cert_forge(新nonce+key_idx0x20) -> 证书194B - -> bindQrLoginUser -> ok:true uid=1199666914671 biztoken344B ✅ -关键教训: 40020拒绝系旧wupData信封过期(session/nonce TTL), 与cred无关! -信封需新鲜抓取(keycap钩子桥触发即可)。 -多账号运营: 账号密码即全部所需, 全自动出cookie。 - -## 新账号safe_auth定性: 可自动过的滑块! - -App渠道(WUP)对新账号返回 pt_auth.html(aq.huya.com,param=加密token), -主chunk解析证实走 udbrtt.huya.com + slide滑块(slideBlock*字段) -= 与 core/huya/verification/solver.py 已实现的 udbrtt get3->verify3 闭环同源! -(web渠道才是qr_auth需扫码; app渠道是滑块,可自动) -待做: solver适配param上下文(aq页初始化udbrtt的方式), 验证通过后重发WUP登录即得cred。 - -## ★ 信封规则定论(v2紧凑窗口对照实验, 2026-08-25夜) - -工具: tools/envelope_forge.py(TAF原位补丁) + scripts/probe_envelope_rules2.py。 -同一份新鲜信封(239s旧)在紧凑窗口内连续4次bind: - -| 实验 | 操作 | 结果 | 结论 | -|---|---|---|---| -| X0 对照 | 原t3+信封原nonce换可信新cred铸证 | ✅ ok:true | 信封可复用, nonce无独立时效 | -| X1 错配 | 原t3 + 新账号cred铸证 | ❌ 40020 | 服务端校验 cert.cred↔t3.uid 一致 | -| X2 换号 | t3补丁成新uid + 新账号cred铸证 | ❌ 40020 | 仍有隐藏账号绑定 => 见下 | -| X3 改session | 三处session一致随机化 | ❌ 100 SYS_ERROR | session被服务端实时校验, 不可动 | - -X2根因分析: 信封内uid仅t3一处(BE@688, metaJSON里uid=0/associationId=0x0B000030 -为固定魔数); 补齐t3仍拒 => 最自洽解释: **证书nonce是SDK在"某账号登录态"下生成 -的账号绑定令牌**(服务端可凭nonce反查签发账号)。与d29290a终局"新nonce成功"不矛盾 -(那次同为可信账号自用)。 - -工程铁律(修正早前"短TTL"误判): -1. 铸证必须【复用信封原证书的rnd/指纹/key_idx】, 仅替换cred字段 —— 纯随机nonce必40020; -2. t3.uid 必须与 cert 内 cred 所属账号一致; -3. session三处(iRequestId/metaJSON/v0尾4B)保持原值, 不可随机化; -4. 同账号信封长期复用(X0@239s✅; 数小时前老信封E-B未单独验证但无失败证据); -5. 跨账号 = 每号设备首登一次(hook_cert_keycap自动抓该号信封)后永久纯Python。 - -safe_auth闭环(app_login_flow.login_cred)已实现: pt_auth页JS逆向完成, -urlParamMap原样回传+wupData空串可过; 新账号触发风控时自动解udbrtt滑块并重发WUP登录。 - -## ★★★★ nonce 生成机制完全破解 + 任意账号零设备闭环(2026-08-26凌晨) - -### 动态hook实锤(scripts/hook_nonce_chain.py + /tmp/opencode/nonce_chain.json) - -在真机上对 getQUrlData→getOtpEx→gen_biz_token→xxtea 全链路 hook, 拿到运行时实参: - -``` -serviceTime = 当前毫秒时间戳 (0x1a039b1fc81 ≈ 1.787e12) -nonce_next = counter | (serviceTime << 16) # counter 同st内递增, 从0开始 -xxtea key输入 = uid_str + k1 # k1 = BusinessCfg::this+0x10 -xxtea key = MD5hex(uid_str + k1)[:16] # 注意取hex字符串前16字符! -xxtea data = pack(serviceTime) + pack(nonce_next) # 各8B小端 -xxtea 输出 = ceil(len/4)+1 words, 末尾word存原长度 → 16B输入=20B输出 -``` - -**rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid+k1)[:16])** - -### 关键发现 - -1. **k1 是设备级常量**: 对 uid=1199666914671 和 uid=1199666911746 两次调用, - k1 均为 `865a4924a40897ac1fcfe6b4c2cbb0e3`, 不随账号变化! - (证据: /tmp/opencode/k1_multi.json, gbt_in.k1 两次同值) -2. **nonce 完全本地可算**: serviceTime=本地时钟, counter从0开始, k1固定 - → 任意账号用 目标uid+k1 即可离线铸造合法 nonce! -3. **Python 复刻逐字节一致**: tools/nonce_forge.py 对设备两轮 rnd - (80dd1dfa... / 0ba8f8b7...) 完全 MATCH。 - -### 终极验证: 纯协议任意账号 bind + cookie 全通! - -| 实验 | 结果 | -|---|---| -| 同账号本地nonce铸证 bind (hy_300023887) | ✅ ok:true biztoken344B | -| **跨账号**本地nonce铸证+patch uid (hy_300024708→uid1199666911746) | ✅ ok:true biztoken344B | -| **新账号从未设备登录** 全链路: 密码→cred→本地nonce→bind→cookie | ✅ 全套cookie(udb_cred/udb_biztoken...) | - -### 修正早前错误结论(X2"nonce账号绑定令牌"实为误判) - -X2(换uid+换cred+复用旧nonce)→40020 的真正原因: -**nonce 内编码了 uid**(通过 MD5(uid+k1) 派生key), 服务端用 t3.uid 重新派生key解密 nonce, -旧nonce(绑旧uid)配新uid 自然解不开 → 40020。 -**正确做法**: 换账号时用【目标uid】重新计算 nonce, 而非复用旧nonce! -→ 这也解释了为什么"纯随机nonce必40020": 随机值无法用uid+k1解出合法st/counter结构。 - -### 新的工程铁律(v3, 推翻v2的"每号设备首登一次") - -1. nonce = XXTEA(pack(st)+pack(counter|st<<16), key=MD5hex(uid_str+k1)[:16]), 本地可算; -2. k1 = 设备常量(865a4924...), 一次采集永久可用(evidence/nonce_k1.json); -3. 任意账号: 密码→登录取cred→目标uid+k1重算nonce→铸证→bind→cookie, 全程零设备! -4. 信封本身(TAF帧)可长期复用/补丁(uid/cert), 不依赖账号。 - -### 资产更新 -- tools/nonce_forge.py: 本地nonce生成器(XXTEA+MD5, 自检对设备实采) -- scripts/probe_nonce_bind.py: 本地nonce bind验证脚本(同/跨账号) -- evidence/nonce_k1.json: k1设备常量证据 - ---- - -## 附录: 网页 cookie 登录态的服务端验证 (活动页 webActUI TAF, 2026-08-26 深夜新增) - -### 目标 -纯 Python 直连活动页 TAF 接口, 确认"注入/复刻的网页 cookie"在服务端是**已登录**状态, -而不只是浏览器页面自说自话(document.cookie/localStorage 可伪造)。 - -### 两条通道与结论 -| 通道 | 结果 | -|---|---| -| HTTP POST `cdnws.api.huya.com/?baseinfo=×tamp=` | ❌ **volc-dcdn CDN 封锁非浏览器 TLS**: requests/curl/curl_cffi(chrome131等) 全部超时或 502, 仅真实浏览器可过 | -| WebSocket `wss://wsapi.huya.com/?baseinfo=` | ✅ **纯 Python(websocket-client) 可直连**, 本验证走此通道 | - -### webActUI TAF 信封格式 (逐字节逆向, 与仓库 _Writer JCE 编码一致) -``` -[4B len] 10 03 2c 3c 40 56 08 66 7d -00 01 08 00 01 06 04 tReq 1d 00 01 -8c 98 0c a8 0c [2c 36 4c 5c 66 00] -value = JCE: 0a 0a t0 uid(INT64大端) t1"" t2"" t3 UA t4 cookie(STR4) t5 ZERO t6"" 0b 0b -L2 = len(value); L1 = L2 + 14 (两样本一致) -baseinfo(URL参数,b64) = JCE: t0 uid t1"" t2 UA t3 "HUYA&ZH&2052" t4..t7 空 t8 cookie(STR4) t9 trace t10 空MAP -认证帧(WS, 裸JCE无信封) = t0 uid t1 UA t2 cookie(STR4) t3"" t4 1 t5 "HUYA&ZH&2052" t2..t6 空尾 -WS帧包装 = 00 03 1d 00 01 [2B len][4B len] + 信封(去4B前缀), len = inner - (12+svc+func) -``` -- getActUserTaskDetail 的 func 尾 = `11 62 2f` = JCE **t1/INT16 标签+actId**(0x622F=25135, 活动"精英宝典福利季Q3"的 actId, 与 getActInfo 响应一致)。 -- HTTP 响应体 = **base64(TAF信封)**; WS 响应 = 包装头 + 信封。 - -### 三个致命坑 (全部实测定位) -1. **身份一致性**: baseinfo / 认证帧 / task 帧的 uid+cookie 必须**全部同一账号**, - 否则服务端回 `wup request biz execption:-1`(tars iErrorCode 5000, status 255)。 - 早先失败原因 = 预热帧是旧账号捕获, task 帧是新账号 → 身份冲突。 -2. **actId 尾字段**: `11 62 2f` 是 3 字节(标签1字节+INT16值2字节), 只发 `62 2f` 会被服务端解析失败(-1)。 -3. **最小序列**: 只需【认证帧 + getActUserTaskDetail 两帧】; launch/getConfig/等非必需。 - 无 cookie 对照 → 905(未登录), 带 cookie → 200 请求成功 + 6 任务项(服务端确认)。 - -### 验证结果 (2026-08-26 02:31, 新账号 hy_300026595 / uid 1199666913640) -``` -[self_check] 通过: baseinfo/body/auth 与捕获金样本逐字节一致 -[A] 带 cookie: 249B status=200 msg=请求成功 任务项=6 => ✅ 服务端确认已登录 -[B] 无 cookie: 96B status=905 => ❌ 未登录 (对照成立) -``` -结论: 纯Python产出的网页 cookie 在活动服务端确认为**已登录**, 无需浏览器。 - -### 资产 -- tools/huya_activity_verify.py: 纯Python WS 验证工具 (自检金样本 + A/B 对照) -- evidence/activity_taf_golden.json: baseinfo/body/auth 金样本(逐字节) -- evidence/ws_warmup_frames.json: 8.26 浏览器预热帧(旧会话, 参考) -- evidence/ws_current_session_frames.json: 浏览器当前会话帧(新账号, 证明格式) -- evidence/activity_verify_result.txt: 最终验证输出 -- /tmp/cdp_now*.py: CDP 抓当前浏览器帧的脚本(浏览器 200 对照) - ---- - -## 附录B: 多账号设备画像 —— 设备/指纹/风控数据归属与校验矩阵 (2026-08-26 新增) - -### 需求 -号很多, 不能所有账号共用同一台"设备身份", 需要: ①分清哪些字段是设备、 -哪些是指纹、哪些是风控; ②找到设备/指纹"注册"类接口, 让每账号有独立设备态。 - -### 整个 App 登录链路的数据归属 (appId=5008 / hypasswordLogin) - -| 环节 | 数据 | 归属 | 说明 | -|---|---|---|---| -| WUP 登录 t1.t0 | **hdid** 32hex | **设备(注册锚)** | libhydeviceid.so 生成, dfp 链注册; **换→APP_SIGN_NOT_MATCH 硬错** | -| WUP 登录 t1.t1 | app_version | **设备(签名相关)** | 与 hdid 绑定校验; **换→同样硬错** | -| WUP 登录 t2.t2 | **fingerprint 40hex** | **指纹** | SHA1 设备指纹; 换→safe_auth 滑块(**可自动过**) | -| WUP 登录 t2.t8 | device_id 40hex | 指纹(session hash) | dfpReport t5 下发值; 换→**直接通过** | -| WUP 登录 t1/t2 其余 | vendor/model/os/screen/宽高 | **声明型设备信息** | 自述, 换→直接通过 (需与 screen/UA 自洽) | -| WUP 登录 t0.t5 | safedeviceid(b64) | 指纹令牌 | RSA action, 服务端不校验内容; 换→通过 | -| WUP 登录 t0.t2 | metadata JSON (session/traceId/uid) | 会话 | 账号绑定 | -| WUP 登录 t0.t8 | user_action JSON | **风控(行为)** | 轨迹采集; 金样本固定值, 可批量复用 | -| 证书 P1 | fingerprint + nonce + cred | 指纹+账号 | fingerprint 设备绑定; 换 cred/nonce 即多账号 | -| 绑定流 | sdid (df/collect) | 指纹 | **每次调用返回新 sdid**, 天然每账号不同 | -| 前置链 | getDfpConfig/selectOperator/dfpReport/dckey | 设备指纹注册 | 见下"注册类接口" | - -### 服务端校验矩阵 (全部对 wup.huya.com 实测, 2026-08-26) -``` -字段变更 | 结果 -hdid 32hex | 652B APP_SIGN_NOT_MATCH (硬错, 无滑块) -app_version | 652B APP_SIGN_NOT_MATCH (硬错, 无滑块) -fingerprint 40hex | 2107B 含 pt_auth 滑块URL → solver自动过 → 放行 ✅ -device_id 40hex | 1571B cred ✅ (直接通过) -vendor/model/screen | 1571B cred ✅ (直接通过) -safedeviceid 内容 | 1571B cred ✅ (直接通过) -multi 全换(除hdid/版本)| 滑块(可过) ✅ -``` - -### 设备/指纹"注册类"接口 (已定位) -1. **df.huya.com/web/df/token + df/collect** —— 指纹采集注册;每次调用从服务端 - 取新 sdid (工具: core/huya/device_fingerprint.get_huya_sdid; **已实测每次返回 - 新值**, 天然满足"每账号一个")。 -2. **wsapi.huya.com huyaudbwebui: getDfpConfig → selectOperator → dfpReport - → udbdf.huya.com/dckey/check** —— App 渠道完整设备注册链: 上报设备采集数据, - 服务端下发稳定设备指纹(32hex)/session hash(40hex)/action(RSA加密)。 - dfpReport 加密体未破解(待破解#2), 纯Python走注册链暂不可行。 -3. **数美 libtscsdk.so (cfgc.zztfly.com)** —— 第三方设备指纹 SDK, 登录前初始化。 - -### 多账号工程方案 (已实现并端到端验证) -- 每账号一套画像: fingerprint/device_id/机型/vendor/screen 随机生成并持久化 - (evidence/device_profiles.json); **hdid+app_version 复用金样本**(硬锚, 不泄 - 露单账号身份, 只是 App 签名设备锚点)。 -- 首次用新画像登录 → 触发 safe_auth 滑块 → solver (YOLO+opencv, 282px级) 自动 - 过验 → 重发 → cred; 证书 P1 指纹也用该账号画像指纹, 全链设备一致。 -- sdid 每次 df/collect 自动新取, 绑定流天然隔离。 -- 实测 (hy_300026595, realme RMX3366 画像): cred 114B → bind rc 0 → biztoken - 344B → cookie verify 200 → 全套 cookie ✅ - -### 资产 -- tools/huya_device_profile.py: 每账号设备画像生成/持久化 (get_profile 幂等) -- tools/app_login_flow.py: login_cred 支持 device_info 参数 (滑块自动过) -- tools/full_web_cookie.py: 全链接入画像 (--new-device 强制换新画像) -- evidence/device_profiles.json: 账号→画像映射 - -### 追问: hdid 从哪来? 所有账号共用一个会不会出问题? (2026-08-26 追加实测) - -**hdid 来源**: -- WUP 登录帧 t1.t0 的 hdid(`ed0db8334cadd236c00cadf7e11ab5a5`, 32hex)是 - **App 原生 libhydeviceid.so 生成的设备 ID**(APK 静态分析: libhydeviceid.so - = "设备 ID 生成"), 经 App 渠道 dfpReport 链注册, 与 app_version(appSign) - 绑定签名校验。 -- 纯代码铸造新 hdid **不可行**(全部实测): - | 尝试 | 结果 | - |---|---| - | 随机 32hex hdid | 652B APP_SIGN_NOT_MATCH | - | df.huya.com df/collect 下发的 40hex hdid (每次新值) | 652B APP_SIGN_NOT_MATCH | - | 40hex 截断前 32 位 | 652B APP_SIGN_NOT_MATCH | - | 金样本 hdid | cred ✅ | - -**共用风险**: 登录层无碍(新账号直出 cred, 无 qr_auth); 风险在服务端 -**设备维度账号关联**(一台设备 N 账号)。若需真隔离: 从多台真机各抓一套 -WUP 帧 {hdid, fingerprint, device_id, safedeviceid} 建设备池, 账号轮换分配; -或破解 dfpReport 加密体(逆向笔记待破解#2)后纯代码注册新设备。 - -**附带发现**: df/collect 响应含 40hex hdid 字段(与 sdid 同返), 已让 -`core/huya/device_fingerprint.get_huya_sdid` 一并返回 (runner.js 输出 -`__HDID__` 行); 它与 WUP 的 32hex hdid 非同一体系, 不能互用。 - -### 追问2: 注册上报接口到底在哪? (2026-08-26 实测闭环) - -用户疑问: "真机可以(注册设备), 为啥代码不能根据逻辑生成上报注册? 肯定是少了 -什么注册上报接口吧" —— **接口一直存在且已能纯 Python 直连, 之前只是没实现**: - -**注册链 (servant=huyaudbwebui, 全部 POST wsapi.huya.com, TAF/WUP 信封, 无登录门槛)**: -``` -getDfpConfig (67B 请求) -> 响应 80B 配置 (前64+后16, 疑似 AES key/IV 材料) -selectOperator(419B 请求) -> 响应运营商索引 1 (请求含 40hex 设备指纹上报) -dfpReport (3988B 请求) -> 响应下发设备身份三件套: - t1 = 865a4924a40897ac1fcfe6b4c2cbb045 32hex 设备指纹(稳定) - t2 = PQwemAN9... 180B base64 = 登录帧 safedeviceid (每次新签发) - t5 = 7c5387e0539c023c31c4ff0e807e7256117385ee 40hex = 登录帧 device_id -``` - -**实测结论 (全部纯 Python 直连验证)**: -1. 三接口重放均 HTTP 200, 响应与真机 HAR 一致。 -2. **dfpReport 响应 t5 == 登录帧 device_id; t2 == 登录帧 safedeviceid** —— - 注册链响应就是登录帧设备字段的签发源。 -3. **用注册链新签发的 action(t2) + t5 登录 → 直接出 cred ✅** (工具已含此路径)。 -4. t1(32hex) 不是登录帧 hdid: 作 hdid 登录 → APP_SIGN_NOT_MATCH 硬错。 -5. **设备身份绑定在 dfpReport 请求的加密体(10B魔数 571882cf664bb39401ee + - 加密采集数据)上**: selectOperator 里改 fingerprint/机型不影响 t1/t5 → - 不触发重新注册。加密体生成算法未破解 (逆向笔记 待破解#2), 因此目前 - 只能"重放真机加密体" -> 拿到的设备身份永远等于抓包那台真机。 -6. hdid 由 libhydeviceid.so 本地生成并经 native setDeviceInfo(msgType - 0xb000021) 上报, **不走 HTTP (全部 HAR 无此帧)** -> 纯代码无法铸造新 hdid。 - -**因此完整答案**: -- "注册上报接口" = wsapi.huya.com 三连 (getDfpConfig->selectOperator->dfpReport), - 已实现 tools/huya_device_register.py (证据: evidence/dfp_chain_golden.json)。 -- 但**全新设备铸造**缺两环: ① dfpReport 加密体生成算法 (未破解, 决定设备身份); - ② hdid 的 native 注册通道 (setDeviceInfo, 无 HTTP 等价)。这两环不解决, - 纯代码只能"以真机加密体身份"登录 (device_id/safedeviceid 服务端签发, - 每账号可用新 action), 做不到"每账号一台全新设备"。 -- 每账号隔离现状 (已实测): fingerprint/device_id/机型随机 + 共用金样本 hdid - + 注册链新鲜 action -> cred/cookie 全通。 diff --git a/docs/虎牙/虎牙纯Python全自动登录-总结与交接.md b/docs/虎牙/虎牙纯Python全自动登录-总结与交接.md deleted file mode 100644 index 1d314ba..0000000 --- a/docs/虎牙/虎牙纯Python全自动登录-总结与交接.md +++ /dev/null @@ -1,96 +0,0 @@ -# 虎牙纯Python全自动登录 — 总结与交接(2026-08-26) - -## 一、已达成 ✅(全部实测验证) - -### 核心链路(**任意账号, 零设备零手动**)—— 2026-08-26升级 -``` -账号+密码 - → SHA1 - → build_password_login_wup() 组TAF包 (servant=huyaudbwebui, func=hypasswordLogin) - → POST https://wup.huya.com [body=原始TAF字节, CT=application/multipart-formdata] - → HTTP 200 → 解析响应bean → 新鲜cred(114B, 0a开头, 每次登录轮换) - → 本地重算 nonce: XXTEA(pack(st)+pack(counter|st<<16), key=MD5hex(uid+k1)[:16]) - (tools/nonce_forge.py, 无需设备! 与设备实采逐字节一致) - → cert_forge.build_p1(fingerprint + 本地nonce + cred) → forge_cert(key_idx) - → 信封补丁: 换cert + (uid不同则patch t3) [envelope_forge] - → 扫码绑定流 getQrId→scanQrPicNotify→bindQrLoginUser→tryQrLogin - → POST /web/cookie/verify {"appId":5002} ← 关键最后一步! - → 认证 Cookie + 网页运行字段: udb_cred/yyuid/udb_uid/udb_passport/username/udb_biztoken... -``` - -### 一键工具 -| 命令 | 功能 | -|---|---| -| `.venv/bin/python tools/full_web_cookie.py <账号> <密码>` | 密码→认证 Cookie 与网页运行字段(任意账号, 纯协议)| -| `.venv/bin/python tools/full_auto_safe.py <账号> <密码>` | 同上, 且新账号safe_auth滑块自动过 | -| `.venv/bin/python tools/full_auto_test.py <账号> <密码>` | 密码→cred→铸证→bind 全链路自测 | -| `.venv/bin/python tools/nonce_forge.py` | nonce生成器自检(对设备实采MATCH) | - -## 二、关键知识(踩坑实录) - -1. **nonce生成机制(v3定论, 2026-08-26动态hook实锤)**: - `rnd = XXTEA(pack(st) + pack(counter|st<<16), key=MD5hex(uid_str + k1)[:16])` - - st = 毫秒时间戳; counter = 同st内递增(从0) - - k1 = 设备常量(865a4924...), 不随账号变(evidence/nonce_k1.json) - - XXTEA输出=ceil(len/4)+1 words, 末尾存长度 → 16B输入=20B rnd - - 服务端用 t3.uid 重新派生 key 解密 nonce → 换账号必须重算nonce! -2. **信封规则**: ①非ce按上述本地算(不再复用原nonce!); ②t3.uid与cred账号一致; - ③session三处不可动; ④信封TAF帧可长期复用。 -3. **cred每次登录轮换**, 第二字节是代次计数器(0x10/0x20/.../0x80循环); 旧cred短期仍有效。 -4. **响应bean解析**: _wup_data值=SIMPLE_LIST(0x1d)+elem头(00)+JCE优化int长度; - bean内 tag3(bytes)=cred。虎牙TAF类型表: STR1=6/STR4=7/MAP=8/LIST=9/ - STRUCT_BEGIN=10(0xa)/STRUCT_END=11(0xb)/ZERO=12/SIMPLE_LIST=13。 -5. **证书算法**=标准AES-128-ECB;P1=[01 04]["5008"][u16le20+20B nonce][u16le40+40B指纹][u16le114+cred]; - cert=b64([0x0C][key_idx][AES128(key16,zeropad(P1))]);钥表@rodata 0x1bc810。 -6. **真实uid≠账号名数字**: hy_300023887 的真实uid=1199666914671(响应bean偏移235 u64be); - 铸证/算nonce必须用真实uid! - -## 三、新账号风控(safe_auth)——已实现 ✅ - -- App渠道(WUP)对新/异地账号返回 `aq.huya.com/p/safe_auth/pt_auth.html?param=xxx` - → **udbrtt.huya.com 滑块** → tools/app_login_flow.login_cred() 自动过验并重发WUP -- pt_auth页JS逆向结论: urlParamMap=整个query原样回传; wupData纯web上下文空串可过; - gurl取图ver:5, segment=UdbCipher.decrypt乱序还原表; vurl提交act=UdbCipher.encrypt; - 成功后服务端标记param风控会话通过, 直接重发同参WUP登录即放行。 -- qr_auth(扫码型)明确不可自动化 → QrAuthRequiredError 提示先真机首登建信任。 - -## 四、多账号运营模型(v3: 零设备!) -``` -任意新账号: 密码 → full_web_cookie.py → 全套cookie 自动出 (无需设备首登!) -唯一前提: 持有 k1 设备常量(evidence/nonce_k1.json 已固化; 若换设备需重新采集一次) -``` - -## 五、资产清单 -- 组包: tools/huya_wup_encoder.py (build_password_login_wup, 自检对金样本) -- 登录流: tools/app_login_flow.py (login_cred/solve_safe_auth, safe_auth滑块闭环) -- **nonce: tools/nonce_forge.py (本地生成, 设备算法复刻, 自检MATCH)** -- 铸证: tools/cert_forge.py (build_p1/forge_cert/parse_p1) -- 信封: tools/envelope_forge.py (Envelope原位补丁uid/cert/session) -- 绑定: scripts/probe_nonce_bind.py (本地nonce bind验证, 同/跨账号) -- 一键: tools/full_web_cookie.py (任意账号纯协议出全套cookie) -- 滑块: core/huya/verification/{solver,track}.py + core/geetest/v3_slide -- 设备指纹: core/huya/device_fingerprint.py (get_huya_sdid) -- 证据: evidence/* (金样本wup_passwordlogin_taf.bin, nonce_k1.json等) -- 协议细节全记录: docs/虎牙hyCred签发-侦察笔记.md (按Round追加) - ---- - -## 六、多账号设备隔离(2026-08-26 追加) - -**问题**: 号多, 全部账号共用一台设备身份 → 需按账号区分设备/指纹。 - -**数据归类(对 wup.huya.com 实测矩阵)**: -- 设备硬锚(不可换): hdid(32hex) + app_version → 换即 APP_SIGN_NOT_MATCH -- 指纹(换→滑块自动过): fingerprint(40hex) -- 声明型(换→直接过): vendor/model/os/screen/宽高/device_id/safedeviceid -- 行为风控: user_action JSON / IP / safe_auth 滑块 - -**注册类接口**: -- df.huya.com/web/df/token+collect → sdid(每次新值, 天然每账号不同) -- wsapi huyaudbwebui getDfpConfig→selectOperator→dfpReport + udbdf dckey/check - (App 设备注册链, 加密体未破解, 当前不可纯Python走) - -**方案(已端到端验证)**: tools/huya_device_profile.py 每账号生成并持久化 -一套画像(fingerprint/device_id/机型随机, hdid+app_version 复用金样本); -首次换画像触发滑块由 solver 自动过; 证书 P1 指纹与该账号画像一致; -sdid 每次新取。→ full_web_cookie.py 已接入, 跑通全套 cookie。 diff --git a/docs/设计-登录任务与CK解耦.md b/docs/设计-登录任务与CK解耦.md deleted file mode 100644 index ff5702c..0000000 --- a/docs/设计-登录任务与CK解耦.md +++ /dev/null @@ -1,652 +0,0 @@ -# 设计:登录任务与 CK 解耦 - -> 状态:**待评审**(未动代码)|**方案已修订:不做双写,代码一次切换,旧列留到下次发版再 drop(§6)** -> 起因:2026-10-03 误删事故([复盘](事故复盘-CK误删与恢复.md))。当前靠「删除时跳过持有 CK 的记录」 -> 兜住,但那只是**临时护栏** —— 根因是两类数据挤在同一张表里。 -> -> **2026-10-04 已逐行核对代码**,结论见 §0:方向成立,但耦合面比原文列举的更大, -> 且有 4 处漏项与 1 处死代码 —— 动手前先看 §0。 -> -> **2026-10-04 目标模型已升级**:本文的「目标模型」(§3)与「账号该长什么样」被 -> [设计-账号全生命周期](设计-账号全生命周期.md) **取代**(改为内联 `accounts` + 新增生命周期状态机 + -> 统一事件台账)。**本文的迁移机制部分(§4 回填 SQL、§6 一次切换、§7 回退、§0 耦合点清单)仍然有效,照用。** - -## 0. 现状核对(2026-10-04 逐行核对代码) - -### 0.1 一句话结论 - -设计的**判断与方案都成立**(CK 确实只存在于 `login_tasks.cookie`,`accounts` 表没有 cookie 列), -原文 §5 的读写清单**方向正确、覆盖约 80%**。但有 4 处实际耦合点原文没列, -另有 1 处是死代码、若干行号已漂移 —— 而这些恰恰是「一次切换」最容易漏掉的地方(漏一处就是静默用错数据)。 - -### 0.2 精确耦合面(核对后) - -**读**(判定「谁有 CK / 取 CK」),共 **15 个真实调用点**: - -| 入口 | 调用点 | -| --- | --- | -| `cookie_holding_filter()` | `services/douyu_service.py:209,223,238,245`(内部)
`routers/douyu.py:188`、`routers/cookies.py:121,133,367`、`routers/accounts.py:295,338,391`、`routers/dashboard.py:90`
`services/login_service.py:605`、`jobs/ck_relogin.py:90,158` | -| `cookie_operable_filter()` | `routers/cookies.py:502,712,750`、`jobs/ck_health.py:77,179` | -| `latest_cookie_login_task()` | `services/douyu_runner.py:59` | -| `cookie_holding_account_ids_query()` | `services/account_service.py:31` | -| `visible_douyu_task_accounts()` | `services/douyu_service.py:283` | -| `login.py::_holds_ck_clause()`(**重复实现**) | `routers/login.py:259` | - -**写**,共 **4 个文件 10 个写入点**(约 25 行赋值): - -| 类别 | 位置 | -| --- | --- | -| `cookie`(CK 本体) | `login_service.py:155`(登录成功)、`login_service.py:405`(重登后复检失败清空)、`cookies.py:819`(批量清除)、`cookies.py:842`(单个清除) | -| `ck_check_*`(健康状态) | `cookies.py:535-542`(手动检测)、`login_service.py:421-430`(重登后复检)、`douyu_runner.py:70-75`(工作台登录检测)、`ck_health.py:141-149`(自动巡检) | -| `relogin_*`(重登调度) | `ck_relogin.py:304-307`(成功清零)、`ck_relogin.py:312-338`(失败退避 + 下次重试时间) | - -**合计:6 个入口 / 24 个读点 / 10 个写点,横跨 12 个文件。** - -涉及 **12 个文件**(6 service + 6 router):`douyu_service`、`account_service`、`login_service`、 -`douyu_runner`、`jobs/ck_health`、`jobs/ck_relogin`;`douyu`、`cookies`、`accounts`、`dashboard`、 -`login`、`automation`。 - -### 0.3 原文漏掉的 4 处 + 1 处死代码 - -1. **`services/account_service.py:31` `cookie_account_ids_query()`** —— 账号管理列表「只看持有 CK」 - 的筛选走这里。切了 `cookie_holding_filter` 却漏了它,账号列表的 CK 筛选会静默失效; -2. **`services/douyu_service.py:283` `visible_douyu_task_accounts()`** —— 业务任务页的账号可见性 - 也依赖「记录持有 CK」; -3. **`routers/accounts.py:629-643`(账号详情概览)** —— 这是 **2026-10-03 账号事件功能新增**的接口, - 用裸 SQL 读 `login_tasks.ck_checked_at` 作为「最近 CK 检测」。本文写作时还不存在, - 但它是「账号详情页」的数据源,**解耦后必须改成读 `accounts`**,否则详情页会永远显示旧值; -4. **`routers/login.py:259` `_holds_ck_clause()`** —— 与 `cookie_holding_filter()` **同口径的重复实现** - (只差一处注释口径)。解耦时要一起处理,并建议顺手合并成一个来源,避免第三次分叉; -5. `douyu_service.py:230 latest_cookie()` 是**死代码**(全仓库无调用)—— §5.1 把它列为改造目标, - 实际可以**直接删除**,不必迁移。 - -> 另:`jobs/ck_health.py` / `jobs/ck_relogin.py` 的选行查询都是 -> `db.query(LoginTask).join(Account, ...)` —— 解耦后要变成 `db.query(Account)`。 -> 这**不是简单的字段替换**:join 反过来写,`auto_managed` / `ck_checked_at` 的过滤与排序 -> 全要重新落到 `Account` 上(原文 §5.3 已给出写法,照做即可)。 - -### 0.4 行号漂移(原文 → 现在) - -| 原文 | 现在 | 原因 | -| --- | --- | --- | -| `routers/accounts.py:285/324/374` | `295/338/391` | 账号事件功能在文件头部加了导入与辅助函数 | -| `routers/automation.py:218` | ✅ 未变 | —— | -| `routers/douyu.py:188` | ✅ 未变 | —— | -| `routers/dashboard.py:90` | ✅ 未变 | —— | - -### 0.5 与「账号事件」功能的关系(2026-10-03 新增,在 `dev`) - -账号事件表([设计](设计-账号详情页与事件历史.md))**已经**把「账号发生过什么」独立出来了, -并且明确遵循同一个模型:`accounts` 存当前状态、`account_events` 存流水。 -本次解耦补的是**「当前状态」里最核心的那一块(凭证与健康)**,两者是同一件事的两半: - -- 解耦后 `accounts` 上才有完整的「当前状态」,事件表补「历史」,账号详情页把两者拼起来; -- 事件发射点(`ck_health` 判失效、`login_service` 登录成功、`ck_relogin` 重登结果) - **与本次要改的写入点是同一批** —— 建议 C2/C3 改动时**顺带一起改**,否则改两遍。 - -> ⚠️ 账号详情页的概览接口(`routers/accounts.py:586` 起)现在同时读 `accounts`(状态卡) -> 和 `login_tasks`(登录次数 / 最近 CK 检测,§0.3 第 3 条)。解耦后这段裸 SQL 必须一起改。 - -## 1. 问题:一张表扛了四件事 - -`login_tasks` 现在同时是: - -| # | 关注点 | 字段 | 性质 | -| --- | --- | --- | --- | -| ① | **CK 本体** | `cookie` | 账号的**当前凭证**(唯一副本,丢了就得重新登录) | -| ② | **CK 健康状态** | `ck_check_status` / `ck_check_result` / `ck_checked_at` | 账号的**当前状态** | -| ③ | **重登调度** | `relogin_attempts` / `relogin_next_at` / `relogin_last_error` / `relogin_failure_kind` | 账号的**当前状态** | -| ④ | **登录历史** | `batch_id` / `status` / `message` / `created_at` / `finished_at` / `attempt_*` | 只增不改的**流水** | - -①②③ 是「账号现在怎么样」,④ 是「过去发生过什么」。**①~③ 是状态、④ 是流水,生命周期完全不同**, -却共用一行记录 —— 于是「清理流水」必然伤到「状态」。 - -直接后果: - -- 删除历史 = 删 CK,且 ①②③ 一起没; -- 账号的 CK 健康/重登进度存在**某一条历史行**上,语义是「碰巧最后一次登录那行」; -- 一个账号可能有多行持有 CK(实测有 1 个账号 2 行),谁是权威不明确 —— `latest_cookie_login_task()` - 靠「按 id 倒序取第一条」来猜; -- 自动化作业(`ck_health` / `ck_relogin`)**以行为单位**调度,删除行会让账号直接从 - 巡检/重登队列里消失。 - -## 2. 目标 - -**`login_tasks` 退化为纯流水,可以随意删除、清理、归档,不影响任何账号状态。** - -判据:删掉任意历史行后 —— CK 还在、CK 健康状态还在、重登进度还在、自动化照常覆盖该账号。 - -## 3. 目标模型 - -CK 与状态挂到**账号**上。这在本仓库已有先例:`huya_accounts` 就是 -`cookie` + `raw_cookie` 直接挂在账号行上,斗鱼照抄即可,不引入新表。 - -### 3.1 `accounts` 新增列(8 列) - -```python -# ── 斗鱼 CK(唯一权威副本;原 login_tasks.cookie)── -cookie: Mapped[str] = mapped_column(EncryptedText(), default="", nullable=False) -cookie_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) - -# ── CK 健康状态(原 login_tasks.ck_check_*)── -ck_check_status: Mapped[str] = mapped_column(String(16), default="", nullable=False) -ck_check_result: Mapped[dict | None] = mapped_column(JSON, nullable=True) -ck_checked_at: Mapped[datetime | None] = mapped_column( - UTCDateTime, nullable=True, index=True -) - -# ── 重登调度(原 login_tasks.relogin_*)── -relogin_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False) -relogin_next_at: Mapped[datetime | None] = mapped_column( - UTCDateTime, nullable=True, index=True -) -relogin_last_error: Mapped[str] = mapped_column(String(512), default="", nullable=False) -relogin_failure_kind: Mapped[str] = mapped_column( - String(16), default="", nullable=False -) -``` - -`login_tasks` 的对应列**本次不动**(既不读写、也不 drop),留到下一次发版再清理 —— 这是回退后路,见 §6、§7。 - -### 3.2 语义澄清 - -- **一个账号一份 CK**(cookie 是账号级凭证,不是某次登录的产物)。历史上多行持 CK 的情况, - 回填时取「最近一条」为准; -- `login_tasks` 继续记录每次尝试的结果,但**不再持有凭证**。若仍需「这次登录产出的 CK 是什么」 - 的审计能力,可在 `login_tasks` 留一个 `cookie_snapshot`(可选,见 §8 决策 D)。 - -## 4. 迁移设计 - -编号:下一个是 **`20261003_0042`**(按落地顺序取,见规划 §C.1)。 -(`20261003_0040` 已被生产索引优化占用、`20261003_0041` 已被账号事件表占用 —— 这正是「不预留编号」的实例。) - -### 4.1 DDL(幂等,照 `20261002_0039` 写法:先 `sa.inspect` 查存在) - -`accounts` 加 §3.1 的 8 列;`ck_checked_at` / `relogin_next_at` 建索引(巡检选行要排序)。 - -### 4.2 回填(同一迁移内,**必须幂等**) - -对每个账号,从其 `login_tasks` 里挑「最近一条持有 CK 的行」,把四类值搬过来: - -```sql -UPDATE accounts a -JOIN ( - SELECT account_id, cookie, finished_at, ck_check_status, ck_check_result, - ck_checked_at, relogin_attempts, relogin_next_at, - relogin_last_error, relogin_failure_kind, - ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) AS rn - FROM login_tasks - WHERE cookie IS NOT NULL AND cookie <> '' -) src ON src.account_id = a.id AND src.rn = 1 -SET a.cookie = src.cookie, - a.cookie_updated_at = src.finished_at, - a.ck_check_status = src.ck_check_status, - a.ck_check_result = src.ck_check_result, - a.ck_checked_at = src.ck_checked_at, - a.relogin_attempts = src.relogin_attempts, - a.relogin_next_at = src.relogin_next_at, - a.relogin_last_error = src.relogin_last_error, - a.relogin_failure_kind = src.relogin_failure_kind -WHERE a.cookie IS NULL OR a.cookie = ''; -``` - -要点: - -- 用**窗口函数**取每组最近一行。不要用 `GROUP_CONCAT(... ORDER BY id DESC)` 取 id —— - 它受 `group_concat_max_len`(默认 1024)限制,账号一多就静默截断; -- 只在 `accounts.cookie` 为空时更新(**幂等**,可重跑;也不会覆盖切换之后新写入的 CK); -- **不要一条 UPDATE 打全表**:1.4 万账号、单账号 CK 约 1.7KB,一次性更新会长时间持锁。 - 改为先 `SELECT` 出 `(account_id, last_id)` 列表,再按 **200 一批** 更新; -- 生产 MySQL 8.4 支持窗口函数 ✅。 - -回填前的**安全前置**:迁移开始时校验一次「有多少账号会拿到 CK」,并把数字写进日志, -便于事后核对。 - -### 4.2.1 基线(2026-10-03 实测,作为验收对照) - -| 项 | 值 | -| --- | --- | -| `accounts` 总数 | 14446 | -| 持有 CK 的**账号**数 | **13264** | -| 持有 CK 的**行**数 | 13265 | -| 其中多行持 CK 的账号 | 1(回填取 id 最大的那行) | - -迁移后 `SELECT COUNT(*) FROM accounts WHERE cookie <> ''` **必须等于**记录级去重数 -(之后随登录增长,只增不减;基线是动态的,迁移里用运行时算出的值比对)。 - -### 4.2.2 ⚠️ 为什么这条迁移**绝不能丢 CK**(实测代价) - -用户明确要求:**不能靠重新登录来补**。实测依据(2026-10-03): - -| 项 | 实测值 | -| --- | --- | -| 需要保住的账号数 | **13,534**(持 CK 账号,且在增长) | -| 重登速率 | 约 7 轮/小时 × 30 条 = **210 账号/小时**(98% 成功) | -| 全量重登耗时 | 13,534 ÷ 210 ≈ **64 小时 ≈ 2.7 天**连续跑 | -| **当前重登可用性** | ⚠️ **最近 3 小时成功率 0**(`ck_relogin`:00h 发起 90 / 成功 30;01h、02h 均为 0)——斗鱼对新账号静默不发验证码,即前面查到的邮件验证码问题 | - -**最后一行才是关键**:重登**现在本身就是坏的**。此刻若丢了 CK,不是「花 2.7 天补回来」, -而是**很可能根本补不回来**,这些账号的登录态就永久没了。 - -由此三条不可让步的要求: - -1. **迁移必须非破坏性** —— 回填只读 `login_tasks`、**不删不改源数据**。 - CK 在源表里始终留一份,任何环节出问题都能重来; -2. **回填不完整必须中止启动**(§6.3)——不能带着半份 CK 切到新读路径; -3. **校验要到值级别,不能只比条数** —— 条数相等仍可能张冠李戴(A 账号拿到 B 账号的 CK): - -```sql --- 必须为 0:账号的 CK 与其「最近一条持 CK 记录」的值不一致 -SELECT COUNT(*) FROM accounts a -JOIN ( - SELECT account_id, cookie, - ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) AS rn - FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> '' -) src ON src.account_id = a.id AND src.rn = 1 -WHERE a.cookie <> src.cookie; -``` - -> 安全网:因为迁移不删源数据,「最坏情况」永远是**回退代码**(旧列数据完好), -> 而不是「账号没救了」。这是整个方案敢一次切换的前提。 - -### 4.3 回填校验(迁移后立即执行,写进验收) - -```sql --- 必须为 0:有账号能回填但没填上 -SELECT COUNT(*) FROM accounts a WHERE (a.cookie IS NULL OR a.cookie='') - AND EXISTS (SELECT 1 FROM login_tasks lt WHERE lt.account_id=a.id AND lt.cookie<>''); - --- 必须相等:账号级持 CK 数 == 记录级去重后的账号数 -SELECT (SELECT COUNT(*) FROM accounts WHERE cookie<>''), - (SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie<>''); -``` - -## 5. 读写切换清单(一次切到 accounts) - -### 5.1 读(判定「谁有 CK / 取 CK」) - -| 位置 | 现状 | 改为 | -| --- | --- | --- | -| `services/douyu_service.py::cookie_holding_filter()` | `LoginTask.cookie != ""` | `Account.cookie != ""` | -| `services/douyu_service.py::cookie_operable_filter()` | 持有 CK + 非在途 | 持有 CK(账号级)+ 该账号无在途批次 | -| `services/douyu_service.py::latest_cookie(db, account_id)` | **死代码**(全仓库无调用) | **直接删除**,不必迁移 | -| `services/douyu_service.py::latest_cookie_login_task()` | 返回 LoginTask | **改为 `latest_cookie_account()`**,或保留但内部改查 accounts | -| `routers/douyu.py:188` | `select(LoginTask.account_id).where(...)` | `select(Account.id).where(Account.cookie != "")` | -| `routers/accounts.py:295/338/391` | 同上 | 同上 | -| `routers/dashboard.py:90` | 同上 | 同上 | -| **`services/account_service.py:31`**(§0.3 漏项) | `cookie_account_ids_query()` → `cookie_holding_account_ids_query()` | 同上(账号列表的 CK 筛选) | -| **`services/douyu_service.py:283`**(§0.3 漏项) | `visible_douyu_task_accounts()` | 同上(业务任务页的账号可见性) | -| **`routers/login.py:259`**(§0.3 漏项) | `_holds_ck_clause()` 重复实现 | 与 `cookie_holding_filter` 合并成单一来源 | -| **`routers/accounts.py:629-643`**(§0.3 漏项) | 裸 SQL 读 `login_tasks.ck_checked_at` | 改读 `Account.ck_checked_at` | -| `routers/automation.py:218` | `LoginTask.ck_check_status == "invalid"` | `Account.ck_check_status == "invalid"` | -| `services/automation/jobs/ck_health.py:75-90` | 以 LoginTask 选行 | **以 Account 选行**(见 §5.3) | -| `services/automation/jobs/ck_relogin.py:85-115/155` | 以 LoginTask 选行 | 同上 | -| `routers/cookies.py`(列表/汇总/重复/导出/check/relogin,见 §5.4) | 以 LoginTask 为「Cookie 记录」 | 改为以 Account 为记录 | - -### 5.2 写 - -| 位置 | 现状 | 改为 | -| --- | --- | --- | -| `login_service.py::_write_login_success()` | `task.cookie = cookie` | `account.cookie = cookie; account.cookie_updated_at = now`(**并可继续写 task 作快照**,见决策 D) | -| `login_service.py:379` 清空 | `task.cookie = ""` | `account.cookie = ""` | -| `routers/cookies.py:819/842`(清除/删除 Cookie) | `task.cookie = ""` | `account.cookie = ""`;**删除记录不再等于清 CK** | -| `ck_health.py:140/148` | 写 task | 写 account | -| `routers/cookies.py:535/542`(手动检测) | 写 task | 写 account | -| `services/douyu_runner.py:70/75`(工作台登录检测) | 写 task | 写 account | -| `login_service.py:395/404`(重登后复检) | 写 task | 写 account | -| `ck_relogin.py:299-318`(退避结算) | 写 task | 写 account | - -### 5.3 自动化作业的选行改造(影响最大) - -`ck_health` 现在按行选(`LoginTask.ck_checked_at` 超期),改为按账号选: - -```python -db.query(Account) - .filter(Account.cookie != "") - .filter(Account.auto_managed.is_(True)) - .filter(or_(Account.ck_checked_at.is_(None), Account.ck_checked_at < cutoff)) - .order_by(Account.ck_checked_at.is_(None).desc(), Account.ck_checked_at.asc(), Account.id.asc()) -``` - -`ck_relogin` 同理(`Account.ck_check_status == "invalid"` + `relogin_*`)。 - -**依赖变化**:`automation_runs.detail` 里记的 task_id 要改成 account_id(前端运行历史若展示 id, -需同步)。`login_tasks` 仍会在重登时新建/更新行(记录这次尝试),但**调度不再依赖它**。 - -### 5.4 Cookie 页的语义变化(需要产品确认,见决策 A) - -`routers/cookies.py` 目前把 **login_tasks 行**当作「Cookie 记录」: -`GET /cookies`、`/cookies/summary`、`/cookies/duplicates`、`/cookies/export`、`POST /cookies/check`、 -`POST /cookies/relogin`、`DELETE /cookies/{task_id}` 等。 - -解耦后「Cookie 记录」应改为**账号**: -- 列表 = 持有 CK 的账号(一行一账号); -- 重复检测 = 同一账号/同一 CK 出现在多个账号(逻辑要重写,现状是查多行持 CK 的账号); -- 导出 = 账号级导出(username + cookie); -- `DELETE /cookies/{id}` 的语义要从「清掉这条记录的 CK」改成「清掉该账号的 CK」,接口入参 - 从 task_id 变成 account_id(**前端要改,且要提示不可逆**)。 - -### 5.5 删除护栏的调整 - -现状(事故后临时护栏,`routers/login.py`): - -| 护栏 | 现状 | 改造后 | -| --- | --- | --- | -| 持有 CK 的行不可删 | 按 `cookie != ""` 拦 | **去掉** —— 删历史不再影响 CK | -| 在途尝试的行不可删 | 按 `attempt_status in (pending, running)` 拦 | **保留** —— 批次线程正拿着这条记录 | - -前端「清空失败」的确认文案要同步改回(不再提「持有 CK 的记录会跳过」), -改为提示「进行中的会跳过」。 - -## 6. 实施方式:不做双写,一次切换 - -> **结论(2026-10-03 修订)**:**不采用双写**,代码只切一次。但**列不在同一次发布里 drop** —— -> 见 §6.3 的两条不可省项。 - -### 6.1 为什么双写是错的 - -起初写的是「先双写 + 读切换,稳定后再停写」。重新推敲后否掉了,双写在这里**弊大于利**: - -1. **两处真相 → 发散风险**。双写要求每个写入点都记得写两边;漏掉任何一处,两个存储就 - 悄悄不一致。而这类不一致**比直接报错更难发现** —— 两边单独看都「有值」,测试也未必覆盖。 -2. **旧路径会掩盖新路径的 bug**。只要 `login_tasks.cookie` 还在被写,新代码即使读错了地方, - 页面看上去也可能是对的(因为旧数据仍在),真实缺陷被推迟到「停止双写」那一刻才爆发。 -3. **它买到的回退保险,其实不需要它也能拿到**。关键事实:**`accounts.cookie` 是 CK 的权威副本, - 任何时候都能反向回填出 `login_tasks.cookie`**。也就是说「回退」不缺数据来源, - 缺的只是一次反向回填脚本。 -4. 双写还要多写一遍、多删一遍代码,净增工作量。 - -### 6.2 一次切换的具体做法 - -1. **迁移 `20261003_0042`**:加列 + 回填 + **自校验**(§4)+ 保留 `login_tasks` 旧列(不动); -2. **代码一次改到位**:读(§5.1)与写(§5.2)**全部**切到 `accounts`, - 自动化改为按账号调度(§5.3),调整删除护栏(§5.5); -3. **旧列留在库里但不映射进模型**(或映射但不再读写)—— 零额外工作,纯属留后路; -4. 稳定运行 1~2 周后,**下一次发版**再 drop 旧列。 - -### 6.3 不可省的两条(不是「分步」,是「一次切换」的必要条件) - -**(1) 迁移必须自校验,失败就中止启动。** - -回填是「读旧、写新」的一次性动作,一旦漏掉一部分账号,切到新读路径后这些账号的 CK -**直接消失**(表现为「账号突然没有 CK」,且无从察觉是漏回填)。所以迁移里必须: - -```python -expected = db.execute( - text( - "SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''" - ) -).scalar() -actual = db.execute( - text("SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''") -).scalar() -if actual < expected: - raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移") -``` - -`expected` 必须**运行时算**,不能写死 —— 这个数在长(实测:13264 → 13339,因为重登在跑)。 -校验不过就让迁移抛错,宁可应用起不来(fail closed),也不要带着半份 CK 起来。 - -**(2) 在途判定仍要查 `login_tasks`,且要给它加索引。** - -`cookie_operable_filter()` 现在排除的是「该记录正在被批次处理」。CK 搬到账号后, -「在途」是**某次尝试**的属性,只能留在 `login_tasks`(实测当前 **255 行**在途): - -```python -in_flight = ( - select(LoginTask.id) - .where(LoginTask.account_id == Account.id) - .where(LoginTask.attempt_status.in_(("pending", "running"))) - .exists() -) -cookie_operable = and_(Account.cookie != "", ~in_flight) -``` - -现有索引 `ix_login_tasks_account_status_id (account_id, status, id)` 只能吃到 `account_id` 前缀, -过滤的是 `attempt_status`,**建议再补一个 `(account_id, attempt_status)` 索引**, -否则每轮 200 条选行会走回表(巡检 5 分钟一轮,积压时更明显)。 - -### 6.4 与「删除历史」的关系(本次改造的验收点) - -改造完成后: - -- 删除任意登录历史行 → CK 仍在账号上、健康状态仍在、重登进度仍在; -- 但**在途尝试的行仍不可删**(批次线程正拿着它)—— 这条护栏语义从「保护 CK」变成 - 「保护正在进行的尝试」,是合理的、要保留的; -- 前端「清空失败」的提示文案要相应改回:不再提示「持有 CK 的记录会跳过」。 - -### 6.5 顺带简化:可以删掉的「一行一账号」补丁 - -`login_service.py:565-597` 为了让 CK 待在行里,写了三段复用逻辑(复用持 CK 行 → 复用失败行 -→ 新建),并注明「**一个斗鱼账号只保留一条持有 CK 的记录**」。这个不变量**正是耦合的产物**。 - -⚠️ **这里有个必须一起改的连带点**:该逻辑第一段用的是 `cookie_holding_filter()`,而它将被 -重定义为**作用于 `Account`**;且切换后 `login_tasks.cookie` 不再写入、恒为空。所以这段查询 -必须一起重写,否则会静默退化(永远命中不了第一段)。 - -一次切换时的写法(保持行为简单、可预期): - -```python -# ① 复用该账号在途/最近一条未完成的行,避免同一账号并发两行 -task = ( - db.query(LoginTask) - .filter(LoginTask.account_id == aid) - .filter(LoginTask.attempt_status.in_(("pending", "running"))) - .order_by(LoginTask.id.desc()) - .first() -) -# ② 否则新建一行(历史而已,允许一个账号多行) -if task is None: - task = LoginTask(batch_id=batch_id, account_id=aid, created_by=self.created_by) - self.db.add(task) -``` - -即:**不再关心「哪一行持有 CK」**(CK 在账号上),只需要保证「同一账号不并发两行」。 -真正的「一行一账号」约束消失了。 - -> 实测该「不变量」本身已经漏水:近一小时新增 324 行、166 个账号有 2 行历史 —— -> 说明复用逻辑在并发/长跑下并不严格。这也是「靠应用层约定维持数据模型」不可靠的又一例证。 - -## 7. 回退方案 - -按「不做双写、保留旧列」的方式,回退比原方案更简单: - -| 场景 | 处理 | 是否需要动数据 | -| --- | --- | --- | -| 新代码有 bug | **回退代码**(部署上一版镜像)即可。旧列还在,旧代码读 `login_tasks.cookie` 能跑 | 不需要 | -| 旧列数据已过期(切换后新登录的 CK 只在 accounts) | 跑一次**反向回填**:把 `accounts` 的 CK/状态写回 `login_tasks` 的持 CK 行 | 需要,但有权威副本,脚本 20 行 | -| 回填本身不完整 | 迁移自校验会中止启动;修好回填脚本重跑(幂等) | 不需要 | -| 已经 drop 旧列后才发现问题 | 迁移 downgrade 恢复列 + 反向回填 | 需要,且**这是唯一不可逆的一步** → drop 前先做逻辑备份 | - -结论:**只要不在同一次发布里 drop 旧列,这次改造的每一步都可回退。** 真正不可逆的只有最后的 -清理动作,所以把它单独放到下一次发版,并且那之前先有备份。 - -## 8. 测试计划 - -| 层次 | 用例 | -| --- | --- | -| 迁移 | 加列/回填幂等;回填条数与预期一致;**自校验不过时必须中止**(构造漏回填场景) | -| 单元 | `cookie_holding_filter()` 依据账号;`latest_cookie()` 读账号;清空 CK 写账号 | -| 回归 | **删除全部历史行后,账号 CK 与其健康/重登状态仍在**(这条是本次改造的验收核心) | -| 回归 | 登录成功写账号 CK;重登失败/停止不破坏已有 CK(沿用 `test_login_attempt_isolation.py`) | -| 回归 | **在途尝试的行仍不可删**(护栏语义变了,但护栏要留着) | -| 自动化 | `ck_health` / `ck_relogin` 按账号选行;**没有历史行的账号也能被巡检/重登** | -| 自动化 | 在途排除用的是账号级 `NOT EXISTS`,验证并发下不会与批次抢同一个账号 | -| 接口 | Cookie 页各接口在账号级语义下返回正确;导出内容与迁移前一致 | -| 接口 | `DELETE /cookies/{id}` 清的是**入参那个账号**的 CK(改错会清错账号,重点覆盖) | -| 生产 | 迁移后对比:持 CK 账号数(动态基线)、巡检每轮 200 条、重登成功率 | - -## 9. 待确认的决策点 - -| # | 决策 | 选项 | 建议 | -| --- | --- | --- | --- | -| **A** | Cookie 页的记录单位 | ①改为账号(一行一账号)②保留历史行、只是 CK 从账号取 | ①更符合解耦目标;②改动小但仍会混淆 | -| **B** | `login_tasks` 是否保留 `cookie` 作快照 | ①保留(审计「这次登录产出什么」)②不留(最干净) | ①:CK 是加密串,约 1.4 万行 ≈ 28MB,可接受,且便于排障 | -| **C** | 清理历史的行为 | ①仍不许删持有 CK 的行(保守)②任意删,CK 不受影响 | ②——这才叫解耦;①就没必要做这个改造了 | -| **D** | 是否顺带把历史做归档/保留策略 | ①加 `automation_runs` 那样的 90 天清理(规划 §11.4)②不做 | ①可顺带解决「任务列表 1.4 万条」的体量问题 | -| **E** | 重登/巡检的调度粒度 | ①账号(推荐)②仍按最近一次登录行 | ①:账号才是状态的载体 | -| ~~F~~ | ~~是否双写~~ | —— | ✅ **已定:不双写**(§6.1),代码一次切换、旧列延后 drop | -| **G** | 每日逻辑备份是否随本次一起做 | ①一起做(drop 列前必须有)②先不做 | ①(§12、C7 前置) | - -## 10. 提交计划(7 个提交) - -拆分的核心约束:**同一份数据的读与写必须在同一个提交里切**。否则出现中间态 —— -比如「写已经写账号、读还在读记录行」,新登录拿到的 CK 在页面上看不见,巡检还会按旧值判定; -这类中间态跑起来不报错,只是**默默用错数据**,比崩溃更难发现。 - -另外三条边界: - -- **迁移单独一个提交**(纯加列 + 回填,无行为变化)—— 单独部署也安全; -- **用户可感知的语义变化各占一个提交**(Cookie 页单位、删除护栏),便于单独验证与回退; -- **drop 旧列隔离到下一次发版**(唯一不可逆动作)。 - -| # | 提交信息 | 改动文件 | 干什么 | 怎么验证 | -| --- | --- | --- | --- | --- | -| **C1** | `feat(db): accounts 增加 CK 与状态列,并从 login_tasks 回填` | `migrations/versions/20261003_0042_account_ck_state.py`(新)
`models.py`
`tests/test_migrations.py` | 加 8 列 + `(account_id, attempt_status)` 索引;分块回填;**自校验不过就抛错** | `pytest tests/test_migrations.py`;本地 dev 库实跑;回填后 `accounts.cookie<>''` == 记录级去重数 | -| **C2** | `refactor(cookie): 斗鱼 CK 本体改由账号承载` | `services/douyu_service.py`(`cookie_holding_filter` / `cookie_operable_filter` / `latest_cookie` / `latest_cookie_login_task`)
`services/login_service.py`(`_write_login_success`、清空、批次行复用)
`routers/cookies.py`(清除/删除 CK 的**写点**)
`services/douyu_runner.py` | **读+写一起切**到 `accounts.cookie`。批次行复用逻辑同步重写(§6.5 连带点,否则静默退化) | 新增 `tests/test_account_ck_storage.py`;改造 `test_login_attempt_isolation.py` | -| **C3** | `refactor(automation): CK 健康状态与重登状态迁到账号,作业按账号调度` | `services/automation/jobs/ck_health.py`
`services/automation/jobs/ck_relogin.py`
`routers/automation.py`
`routers/cookies.py`(检测写点)
`services/login_service.py`(重登后复检写点)
`services/douyu_runner.py` | `ck_check_*` / `relogin_*` 的**读+写+选行**一起切到 `Account`;`automation_runs.detail` 里的 task_id 改 account_id | 改造 `test_automation_ck_health.py` / `test_automation_ck_relogin.py`;新增「**没有历史行的账号也能被巡检/重登**」 | -| **C4** | `refactor(cookies): Cookie 页改为账号级` | `routers/cookies.py`
`routers/accounts.py`
`routers/douyu.py`
`routers/dashboard.py`
`web/frontend/src/pages/CookiePage.tsx`
`api/cookies.ts` / `api/types.ts` | 列表/汇总/重复/导出/检测/删除全部改为以**账号**为单位;`DELETE /cookies/{id}` 入参由 task_id 变 account_id | **风险最高**:前后端同一提交;专门用例覆盖「删除清的是入参那个账号」;上线后手工验证一次 | -| **C5** | `feat(login): 登录历史可自由删除(解除 CK 护栏)` | `routers/login.py`
`web/frontend/src/pages/LoginTasksPage.tsx`
`tests/test_login_tasks_pagination.py` | 去掉「持有 CK 不可删」,**保留**「在途不可删」;前端文案改回 | 验收核心:**删除全部历史后,账号 CK 与健康/重登状态仍在** | -| **C6** | `docs: 解耦落点与验收结果` | 本设计文档、`docs/自动化与资料收集规划.md`、`README.md` | 记录落点、基线对照、遗留项 | —— | -| **C7** | `chore(db): 删除 login_tasks 上已弃用的 CK/状态列`(**下一次发版**) | 迁移 `20261002_0041`、`models.py` | drop `cookie` / `ck_check_*` / `relogin_*`(若决策 B 选「留快照」则保留 `cookie`) | downgrade 可恢复结构;**执行前必须有每日备份**(决策 G) | - -### 10.1 提交顺序的依赖关系 - -``` -C1(加列+回填,无行为变化) - ├─> C2(CK 读写切账号)──┬─> C5(解除删除护栏) - │ └─> C4(Cookie 页账号级)──> C6(文档)──> C7(drop 列) - └─> C3(健康/重登状态 + 作业按账号)──┘ -``` - -- C2 与 C3 **可以并行**(CK 本体 vs 健康状态,互不依赖),但 C3 必须先于 C4(Cookie 页要展示 `ck_check_status`); -- **每个提交都能单独部署**:C1 部署后行为不变;C2 之后旧列虽不写但仍可回退; -- 每一步 pre-push 跑全量 pytest,迁移保持幂等,重复部署无害。 - -### 10.2 每个提交的「不能省」 - -| 提交 | 不能省的东西 | -| --- | --- | -| C1 | 自校验 + 分块回填 + 幂等;`HEAD_REVISION` 同步 | -| C2 | 读与写同提交;批次行复用逻辑一起改;`cookie_operable_filter` 的在途子查询 + 新索引 | -| C3 | 选行改账号后,「无历史行」的账号也要能被覆盖(否则等于漏掉一批账号) | -| C4 | 前后端同提交;删除接口的入参语义单独用例 | -| C5 | 在途护栏**保留**(语义从「保护 CK」变成「保护正在进行的尝试」) | -| C7 | 每日备份已就绪(不可逆) | - -## 11. 工作量与风险 - -- 涉及:1 个迁移、`models.py`、4 个 service、7 个 router、2 个自动化作业、前端 3 个页面 + 类型、约 10 个测试文件; -- 粗估 **2~2.5 人日**(去掉双写后比原估的 2~3 天更少:少了双写与后续停写两轮改动); -- **最大风险是 Cookie 页**:它的接口语义从「记录」变成「账号」,前后端要一起改, - 且 `DELETE /cookies/{id}` 的入参含义变了 —— **改错会变成「点删除清错账号的 CK」,比这次事故更糟**。 - 这一处要单独写用例(见 §8 最后一条),并在上线后手工验证一次删除清的是同一个账号; -- 生产上自动化在跑(5 分钟一轮,无真正空窗),迁移只动 `accounts` 的 1.4 万行且分块执行,影响可控; - 但**回填 SELECT 与切换之间存在的竞态**要注意:迁移在应用启动时执行,此时旧容器已停止 - (compose 会先停后起),所以窗口极小;回填按 `finished_at` 取最新并写成幂等,必要时可重跑自愈。 - -## 12. 与「备份」的关系 - -这次改造本身是安全的(旧列保留、可反向回填),但**最后的 drop 列是不可逆的**。 -叠加 2026-10-03 的事故(当时唯一退路是 binlog),建议把**每日逻辑备份**作为这个改造的 -前置项一起做掉 —— 否则「不可逆的一步」仍然只靠 30 天 binlog 兜底。 - ---- - -## 13. 账号「独立」的目标形态:全生命周期信息模型 - -> 本节回答的是比「解耦」更大的问题:**一个斗鱼账号,应该自己承载哪些信息?** -> 解耦只是让第 ② 类信息回到账号上;要「账号管理单独出来」,还得把 ③④ 补上。 - -### 13.1 现状:账号的事实散在 5 张表、7 个页面 - -| # | 信息类别 | 今天存在哪 | 今天在哪看 | -| --- | --- | --- | --- | -| ① | 身份与凭据(用户名/密码/邮箱/邮箱密码) | `accounts` ✅ | 账号管理 | -| ② | **凭证与健康(CK / 巡检 / 重登)** | **`login_tasks`** ❌ | Cookie 管理、CK 检测与重登、自动巡检 | -| ③ | **生命周期状态(现在是「没有」)** | —— ❌ | —— | -| ④ | 归属与运营(分配/标签/备注/自动化开关) | `accounts` ✅ | 账号管理、分配管理 | -| ⑤ | 平台画像(uid/昵称/积分/鱼翅/游戏角色…) | `accounts` ✅ | 散在精英宝典/电竞手册/和平小店 | -| ⑥ | 业务流水(充值/兑换/积分/开通) | `douyu_tasks`+`account_events` ✅ | 各业务页、账号详情页 | -| ⑦ | 人工操作审计 | `audit_logs`(**关联不到账号**)⚠️ | 审计日志页 | - -**「账号管理没有单独出来」的三种具体表现:** - -1. **数据层**:`accounts` 表**没有 `cookie` 列**,账号的登录态寄存在某条登录记录的 `cookie` 字段上。 - 删除登录记录 = 删除账号的登录态(事故根因); -2. **接口层**:`AccountOut` 只暴露 `id/username/tag/remark/assigned_to/created_at`(+敏感字段), - **零运营状态** —— 账号列表接口拿不到 CK、健康、积分、开通状态; -3. **UI 层**:账号管理页只有「ID/用户名/标签/上传时间/密码/邮箱/分配/删除」8 列。 - 要知道这个账号能不能用,得跳去 Cookie 管理页;要看积分/开通,得跳去业务页。 - -### 13.2 参照物:虎牙账号已经是「独立」的 - -`huya_accounts` 把凭据与状态**全部挂在账号上**,因此虎牙账号页一页就能看清: -`has_cookie` / `cookie` / `cookie_preview` / `status` / `login_channel` / -`points` / `game_*` / `sms_url` / `assigned_to` / `updated_at`。 - -斗鱼与之相比:**平台画像比虎牙更全**(多了精英宝典、电竞手册、和平小店、鱼翅/兑换余额), -但**凭证与状态几乎为零**。对齐虎牙即可,不需要发明新模型。 - -### 13.3 目标:账号承载的四层信息 - -``` -┌ ① 身份凭据 ────────────────────────────────────────────┐ -│ username / password / email / email_password / tag / remark │ -└────────────────────────────────────────────────────────┘ -┌ ② 登录态(本次解耦)────────────────────────────────────┐ -│ cookie / cookie_updated_at │ -│ ck_check_status / ck_check_result / ck_checked_at │ -│ relogin_attempts / relogin_next_at / relogin_last_error / │ -│ relogin_failure_kind │ -└────────────────────────────────────────────────────────┘ -┌ ③ 生命周期状态(新增)──────────────────────────────────┐ -│ status: imported → active → ck_invalid → relogin → manual │ -│ → disabled / archived │ -│ 派生视图(不落库):能不能跑业务 = 有 CK 且健康且未被禁用 │ -└────────────────────────────────────────────────────────┘ -┌ ④ 平台画像 + 运营 ──────────────────────────────────────┐ -│ uid / nickname / points / gold / exchange / bind / │ -│ elite_* / esports_* / xpd_* / assigned_to / auto_managed │ -└────────────────────────────────────────────────────────┘ - ↓ 流水(append-only,已经做好) - account_events(业务+状态变更+人工操作)+ login_tasks(登录尝试历史) -``` - -### 13.4 生命周期阶段与缺口 - -| 阶段 | 现状 | 缺口 | -| --- | --- | --- | -| 导入 | `accounts.created_at` | 无导入批次、无导入人(只有一句审计文案「导入103个」) | -| 待登录 | **无状态**,靠「有没有持 CK 的记录」反推 | ③ `status` 缺失 | -| 登录成功 | CK 写进 `login_tasks.cookie` | ② 不在账号上 | -| 已分配 | `accounts.assigned_to` ✅ | —— | -| 业务执行 | 画像字段 ✅ + `douyu_tasks` | 画像字段在账号页看不到(接口未暴露) | -| CK 失效 | `login_tasks.ck_check_status` | ② 不在账号上 | -| 自动重登 | `login_tasks.relogin_*` | ② 不在账号上 | -| 转人工 | `login_tasks.relogin_failure_kind` | ② 不在账号上;人工队列页面以「记录」为单位 | -| **停用 / 归档** | **只有 `auto_managed`(自动化后门)** | ③ 没有停用/归档语义,删除是硬删(连带删任务与事件) | -| 删除 | 硬删 + 连带清 `login_tasks`/`douyu_tasks`/`account_events` | 无软删除,历史不可追溯 | - -### 13.5 建议的落地顺序(与 §10 的 C1~C7 对齐) - -解耦(C1~C5)是**前置**:凭证状态不回到账号上,后面的「状态机」「账号页」都是空中楼阁。 - -> ⚠️ **阶段编号以 [设计-账号全生命周期](设计-账号全生命周期.md) §6 为准(S1~S8)。** -> 本文早期版本在这里另写过一套 S1~S6,两套编号含义不同,已废弃 —— 保留本节只为说明依赖关系。 - -| 依赖关系 | 说明 | -| --- | --- | -| **解耦(本文 C1~C5 = 生命周期设计 S1~S2)** | 一切的前置。不做,账号页展示的 CK 状态仍然来自登录记录,删一条记录页面就变了 | -| **状态收口(S3)** | 依赖解耦;产出「可信的 `status`」 | -| **账号页重构(S6)** | 依赖 S3(要显示状态)+ S5(要显示台账) | -| **清理旧列(S8)** | 最后一步,不可逆,前置每日备份 | - -### 13.6 待确认 - -| # | 问题 | 选项 | -| --- | --- | --- | -| **L1** | ③ 生命周期 `status` 落库还是派生? | ①显式列(好筛选/统计,需维护一致性)②纯派生(无冗余,但查询复杂) | -| **L2** | 停用/归档要不要做(软删除)? | ①做(账号是资产,硬删不可逆)②不做 | -| **L3** | 导入批次要不要做? | ①做(能回答「这批 103 个现在活了几个」)②不做 | -| **L4** | 账号列表页要不要直接显示 CK/健康/积分? | ①显示(信息密度换查询成本,需注意 N+1)②只显示状态徽标 | diff --git a/docs/设计-账号一键查询.md b/docs/设计-账号一键查询.md deleted file mode 100644 index 796ee76..0000000 --- a/docs/设计-账号一键查询.md +++ /dev/null @@ -1,198 +0,0 @@ -# 设计:账号「一键查询」(斗鱼官方实时) - -> 状态:**方案已定,待实现**。所有数据源已用**生产镜像库的真实数据**核实过。 -> 决策:① 两个套装都属于**精英宝典**,`manual_id`/`rid` **用后台配置**; -> ② 交互方式 = **异步任务**(复用现有 runner + WebSocket 推送)。 - ---- - -## 1. 目标 - -账号管理/详情页一个「一键查询」按钮 → 实时向斗鱼拉取该账号的**全部关键信息** → -汇总展示,并**高亮两个高价值套装是否已拥有**。 - ---- - -## 2. 关键实证发现(已核实,别再猜) - -### 2.1 「是否已获得」的依据:**官方兑换记录**(⚠️ 修正过一次) - -**结论:以商城 `storehome` 商品项的 `status == 1`(已达兑换上限)为最高裁判;`exchange_records` 仅作历史展示与下架商品兜底。** - -#### ❌ 历史踩坑与认知误区复盘(2026-10-07 事故修正) - -第一版曾误以为商品列表里的 `count`(库存数)和 `storeStatus`(店铺上架状态)在不同账号间一致, -就武断地认为“商品列表整个对象都是全局的,不暴露账号信息”,因而全盘依赖 `exchange_records(manual_id)`。 - -**这是严重的偷换概念与认知漏洞!** -实测(HAR 抓包与生产 50 账号对比采样)表明: -1. 斗鱼官方的 `exchange_records`(`exchangeList`)接口对早期兑换记录**早已返回空列表(0 条)**,根本不可信; -2. 斗鱼官方真正的账号准入状态就在商城接口 `storehome`(`list_goods`)中: - - **`status == 1`**:官方前端渲染为【已达兑换上限】(该账号限量 1 件已满,已兑换,不可再兑); - - **`status == 0`**:官方前端渲染为【立即兑换】(该账号未兑换,可兑换)。 -生产 50 账号实测中,兔兔白日梦有 23 个账号 `status=0`(可兑换),27 个账号 `status=1`(已达上限)。 - -#### ✅ 正确判定法则(双重承重) - -| 来源 | 条件 | 判定结果 | -| --- | --- | --- | -| 商城商品列表(`storehome`) | `status == 1` | **已获得 / 已达兑换上限(不可再兑)**(最高现场裁判) | -| 商城商品列表(`storehome`) | `status == 0` 且 records 无记录 | **未获得(可兑换)** | -| 官方兑换记录(`exchange_records`) | 存在匹配记录 | **已获得**(用于已下架商品的历史兜底) | -| 两处均查无此商品 | - | `missing = True`(未上架/未查到) | - -#### 名字按子串匹配 - -同一件东西有多个商品名 —— 实测既有「**套装**-兔兔白日梦」也有 -「**破片手榴弹**-兔兔白日梦」。所以按核心词(`兔兔白日梦`)子串匹配, -把整个名字家族都算上。 - -### 2.2 两个套装的确切标识 - -| 核心词 | 出现过的完整商品名 | -| --- | --- | -| 兔兔白日梦 | `套装-兔兔白日梦`(`20260120QYOOB_bag10`,1280 分)、`破片手榴弹-兔兔白日梦` | -| 狂战萌兔 | 实测 607 的列表里是 `套装-狂战萌兔`(库存 2551) | - -两者均带 `eliteLimited = 1` → **属于精英宝典**(与决策① 一致)。 - -> 实现按**核心词**匹配即可,不必写死完整商品名或 `commodityId`; -> 名单做成配置项,以后加更多高价值商品不用改代码。 - -### 2.3 一个易踩的坑 - -`query_exchange_records` 这个任务类型**在生产从未运行过**(0 行)。真正的官方兑换记录 -接口是客户端的 **`exchange_records()`**(`activity_client.py:950`),它**没有被任何任务接线**。 -一键查询要用它。 - ---- - -## 3. 数据源矩阵(客户端方法都已存在) - -| 展示项 | 客户端方法 | 参数来源 | -| --- | --- | --- | -| 鱼翅余额 | `gold_account()` | 无 | -| 钱包兑换余额 | `exchange_balance()` | 无 | -| **积分 + 到期时间** | `query_points(uid=, ctn=)` | `account.uid`;`ctn` 来自 `acf_ccn()` | -| 精英宝典开通状态 | 现有 `query_elite_status` 任务的实现(见 runner) | 后台配置 | -| 电竞手册状态 + 积分 | `esports_user_info(manual_id=)` | 后台配置 | -| **兑换记录(官方)** | **`exchange_records(manual_id=, limit=, offset=)`** | 后台配置 | -| **限兑商品(含两套装是否已拥有)** | **`query_limited_goods(manual_id=, rid=)`** | 后台配置 | -| 小店余额 / 碎片 | `xpd_embed_query(act_alias=, rid=)` | 后台配置 | - -> 「现有 `query_elite_status` 的实现」需要实现时去 runner 里确认它调的是哪个方法 -> (我的检索没直接命中该方法名,可能在 runner 内联了请求)。 - ---- - -## 4. 任务设计 - -**新增任务类型**:`query_account_overview` - -- 走现有 `douyu_tasks` 体系(异步 + WebSocket 进度推送 + 离线可查) -- **只读**:不改任何账号状态、不发业务事件 - (纯查询不是"事实",按既有设计**例行查询不进时间线**,避免刷屏) -- 结果写入 `task.result`,结构见下 - -### 结果结构(建议) - -```jsonc -{ - "queried_at": "2026-10-04T18:00:00Z", - "assets": { - "gold_balance": 38, // 鱼翅 - "exchange_balance": 120, // 钱包兑换余额 - "points": 990, - "points_expire_at": "2026-12-01 00:00:00" // 来自 willExpireTime - }, - "elite": { "opened": true, "expire_time": "..." }, - "esports": { "opened": false, "points": null }, - "xpd": { "balance": null, "fragments": null }, - "high_value_goods": [ // 🎯 重点 - { - "name": "套装-兔兔白日梦", - "commodity_id": "20260120QYOOB_bag10", - "score": 1280, - "owned": true, // 来自官方兑换记录,不是库存字段 - "obtained_names": ["破片手榴弹-兔兔白日梦"], - "in_goods_list": true, // 当前限兑列表里有没有(全局信息) - "count": 96956, // 全局库存,仅展示 - "start_time": "1769153181", - "end_time": "1795967999" - }, - { "name": "套装-狂战萌兔", "...": "同上" } - ], - "limited_goods_total": 87, // 商品总数(便于判断是否拿到了完整列表) - "exchange_records": [ /* 官方兑换记录前 N 条 */ ], - "errors": { "esports": "该账号未绑定电竞手册" } // 单项失败不整体失败 -} -``` - -**关键设计点**: - -1. **单项失败不整体失败** —— 每个查询独立 try/except,失败写进 `errors[项]`, - 其余照常返回。账号没绑定电竞手册是常态,不该让整次查询失败。 -2. **`errors` 必须如实展示** —— 避免"没查到"被误读成"没有"。 -3. `exchange_records` 默认取前 20 条,完整记录量大。 - ---- - -## 5. 实现清单 - -### 后端 - -| 文件 | 改动 | -| --- | --- | -| `web/backend/services/douyu_runner_*.py` | 新增 `query_account_overview` 的 handler(聚合各查询) | -| 任务类型注册处 | 加入 `query_account_overview`(**放在只读/查询类**,不要放进写操作类) | -| `web/backend/routers/douyu.py` | 建任务时允许该类型;payload 只需 `account_id` | -| 后台配置 | 取精英宝典的 `manual_id` / `rid`(复用现有配置项,不新增硬编码) | -| 高价值商品名单 | 加配置项,默认 `["兔兔白日梦", "狂战萌兔"]` | - -### 前端 - -| 文件 | 改动 | -| --- | --- | -| `web/frontend/src/pages/AccountDetailPage.tsx` | 顶部加「一键查询」按钮;结果面板展示资产/开通/绑定/兑换记录;**两个套装单独高亮卡片**(已拥有 / 可兑换 + 积分价 + 活动时间) | -| `web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx` | 新任务类型的展示(结果渲染成上面的面板,而不是原始 JSON) | -| (可选,强烈建议)`AccountsPage.tsx` | 列表加「高价值套装」列/标记,**支持筛选"拥有兔兔白日梦的账号"** —— 这才是"找高价值账号"的真正用法 | - -### 测试 - -| 用例 | 要点 | -| --- | --- | -| 高价值商品判定 | `storeStatus=0` → `owned=true`;`=1` → `false`;**缺该项时不能报 owned** | -| 单项失败隔离 | 电竞查询抛异常 → 其余字段仍返回,且 `errors.esports` 有值 | -| 只读性 | 该任务**不改**任何 `accounts.*` 字段、**不发**任何 `account_events`(用既有的事件计数断言) | -| 结果结构 | `high_value_goods` 一定包含配置里的商品名(即使官方列表里没有 → 标记 `"missing": true`,不能默默消失) | -| 契约 | 用真实返回样本(已存于镜像库)做解析回归,避免字段改名静默失效 | - ---- - -## 6. 实测样本可复现(镜像库) - -```sql --- 已兑换的账号(用于验证判定规则) -SELECT DISTINCT account_id FROM douyu_tasks -WHERE task_type='exchange_goods' AND status='success' - AND result LIKE CONCAT('%', 0xE58594E58594E799BDE697A5E6A2A6, '%') LIMIT 5; --- → 558, 607, 2682, 3094, 3090 - --- 未兑换但跑过商品查询的账号 --- → 15407, 15406, 15401 -``` - -> 注意:SQL 里中文用 `LIKE '%兔兔白日梦%'` 在多层引号里会被 shell 搞坏, -> **要写成 `0x...` 十六进制**(上面的写法)。 - ---- - -## 7. 待确认 / 风险 - -| 项 | 说明 | -| --- | --- | -| `rid`(房间号) | 决策是用后台配置。要确认**同一套配置对所有账号都适用**(房间号通常绑定活动,不随账号变) | -| 狂战萌兔的 `commodityId` | 实现时从官方列表取,不要写死 | -| 「已拥有」的准确措辞 | `storeStatus=0` 也可能是售罄/下架,UI 上应写「不可再兑」并显示 `count` | -| 是否发事件 | 本方案**不发**(纯查询)。若希望"查过"留痕,可单独加一个轻量事件,但要克制 | -| 批量查询 | 用户尚未确认。若要做,形态应是「勾多个账号 → 批量建任务 → 汇总对比表」 | diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index 2fc2504..43fe01e 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -1,583 +1,110 @@ -# 设计:账号为中心的全生命周期模型(斗鱼) +# 斗鱼账号全生命周期 -> 状态:**待评审**(未动代码)|2026-10-04 -> 定位:**斗鱼账号域的全新设计**。本文取代以下文档中关于「账号应该长什么样」的部分, -> 其余文档保留其专业内容: -> - [登录任务与 CK 解耦](设计-登录任务与CK解耦.md) —— 迁移机制、回填校验、回退方案仍然有效 -> - [账号详情页与事件历史](设计-账号详情页与事件历史.md) —— `account_events` 表已实现,本文在其上扩展 -> - [自动化与资料收集规划](自动化与资料收集规划.md) §9 皮肤台账 —— 本文的 `account_events` 是其落地方式 -> -> **不参照虎牙**(虎牙是另一套业务与历史,只有「账号自己承载凭证」这一点值得借鉴)。 +更新时间:2026-11-06 ---- +本文描述当前代码采用的账号模型。历史设计稿认为状态需要另建一套聚合结构,实际落地后 +统一收口在 `accounts`、`account_events` 和 `account_lifecycle` 三处。 -## 1. 为什么推倒重来 +## 核心原则 -### 1.1 三个「状态」都找不到家 +1. **账号是聚合根**:登录态、健康状态、绑定信息和资料快照都归属 `accounts`。 +2. **当前状态存账号,发生过什么存事件流**:`accounts` 可覆盖,`account_events` 只追加。 +3. **状态只允许一个写入口**:`services/account_lifecycle.py` 是 `accounts.status` 的唯一写入模块。 +4. **旁路失败不能带崩主流程**:事件写入使用 SAVEPOINT,失败只记 warning。 +5. **查不到不等于没有**:CK 检测的 `error` 不覆盖上一次有效或失效结论。 -| 状态 | 今天在哪 | 后果 | +## 账号当前状态 + +`accounts.status` 有 6 个值: + +| 值 | 展示 | 含义 | | --- | --- | --- | -| **凭证状态**(CK 本体) | `login_tasks.cookie`(某一条登录记录的字段) | 删一条历史记录 = 账号失去登录态(10-03 事故根因) | -| **健康与重登状态** | `login_tasks.ck_check_*` / `relogin_*` | 状态挂在「碰巧最后一次登录那一行」上;一个账号有两行持 CK 时(实测有)谁的权威不明确 | -| **生命周期状态** | **不存在** | 没有任何字段能回答「这个账号现在是什么状态」,只能靠「有没有持 CK 的记录」反推 | +| `imported` | 未登录 | 没有可用 CK | +| `active` | 正常 | 有 CK,且未判定失效 | +| `invalid` | CK 失效 | 已判定失效,仍允许自动重登 | +| `manual` | 待人工 | 重登退避耗尽、极验/凭证问题或必须绑手机 | +| `disabled` | 已停用 | 人工停用,不参与任何自动化 | +| `archived` | 已归档 | 人工归档,不参与自动化 | -### 1.2 `bind_status` 是个垃圾场(本次推翻的直接证据) +`disabled` / `archived` 是人工态,优先级高于自动推导,不会被巡检结果覆盖。 +解除人工态使用 `set_manual_state(..., "auto")`,随后立即按底层判据重新推导。 -`accounts.bind_status` 一列里塞了 **36 种取值**,把三种完全不同性质的东西混在一起: +### 推导依据 -| 性质 | 取值 | -| --- | --- | -| 真·绑定状态 | `bind_confirmed` / `xpd_bound` / `xpd_not_bound` / `game_not_bound` / `game_queried` | -| 一次性动作的痕迹 | `gold_qr_created` / `points_queried` / `goods_refreshed` / `csrf_token_ready` / `limited_goods_queried` / `elite_status_queried` | -| 业务结果 | `gold_recharged` / `gift_donated` / `goods_exchanged` / `elite_opened` / `esports_opened` | +- `cookie` 是否为空; +- `ck_check_status` 是否为 `invalid`; +- `relogin_failure_kind` 是否属于必须人工处理; +- `relogin_attempts` 与 `relogin_next_at` 是否表示退避耗尽。 -**致命点:它们互相覆盖。** 代码里 `account.bind_status = "elite_status_queried"`(查一次精英宝典状态) -会把之前的 `bind_confirmed` 冲掉 —— 于是「这个账号到底绑没绑」**查不出来**。 -而账号详情页正拿这个字段显示「绑定状态」。 +任何改动上述字段的路径,都必须在同一事务内调用 `sync_status()`。状态未变化时 +`sync_status()` 不写库、不发事件,因此可以安全重复调用。 -同样的问题在 `esports_bind_status`(8 种)与 `xpd_bind_status`(8 种)上重复,且三列取值还互相串 -(`esports_*` 同时出现在 `bind_status` 与 `esports_bind_status` 里)。 +### 自动化开关 -> **结论**:账号的「当前状态」被当成「任务执行日志」在用。这不是加几个字段能修的,是列的语义没有定义。 +- `status=disabled`:账号不可用。 +- `status=archived`:账号已归档。 +- `auto_managed=False`:账号仍可用,但自动巡检和自动重登不得选择它。 -### 1.3 业务记录埋在 687MB 的 JSON 里 +这三个条件语义不同,不能合并成一个布尔字段。 -开通 / 积分 / 兑换 / 充值 这些**账号的业务事实**,今天只存在于 `douyu_tasks.result`(JSON)中: +## CK 状态 -| 问题 | 实测 | -| --- | --- | -| 体量 | `douyu_tasks` **222,679 行 / 687MB**,且每天在写 | -| 噪声占比 | 绝大多数是例行轮询(`query_limited_goods` 6.7 万行、`query_change_bind_time` 3 万行) | -| 结构 | 每条结果 JSON 结构不一(单条兑换 `result` ≈ 8KB),字段路径靠抽样猜 | -| 可用性 | 无法按账号查历史、无法建索引、无法支撑「365 天同皮肤」规则 | -| 寿命 | 一旦给 `douyu_tasks` 做保留策略(规划 §11.4),账号历史跟着消失 | +CK 本体和健康结果都在账号上: -### 1.4 三套东西各说各话 +- `cookie`、`cookie_updated_at`、`cookie_source` +- `ck_check_status`、`ck_check_result`、`ck_checked_at` +- `relogin_attempts`、`relogin_next_at`、`relogin_last_error`、`relogin_failure_kind` -| 场景 | 今天怎么做 | 问题 | -| --- | --- | --- | -| **账号检测** | `/account-check` 页:**粘贴一段文本** → 检测 → 下载 zip | **结果不落库、没有 `account_id`**,与库里账号完全脱节;`AccountCheckInput` 连账号 ID 字段都没有 | -| **CK 检测** | `/cookie-operations`、`ck_health` 作业:按 `login_tasks` 行检测 | 写 `login_tasks.ck_check_*`,与上面那套是两回事 | -| **业务执行** | `/douyu/elite`、`/esports`、`/peace` 工作台 → `douyu_tasks` | 结果散在 JSON 里,账号页看不到 | +所有检测入口统一调用 `on_ck_verdict()`: -同一个账号,三处各有各的「状态」,互不相认。 +1. `valid` / `invalid` 覆盖检测结论,`error` 保留上次结论; +2. 只在“由好变坏”时写一条 `ck_invalid` 事件; +3. 重新推导账号生命周期状态。 ---- +登录成功统一调用 `on_login_success()`:清空重登退避痕迹并重新推导状态。 +清空 CK 统一调用 `on_ck_cleared()`:同时清空健康字段、记录 `ck_cleared` 事件并回到未登录。 -## 2. 设计原则 +## 账号事件流 -1. **账号是聚合根** —— 一切围绕账号发生的事实,都以 `account_id` 归属;不存在「必须借道登录记录才能找到账号状态」的路径。 -2. **当前状态在账号上,发生过什么在流水里** —— 两者严格分离。状态可覆盖,流水只追加,且流水不因状态变化而丢失。 -3. **一列一义** —— 禁止「最后一件事」式的复用列(`bind_status` 的教训)。一列要么是某个确定的当前值,要么是某个确定的历史量。 -4. **结构可以推翻,数据必须无损** —— 可以丢掉旧的设计包袱,但 **CK 一条都不能丢**(实测重登当前不可用,丢了补不回来)。 -5. **历史从权威源来,不从噪声里挖** —— 斗鱼侧有权威记录接口(兑换记录 / 小店购买记录 / 个人资料),补历史优先走它,不要在 687MB 的 JSON 里考古。 +`account_events` 记录账号发生过什么,数据源是业务发生时的直接发射,不再从 +`douyu_tasks.result` 猜字段回填。 ---- +- `lifecycle`:状态变迁; +- `auth`:登录、CK 失效、CK 清除; +- `business`:充值、兑换、积分、到期、开通、鱼翅消耗; +- `ops`:分配、标签、备注、人工操作。 -## 3. 领域模型 +`source_ref` 是可选幂等键。重复查询官方记录或重复发射事件时,唯一键冲突按幂等命中处理, +不回滚调用方事务,也不会刷 WARNING。 -### 3.1 总览 +账号详情页由两部分组成:状态卡读取 `accounts`,时间线读取 `account_events`。 -``` - ┌───────────────────────────────────────────┐ - │ accounts │ ← 聚合根 - │ 身份凭据 · 平台身份 · 生命周期 · 归属运营 │ - │ 登录态(CK/健康/重登)· 当前画像快照 │ - └───────────────┬───────────────────────────┘ - │ account_id - ┌───────────────┬───────────┼────────────┬──────────────────┐ - ↓ ↓ ↓ ↓ ↓ - account_events login_tasks douyu_tasks yyb_recharge_* workbench_accounts - 时间线 + 业务台账 登录尝试历史 执行日志 应用宝充值 工作台归属 - (append-only) (瘦身后) (降级) (已有) (已有) -``` +## 一键查询 -一句话:**账号独自站立;登录记录退化为历史;`douyu_tasks` 退化为执行日志;业务事实进事件流。** +`query_account_overview` 把斗鱼官方数据汇总到以下账号字段: -### 3.2 `accounts`(账号主体) +- `points`、`gold_balance`、`exchange_balance` +- `elite_opened`、`esports_opened`、各作用域绑定状态 +- `overview_snapshot`、`overview_queried_at` +- `high_value_owned` -按语义分四组,避免再出现 `bind_status` 那种无定义的列。 +列表只返回 `high_value_owned` 等小字段,不返回整份快照,避免响应体积膨胀。 +一键查询本身是只读聚合,不直接修改生命周期状态;官方兑换记录会以 +`source=official_sync` 幂等补入事件流。 -**① 身份与凭据(导入时确定,人工维护)** +## 自动化策略 -| 列 | 说明 | -| --- | --- | -| `id` / `username` / `password`(enc) | 主键与登录凭据 | -| `email`(enc) / `email_password`(enc) / `email_imap_server` / `email_imap_port` / `email_imap_ssl` | 取码邮箱配置 | -| `uid` / `nickname` | 斗鱼平台身份(登录/查询后回写) | +- CK 巡检按账号选行,不按登录历史选行。 +- 高价值账号优先巡检,其余按最久未检测排序。 +- 非鉴权失败计入 `error`,失败率过高时自动暂停作业。 +- 资料刷新只处理 `ck_check_status=valid`、允许自动管理且非人工态的账号。 +- “从未查询过”的账号优先于“查询结果已过期”的账号。 -**② 生命周期状态(新增,本文核心)** +## 代码入口 -| 列 | 说明 | -| --- | --- | -| `status` | 见 §3.4 状态机;**有明确取值域**,不再是自由文本 | -| `status_reason` | 进入该状态的原因(失败分型 / 人工备注) | -| `status_changed_at` | 状态最后一次变化时间 | -| `disabled_at` / `archived_at` | 停用 / 归档时间(可空) | - -**③ 登录态(从 `login_tasks` 搬来,见 §3.3)** - -| 列 | 原位置 | -| --- | --- | -| `cookie`(enc) / `cookie_updated_at` / `cookie_source` | `login_tasks.cookie` | -| `ck_status` / `ck_checked_at` / `ck_detail`(JSON) | `login_tasks.ck_check_status/ck_checked_at/ck_check_result` | -| `relogin_attempts` / `relogin_next_at` / `relogin_last_error` / `relogin_failure_kind` | `login_tasks.relogin_*` | - -**④ 归属与运营** - -| 列 | 说明 | -| --- | --- | -| `assigned_to` / `tag` / `remark` | 已有 | -| `auto_managed` | 已有;**语义收窄为「是否参与自动化」**,与 `status=disabled` 分工明确(disabled 是账号不可用,auto_managed=false 是可用但不自动跑) | -| `import_batch_id` / `imported_by` | **新增**:回答「这批 103 个现在活了几个」 | - -**⑤ 当前画像快照(覆盖式,来自斗鱼侧查询)** - -`points` / `gold_balance` / `exchange_balance` / `game_name` / `game_channel` / -`elite_opened` / `elite_expire_time` / `esports_points` / `esports_game_name` / -`esports_game_channel` / `esports_can_change_time` / `xpd_*` - -> 这些是「现在是多少」,保留但**清掉 `bind_status` / `esports_bind_status` / `xpd_bind_status` 三列**, -> 用职责明确的字段替代(对照表见 §3.6)。 - -**⑥ 时间戳**:`created_at`(导入时间)/ `updated_at` - -### 3.3 登录态:为什么不单开一张表 - -`cookie` + 健康 + 重登共 10 列,仍然是 **1 账号 : 1 登录态**。评估过单开 -`account_auth` 表,**否决**,理由: - -1. **本仓库有多个「新开会话 / 多线程」的调用模式**(`ck_relogin` 刻意用新 session 绕开 - REPEATABLE READ 快照)。1:1 关系的延迟加载跨会话是经典 bug 源,而这里出 bug 的代价是**CK**; -2. 1:1 拆表的收益(列表查询不拖大字段)用 **`defer()`** 就能拿到 —— `accounts` 列表接口现在 - 已经在用这个模式(`defer(Account.password)` 等); -3. 切换成本:内联是**纯字段搬家**(`LoginTask.cookie` → `Account.cookie`),12 个文件全部只是改 - 类名与属性名,可逐处静态核对;拆表要额外引入 join 或 relationship,出错面更大。 - -> 若将来真出现「一个账号多端登录态」(web / app 各一份),再抽 `account_auth(account_id, channel)` -> 也不迟 —— 那时是**新增**,不是迁移,成本更低。 - -### 3.4 生命周期状态机 - -> **S3 已落地(2026-10-04)**,实现见 `web/backend/services/account_lifecycle.py`。 -> 落地时把原来的 7 个状态**收敛成 6 个**:砍掉了 `relogin`(理由见下)。 - -``` - 导入 - │ - ▼ - ┌─────────┐ 登录成功 ┌────────┐ 巡检判定失效 ┌─────────┐ - │imported │────────────▶│ active │───────────────▶│ invalid │ - └─────────┘ └────────┘ └────┬────┘ - ▲ ▲ ▲ │ 自动重登(可退避) - │ 清空 CK │ │ 重登成功/人工登录救回 │ - └────────────────────┘ │ ▼ - │ ┌─────────────────┐ - │ │ 极验/凭证问题 │ - │ │ 或重试次数用尽 │ - │ └────────┬────────┘ - │ ▼ - │ ┌─────────┐ - └──────────────────│ manual │ - 人工处理成功 └────┬────┘ - │ 人工停用 / 归档 - ▼ - ┌──────────┐ ┌──────────┐ - │ disabled │◀──────▶│ archived │ - └──────────┘ └──────────┘ - 两者可互换,也可 restore 回自动态 -``` - -| 状态 | 判据(`derive_status`) | 能否跑业务 | 自动化 | -| --- | --- | --- | --- | -| `imported` | 没有 CK | ✗ | 不巡检(没 CK 可检) | -| `active` | 有 CK,且 `ck_check_status != 'invalid'` | ✅ | 参与巡检 | -| `invalid` | 有 CK 但判失效,**还会自动救** | ✗ | 参与重登 | -| `manual` | 判失效且**已放弃自动救**(极验/凭证,或试过却没排下次) | ✗ | **不参与**(避免刷屏) | -| `disabled` | 人工停用(最高优先级,不被推导覆盖) | ✗ | 不参与 | -| `archived` | 人工归档,默认列表不显示(同上) | ✗ | 不参与 | - -**为什么砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`。 -独立成状态就多出一个**必须和 `relogin_next_at` 同步**的字段 —— 正是下面要消灭的 -「双份真相」。想筛「正在退避重试的」直接查那个字段即可。 - -**状态与判据的分工(重要)**: - -- `status` 是**人可读的当前阶段**,落库、可索引筛选、可统计; -- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_check_status != 'invalid'`; -- **写入收口**:状态只由 `account_lifecycle` 写 —— `sync_status()`(按判据推导) - 与 `set_manual_state()`(人工态)。**禁止各处直接赋值 `account.status = ...`**, - 这是 `bind_status` 崩掉的直接教训。 - -> `auto_managed` 保留,与 `disabled` 的分工写进了 UI 与接口注释: -> `disabled` = 账号不可用;`auto_managed=false` = 账号可用,只是不自动跑。 -> **S3 之前 `auto_managed` 是个死开关**(没有任何接口/前端能改它),本次一并接上。 - -### 3.4.1 防漂移:三层防线 - -`status` 落库意味着**两份真相**(底层判据 + 摘要),谁忘了同步页面就撒谎。防线: - -1. **收口**:推导逻辑只有 `derive_status()` 一份纯函数;所有写底层判据的地方 - (`cookie` / `ck_check_status` / `relogin_*`,共 **9 处**)在其后调用 `sync_status()`; -2. **幂等**:状态没变时 `sync_status()` 返回 `False`,**不写库、不发事件** - —— 否则巡检每轮都会给时间线刷一条; -3. **迁移与运行时同规则**:迁移 `0042` 的回填 SQL 与 `derive_status()` 必须一致; - 有一条用例**真跑那段 SQL 与纯函数逐条比对**。分叉了不会报错,只会表现为 - 「迁移刚跑完是 A、下轮巡检又改成 B」。 - -另有 `warn_if_drift()` 供巡检顺手观测 —— 但**真正的修复是补上 `sync_status`,不是硬改**。 - -### 3.5 `account_events`(时间线 + 业务台账) - -**已实现**(迁移 `20261003_0041`,在 `dev`)。本文在其上扩展,让它同时承担 -「开通记录 / 积分记录 / 兑换记录 / 充值记录」—— 因为用户要的本来就是**账号视角的一条时间线**。 - -现有字段:`id` / `account_id` / `event_type` / `occurred_at` / `title` / `amount` / -`balance_after` / `detail`(JSON) / `source` / `source_ref`(唯一,幂等) / `created_at` - -**新增 4 列**: - -| 列 | 用途 | -| --- | --- | -| `category` | 粗分类:`lifecycle`(状态变迁)/ `business`(开通兑换充值)/ `ops`(人工操作)/ `auth`(登录与CK) | -| `subject_type` | 客体类型:`commodity` / `skin` / `order` / `role` / 空 | -| `subject_key` | 客体键:斗鱼 `commodity_id` / 归一后的 `skin_key` / 订单号 | -| `actor_type` / `actor_id` | 谁触发的:`user` / `system` / `public`(收集链接)+ 用户 ID | - -**新增索引**(支撑「365 天同皮肤」与各类台账查询): - -```sql --- 已有:ix_account_events_account_occurred (account_id, occurred_at) --- 已有:ix_account_events_event_type (event_type) --- 已有:ix_account_events_source_ref (source_ref, unique) -CREATE INDEX ix_account_events_ledger - ON account_events (account_id, event_type, subject_key, occurred_at); -``` - -**事件类型表(收敛后的完整取值域)**: - -| category | event_type | 含义 | 发射点 | amount | -| --- | --- | --- | --- | --- | -| lifecycle | `status_changed` | **所有**状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 | `account_lifecycle._apply`(唯一) | — | -| auth | `login_ok` | 登录成功(拿到新 CK) | `login_service._write_login_success` | — | -| auth | `ck_invalid` | CK **由好变坏**(转变才记,见下) | `account_lifecycle.on_ck_verdict` | — | -| auth | `ck_cleared` | CK 被清除(**不可逆**,必须留痕) | `account_lifecycle.on_ck_cleared` | — | -| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | `login_service` / `ck_relogin.settle_results` | — | -| ops | `assigned` | 分配 / 取消分配 | `audit_service.record_account_action`、批量走 `emit_bulk` | — | -| ops | `tag_changed` | 标签变更(detail 带 from/to) | 同上 | — | -| ops | `remark_changed` | 备注变更(detail 带 from/to) | `accounts.set_account_remark`(单个) | — | -| business | `recharge` | 充值到账 | `business_events.record_recharge`(二维码/供应商轮询/**供应商回调** 三条路径,同一 `task_ref` 去重) | + ¥ | -| business | `exchange` | 兑换(含消耗积分) | `record_exchange`:`exchange_goods` 的真兑换分支、`exchange_esports_goods`、`exchange_xpd_goods`、`pay_locked_order` | − 积分/点券 | -| business | `points_credit` | 积分到账(detail 带 `confirmed`) | `record_points_credit`:`donate_elite_gift`、`donate_esports_*` | + 积分 | -| business | `elite_opened` / `esports_opened` | 开通精英宝典 / 电竞手册 | `douyu_runner_manual` | — | -| business | `bind_ok`(detail 带 `rebind`) | 角色绑定 / 换绑 | `record_bind`:`confirm_bind` 的真回查分支、`confirm_esports_bind` | — | - -> **只有一个不落表**:`imported` —— 导入时间直接读 `accounts.created_at`。 -> -> `remark_changed` 曾长期处于「定义了但没有发射点」(因为压根没有改备注的接口), -> 2026-10-04 补上 `PUT /api/accounts/{id}/remark` 后接顺。 -> **教训**:留着一个永远不发的类型,比删掉它更糟 —— 看代码的人会以为它有数据。 -> -> **`ck_invalid` 只认「转变」**(2026-10-04 S4 修正):旧实现每轮巡检只要判定 invalid -> 就发一条,注释写着「避免每轮刷屏」而代码并没有做到 —— 6 小时一轮、失效一周 = -> 28 条一模一样的事件,把时间线淹掉。判定失效是**一次状态转变**,不是每轮读数。 -> 同理 `error`(查不到)不改变结论、也不发事件。 - -**「开通 / 积分 / 兑换记录」怎么呈现**:就是这张表按 `category` 或 `event_type` 过滤的视图, -**不另建三张表**。理由: - -| 要支持的查询 | 统一事件表能否满足 | -| --- | --- | -| 账号详情页时间线(倒序分页) | ✅ 已有索引 | -| 365 天同皮肤判定 | ✅ `(account_id, event_type, subject_key, occurred_at)` | -| 某皮肤被哪些账号兑换过 | ✅ 同索引反查 | -| 累计充值 / 累计消耗积分 | ✅ `SUM(amount)` 按 event_type | -| 某账号开通了哪些手册、何时开通 | ✅ 按 event_type 过滤 | -| 积分明细 | ✅ `points_credit` + `exchange` 组合 | - -> 反过来说:**什么时候才该建独立表?** 当它有「流程」而不只是「事实」时。 -> 规划 §10 的 `exchange_orders`(工单:资料 → 匹配 → 执行 → 归档)**有状态机,保留为独立表**; -> 它执行成功后再往 `account_events` 写一条 `exchange` 事实。**流程归工单,事实归事件流。** - -### 3.5.1 事故复盘:`source_ref` 的 NOT NULL 让一批事件静默消失 - -**现象**:开发库上「状态变迁 / 分配 / 标签 / 备注 / CK 清除」这些事件**一条都没有**, -而带幂等键的事件(业务事实、`ck_invalid`、`relogin_*`)照常写入 —— -时间线看起来「有数据」,只是恰好缺了某一类,**极难发现**。 - -**根因**:`account_events.source_ref` 是**可选**幂等键(模型与迁移文件都写着 -`nullable=True`,`emit()` 对没有天然幂等键的事件就传 `None`)。 -但早期那版迁移(重编号成 `0041` 之前的 `0040`)建的是 NOT NULL, -而我后来只改了**迁移文件**、没处理**已建好的库** —— Alembic 只记版本号, -不会重放已应用过的脚本。开发库就是这么漂移的。 - -**为什么静默**:`emit()` 按设计**吞掉任何异常**(事件是旁路,不能影响主流程), -只记一条 warning;调用方也从不检查它的返回值。所以「写不进去」表现为完全正常。 - -**修法**:迁移 `20261004_0045` 把它改回可空(`MODIFY`,唯一索引保留,幂等)。 -另在 `tests/test_migrations.py` 加了一条**可空性比对**:迁移建出的列不允许 -比模型更严(模型说可空、库里 NOT NULL)。 - -> ⚠️ **这条守卫有边界**:它在**全新库**上跑整条链,所以只能防「**未来**的迁移 -> 把模型可空的列建成 NOT NULL」,**防不了已有库的历史漂移**(那种只能靠迁移本身修)。 -> 已验证:把 `0045` 临时禁用,该用例仍然通过 —— 因为全新链本来就是对的。 -> -> **教训**:改动「迁移文件」时,已经建过表的库**不会自动跟上**。 -> 要么写一条幂等修正迁移,要么确认所有环境都还没应用过。 - -### 3.6 `bind_status` 的拆解(旧列 → 新字段) - -> **S6 已落地(2026-10-04)**,实现见 `web/backend/services/account_binding.py`, -> 迁移 `20261004_0044`。 - -**它烂掉的方式**:一个自由文本列,**6 个文件、29 处写入、20 多种取值**, -而且混了三种语义 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、 -纯查询痕迹(`points_queried`)。因为它是**覆盖式**的,`bind_confirmed` 会被之后 -任意一次查询盖掉,于是「这个账号到底绑没绑」查不出来。 -`esports_bind_status` / `xpd_bind_status` 是同一个病。 - -**收敛成四个值**:`unknown` / `unbound` / `pending` / `bound`, -三列各管一个作用域(**一列一义**): - -| 列 | 作用域 | -| --- | --- | -| `accounts.role_bind_status`(**新增**) | 精英宝典角色绑定 | -| `accounts.esports_bind_status`(收敛) | 电竞手册角色绑定 | -| `accounts.xpd_bind_status`(收敛) | 和平小店角色绑定 | - -**写入收口**:只有 `account_binding.set_bind_status()` 能写; -传旧自由文本直接 `ValueError`(否则垃圾又会被写回去)。 -有一条**源码扫描用例**禁止任何地方再出现自由文本写入。 - -**回填不猜**(§5.5):只映射明确陈述绑定关系的取值 -(`bind_confirmed`/`esports_bound`/`xpd_bound` → `bound`,`*_qr_*`/`*_ready` → `pending`, -`game_not_bound`/`xpd_not_bound` → `unbound`),**其余一律 `unknown`**。 -迁移的 CASE 与 Python 映射有一致性用例(解析迁移 SQL 源码比对)。 - -**动作与查询痕迹一律不再写状态列**:动作已由 S5 的直发事件记录, -查询痕迹本来就不该是状态。 - -#### S6 顺带救回一个差点丢掉的状态 - -「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened` -取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown` -—— 于是迁移里必须先 `save_esports_opened()` 把它救进新增的 `accounts.esports_opened` -列,**再**归一化。顺序用例钉住了这一点(`upgrade()` 里 `save_esports_opened` -必须早于 `normalise_esports_bind_status`)。 - -> `accounts.bind_status` 列**保留原值不动**(便于人工比对),但已无任何写入方, -> 计划 S8 一并 drop。`role_bind_status`/`esports_bind_status`/`xpd_bind_status` -> 三列收窄成 `String(16)` + `server_default='unknown'`,与模型一致; -> ⚠️ 收窄**必须放在归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。 - -#### 前端不再从任务日志反推账号状态 - -工作台原来从**当前加载的任务列表**里挑每个账号最新的一条来推绑定状态 -(`latestConfirmBindTaskByAccount` 等)—— 只看得见这一页的任务,支撑任务一旦翻出 -窗口显示的状态就是**错的**;而且这让任务日志变成了「账号状态库」,于是它永远不敢清理。 - -现在**状态读账号字段**,任务日志只用来补「扫码流程中的瞬时信息」 -(如扫码后、确认前的 `pending_role_name`)。 - -### 3.7 `login_tasks` 瘦身:退化为登录尝试历史 - -**保留**(这是它的本职): -`id` / `batch_id` / `account_id` / `status` / `message` / `created_by` / `created_at` / `finished_at` / -`attempt_status` / `attempt_message` / `attempt_batch_id` / `attempt_at` - -**迁走后 drop**(下一次发版): -`cookie` / `ck_check_status` / `ck_check_result` / `ck_checked_at` / -`relogin_attempts` / `relogin_next_at` / `relogin_last_error` / `relogin_failure_kind` - -**不变量**: -- 删除任意登录历史行,**不影响账号的 CK、健康、重登进度与生命周期状态**; -- 「在途尝试不可删」的护栏保留(批次线程正拿着那一行); -- 批次行复用逻辑(`login_service.py:599-632`)重写为「同一账号不并发两行」,不再关心「哪一行持有 CK」。 - -### 3.8 `douyu_tasks` 降级:执行日志,不再是账号历史来源 - -> **S5 已落地(2026-10-04)**。职责收窄为**执行层**:二维码、轮询、锁单、支付、 -> 结果回传、WebSocket 推送。业务事实由业务代码在发生那一刻**直接发事件** -> (`services/business_events.py` 的命名构造函数),不再事后从 JSON 里挖。 - -**收集器已删除**(`jobs/account_events_collect.py` 连同 `_task_to_event`、 -`COLLECTED_TASK_TYPES`、水位线一起删掉,迁移 `20261004_0043` 清掉作业行)。 -它必然漏数据:靠 allowlist 认业务类型,新增类型没人想起维护就**静默丢** -(电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此从来没进过时间线)。 - -**保留策略**(`jobs/douyu_tasks_retention.py`,默认**关闭**,由运维打开): - -| 档 | 判定 | 保留 | -| --- | --- | --- | -| 噪声查询 | 只读看一眼就走,且前端**不**拿它派生账号状态 | **7 天** | -| 其余一切(业务事实 + 状态派生依赖的查询 + **以后新增的类型**) | — | **90 天** | - -> 长保留档用 `task_type NOT IN (噪声名单)` 表达,于是**新增任务类型默认长保留**, -> 不会因为「忘了加进某个列表」被误删 —— 正是收集器当年翻车的反面。 -> -> ⚠️ **「状态派生依赖类的查询」不能按 7 天删**:工作台是从任务列表**反推**账号 -> 当前状态的(`DouyuTasksPage` 的 `latest*ByAccount`,且要求 `task.result` 非空)。 -> 这是历史包袱(与 `bind_status` 同源:账号的「现在是什么」存在执行日志里), -> §3.6 的状态列拆解才是根治;在拆解完成前,这类查询保守留长。 -> -> **S6 之后**:前端已不再用任务日志推账号状态,因此「状态派生依赖」那几类查询 -> **理论上可以下探到 7 天档**(只剩扫码流程的瞬时提示会看不到)。这一步保守起见 -> 留待确认后再做 —— 它影响的是在用页面的显示,不是历史。 -> -> `account_events` 独立长期保留(体量小:每天数百条,一年 10 万条以内)。 -> 注:`DELETE` 只让 InnoDB 的空间**可复用**,文件不会自动缩 —— 要缩需在维护窗口 -> `OPTIMIZE TABLE`。 - -## 4. 数据源矩阵:记录从哪来 - -| 记录 | 主源(权威) | 补充源 | -| --- | --- | --- | -| 导入 | 导入动作本身 | — | -| 登录 / CK / 失效 / 重登 | 登录批次、`ck_health`、`ck_relogin` 直接发事件 | — | -| 开通(精英宝典 / 电竞手册) | 开通任务成功时发事件(`run` 内直发) | 斗鱼侧状态查询可校正当前值 | -| 充值鱼翅 | **任务成功或供应商回调**时发事件(三条路径同一 `task_ref` 去重) | — | -| 积分到账 / 兑换 | 任务成功时发事件(**电竞与小店兑换、锁单支付也在内**) | **斗鱼侧权威接口**:`query_exchange_records`、`query_xpd_purchase_records`、`query_points` | -| 角色绑定 / 换绑 | 确认接口成功 + 回查生效时发事件 | — | -| 人工操作(分配 / 标签 / 备注 / 停用) | 审计日志 + 直接发事件(`audit_logs.account_id` 已打通) | — | - -> **历史怎么办**:**不从 `douyu_tasks` 回填**(噪声大、结构靠猜)。 -> 需要历史时走**斗鱼侧权威接口按需同步**(如 `query_exchange_records`), -> 这是「抛弃旧包袱」的落地方式 —— 宁可少,不可假。 - ---- - -## 5. 迁移方案(无损 · 幂等 · 自校验) - -编号 **`20261003_0042`**(按规划 §C.1 取号规则)。 - -### 5.1 步骤 - -1. `accounts` 加列:生命周期 6 列 + 登录态 10 列 + 运营 2 列 + 明确的状态列; -2. `account_events` 加 4 列 + 1 个复合索引; -3. **回填登录态**(§5.2); -4. **回填生命周期状态**(§5.3); -5. `login_tasks` 加 `(account_id, attempt_status)` 索引(在途判定要用); -6. 旧列**本次不动**,下一次发版再 drop。 - -### 5.2 回填登录态(CK 绝不允许丢) - -对每个账号,取其 `login_tasks` 里**最近一条持有 CK 的行**,搬 `cookie` 与状态: - -```sql -UPDATE accounts a -JOIN ( - SELECT account_id, cookie, finished_at, ck_check_status, ck_check_result, ck_checked_at, - relogin_attempts, relogin_next_at, relogin_last_error, relogin_failure_kind, - ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) AS rn - FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> '' -) src ON src.account_id = a.id AND src.rn = 1 -SET a.cookie = src.cookie, a.cookie_updated_at = src.finished_at, … -WHERE a.cookie IS NULL OR a.cookie = ''; -- 幂等,可重跑 -``` - -要点(沿用 CK 解耦设计 §4.2,那部分经复核仍然有效): - -- 窗口函数取最近一行,**不要** `GROUP_CONCAT(... ORDER BY id DESC)`(受 `group_concat_max_len` 限制会静默截断); -- **分块执行**(先 SELECT 出 `(account_id, last_id)`,按 200 一批更新),1.4 万账号 × 单账号约 1.7KB,一次性 UPDATE 会长时间持锁; -- **只读 `login_tasks`、不删不改源数据** —— 这是回退的安全网。 - -### 5.3 回填生命周期状态 - -```sql --- 有 CK 且未判失效 → active;有 CK 但判失效 → 按重登进度分流;无 CK → imported -UPDATE accounts SET status = CASE - WHEN cookie IS NULL OR cookie = '' THEN 'imported' - WHEN ck_status = 'invalid' AND relogin_failure_kind IN ('captcha','credential') THEN 'manual' - WHEN ck_status = 'invalid' AND relogin_next_at IS NOT NULL THEN 'relogin' - WHEN ck_status = 'invalid' THEN 'invalid' - ELSE 'active' -END; -``` - -### 5.4 自校验(不通过就中止启动,fail closed) - -```python -expected = SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie <> '' -actual = SELECT COUNT(*) FROM accounts WHERE cookie <> '' -if actual < expected: - raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移") -``` - -外加**值级校验**(防张冠李戴): - -```sql --- 必须为 0:账号的 CK 与其「最近一条持 CK 记录」不一致 -SELECT COUNT(*) FROM accounts a -JOIN (SELECT account_id, cookie, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) rn - FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> '') src - ON src.account_id = a.id AND src.rn = 1 -WHERE a.cookie <> src.cookie; -``` - -> `expected` 必须**运行时算**(这个数在长:实测 13264 → 13339,因为重登在跑)。 - -### 5.5 `bind_status` 的处理(不回填进状态列) - -旧的 `bind_status` 是覆盖式垃圾场,**无法可靠还原「当时到底绑没绑」**。 -处理:把三列(`bind_status` / `esports_bind_status` / `xpd_bind_status`)的**当前值**作为 -**「未知」导入**,随后由第一次查询/任务校正。**不做任何猜测性回填。** -旧的原始值整列保留在旧列里(下一次发版一起 drop),便于人工比对。 - ---- - -## 6. 落地阶段 - -| 阶段 | 内容 | 产出 | 依赖 | -| --- | --- | --- | --- | -| **S1** | 迁移 0042:加列 + 回填 + 自校验;`accounts` 成为状态载体 | 账号能独立描述登录态 | — | -| **S2** | 读写一次切换:12 个文件、24 读点 / 10 写点全部改 `LoginTask.*` → `Account.*`;批次行复用逻辑重写;在途子查询 + 索引 | 登录记录与 CK 彻底解耦 | S1 | -| **S3** ✅ | 状态收口(**已完成 2026-10-04**):`account_lifecycle` 提供 `derive_status()` / `sync_status()` / `set_manual_state()` / `on_login_success()`;9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 `disabled`/`archived`;新增 `account:manage` 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 | `status` 可信 | S2 | -| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选;补上改备注接口 `PUT /api/accounts/{id}/remark`(`remark_changed` 从此有发射点)| 时间线完整 | S2 | -| **S5** ✅ | 业务事实直发事件(**已完成 2026-10-04**):新增 `services/business_events.py` 命名构造函数(`record_recharge` / `record_exchange` / `record_points_credit` / `record_bind`);9 处成功点直发(含**此前从未收录**的电竞兑换、和平小店兑换、锁单支付、电竞礼物积分);修掉「到账判据 `!=`」与「回调晚于超时」两个洞;幂等键 `douyu_task:{id}` 让直发与旧收集器/回调互相去重;**删除收集器**(含映射、水位线、测试,迁移 `0043` 清作业行);新增 `douyu_tasks` 保留策略作业(噪声 7 天 / 其余 90 天,默认关闭) | 台账可用,687MB 可控 | S4 | -| **S6** ✅ | 绑定三列拆解 + 账号页重构(**已完成 2026-10-04**):①`services/account_binding.py` + 迁移 `0044`(新增 `role_bind_status` 与 `esports_opened`、归一化三列、收窄列宽、可回退),删除全部 29 处自由文本写入;②前端不再从任务日志反推账号状态,改为读账号字段;③**账号列表**加关键列(CK 健康 + 检测时间、精/电/店绑定、积分/鱼翅)与三个筛选(状态 / CK / 绑定);④**详情页做成生命周期主页**:顶部状态卡(生命周期状态 + 原因 + 变更时间 + CK + 三角色绑定 + 积分/鱼翅/兑换余额 + 归属与标注操作)、业务台账(补登录次数/最近登录)、分类时间线、生命周期操作入口。`bind_status` 旧列待 S8 drop | 账号管理真正独立 | S3、S5 | -| **S7** | 账号检测并入账号域(`account-check` 从「粘贴文本」改为「按账号选」,结果落事件) | 检测不再是孤岛 | S6 | -| **S8** | 清理:drop `login_tasks` 旧列与 `accounts` 三个 `*_bind_status` 列(**不可逆,前置:每日备份 + 反向回填脚本**) | 包袱丢干净 | S6 | - -**关键路径**:S1 → S2 是一切的前置。S4/S5 可以并行推进(事件流不依赖状态切换完成), -但 S6(账号页)必须等 S3 + S5。 - ---- - -## 7. 风险与回退 - -| 风险 | 等级 | 应对 | -| --- | --- | --- | -| **回填丢 CK** | 🔴 最高 | 迁移只读不删、分块、自校验 fail closed、值级校验;**重登当前不可用,丢了补不回来** | -| Cookie 页语义从「记录」变「账号」,`DELETE /cookies/{id}` 入参含义改变 | 🔴 高 | 前后端同提交;专门用例覆盖「删的是入参那个账号」;上线后手工验证一次 | -| 状态列被绕过直接赋值(重蹈 `bind_status`) | 🟠 中 | 唯一写入口 + 静态检查(grep `\.status = `)+ 用例 | -| 12 个文件的切换漏一处 → 静默用错数据 | 🟠 中 | 按 §0.2(CK 解耦设计)的清单逐项核对;每处都有对应用例 | -| 事件表被当成「什么都能塞」 | 🟡 低 | `event_type` 收敛为受控取值域;`subject_key` 只放可检索的键 | - -**回退**:S1 之后每一步都可回退 —— 旧列保留、`login_tasks.cookie` 始终有一份, -最坏情况是回退代码 + 跑一次反向回填(把 `accounts` 的 CK 写回 `login_tasks`)。 -**唯一不可逆的是 S8 的 drop**,所以它单独放到最后,且前置每日逻辑备份。 - ---- - -## 8. 待确认 - -| # | 问题 | 选项 | 倾向 | -| --- | --- | --- | --- | -| **A** | 登录态内联 `accounts` 还是独立 `account_auth` 表 | ①内联 ②独立表 | **①**(§3.3:多会话/懒加载风险 + 切换面最小) | -| **B** | 生命周期 `status` 落库还是派生 | ①落库 + 唯一写入口 ②纯派生 | **①**(可筛选统计;写入口收口后一致性可控) | -| **C** | 开通/积分/兑换记录用统一事件表还是各建表 | ①统一 `account_events` ②三张专表 | **①**(§3.5;有流程的工单才独立建表) | -| **D** | 历史业务记录是否回填 | ①不回填,需要时走斗鱼权威接口同步 ②从 `douyu_tasks` 挖 | **①**(噪声大、结构靠猜,宁可少不可假) | -| **E** | 导入批次是否本轮做 | ①做(回答「这批活了几个」)②往后放 | ②(不阻塞主链路) | -| **F** | `account-check` 页改为按账号选 | ①S7 一起做 ②保留粘贴模式 | ①(否则检测永远是孤岛) | -| **G** | drop 旧列前是否已有每日备份 | ①已就绪 ②需要先做 | **②必须先做**(S8 前置) | - ---- - -## 9. 本文与既有文档的关系 - -| 文档 | 关系 | -| --- | --- | -| [设计-登录任务与CK解耦](设计-登录任务与CK解耦.md) | **迁移机制仍然有效**:回填 SQL、分块、自校验、回退方案、§0 耦合点清单照用。本文覆盖其「目标模型」部分(把登录态放 `accounts` 而非新表,并补生命周期状态) | -| [设计-账号详情页与事件历史](设计-账号详情页与事件历史.md) | `account_events` 已实现(迁移 `20261003_0041`,在 `dev`),本文在其上加 `category` / `subject_*` / `actor_*` 与台账索引 | -| [自动化与资料收集规划](自动化与资料收集规划.md) §9 | 其 `account_exchange_records` **由本文的 `account_events` 取代**(`subject_key` 承载 `skin_key`,索引支撑 365 天规则)。§10 的 `exchange_orders` 工单表保留 | -| [事故复盘-CK误删与恢复](事故复盘-CK误删与恢复.md) | 本次设计的动因;其「护栏」在 S2 后从「保护 CK」变为「保护在途尝试」 | +- `web/backend/models.py`:`Account`、`AccountEvent` +- `web/backend/services/account_lifecycle.py`:状态推导与唯一写入口 +- `web/backend/services/account_events_service.py`:事件写入与幂等 +- `web/backend/services/automation/jobs/`:巡检、重登和资料刷新 +- `tests/test_account_lifecycle.py`:状态真值表、漂移检查和写入点约束 +- `tests/test_account_overview.py`:一键查询、字段落库和高价值资产判定 diff --git a/docs/设计-账号详情页与事件历史.md b/docs/设计-账号详情页与事件历史.md deleted file mode 100644 index 037f6e9..0000000 --- a/docs/设计-账号详情页与事件历史.md +++ /dev/null @@ -1,416 +0,0 @@ -# 设计:账号详情页与账号事件历史 - -> 状态:**后端已完成、前端已完成,本地验证通过**(2026-10-03) -> 已提交在 `dev`(`834bfdd`);迁移编号经合并后为 **`20261003_0041`**(原 `20261002_0040`, -> 因生产索引优化先落地占了 `20261003_0040`)。**尚未合入 `main`、未上生产。** -> 关联:[登录任务与 CK 解耦](设计-登录任务与CK解耦.md) —— 两者的写入点高度重合,建议同一工作流推进(见 §8)。 -> -> **范围(2026-10-03 已定)**:**不做历史回填**。既有数据(`douyu_tasks` 里的兑换/充值/积分记录) -> **不导入**事件表,事件**从上线那一刻起往后累积**。账号的历史明细仍可去既有的任务页查。 -> 这样本需求从「一次数据搬运工程」缩小为「建表 + 几处发事件 + 一个页面」。 - -## 1. 需求 - -要一个「账号相关」的界面,围绕**单个账号**把下面这些都记下来、看得见: - -| # | 需求项 | 用户原话 | -| --- | --- | --- | -| 1 | 导入时间 | 「这个账号 导入时间」 | -| 2 | CK 登录记录 | 「ck登录记录」 | -| 3 | **CK 失效时间** | 「失效时间」 | -| 4 | 兑换记录 | 「兑换记录」 | -| 5 | 积分多少 | 「积分多少」 | -| 6 | 消耗记录 | 「消耗记录」 | -| 7 | 充值记录 | 「充值记录」 | -| 8 | **精英宝典开通记录** | 「精英宝典开通记录 时间等等」 | -| 9 | **电竞手册开通时间** | 「还有电竞手册开通时间 啥的」 | -| 10 | 兜底原则 | 「只要跟这个账号有关系的 都记录下来」 | - -## 2. 现状盘点:每条需求今天有没有数据 - -上机实测(2026-10-03)—— 结论是**大部分业务数据已经存在**,但两点不足: -**散在 687MB 的噪声表里**、以及**状态变更没有历史**(覆盖式)。本设计**不回填**这些历史, -只保证「从现在起不再丢」。 - -| # | 需求 | 今天的数据在哪 | 本设计怎么处理 | -| --- | --- | --- | --- | -| 1 | 导入时间 | `accounts.created_at` | ✅ 直接读,无需事件 | -| 2 | CK 登录记录 | `login_tasks`;解耦后状态在 `accounts` | ✅ 读现有 + 新事件补「失效/重登」 | -| 3 | **CK 失效时间** | 只有 `ck_check_status`+`ck_checked_at`,**每次检测覆盖** | 🆕 **新增事件**(覆盖前记一笔) | -| 4 | 兑换记录 | `douyu_tasks` `exchange_goods`(10,009 行) | 🔸 **不回填**;上线后用收集器增量提取 | -| 5 | 积分多少 | `accounts.points`(当前值) | ✅ 状态卡直接读 | -| 6 | 消耗记录 | 兑换任务的 `result.score` | 🔸 同 #4(增量) | -| 7 | 充值记录 | `douyu_tasks` `create_gold_qr`(13,867 行) | 🔸 同 #4(增量) | -| 8 | **精英宝典开通** | 只有 `elite_opened`(bool),**覆盖式** | 🆕 **新增事件** | -| 9 | **电竞手册开通** | 只有 `esports_bind_status` 字符串,**覆盖式** | 🆕 **新增事件** | -| 10 | 兜底(人工操作) | `audit_logs` | ⚠️ **关联不到账号**,需补 `account_id`(§7) | - -> 🔸 = 既有数据不回填,从上线起增量收录。账号的历史明细仍可在既有的 -> `/douyu/elite`、`/douyu/esports`、`/login-tasks` 等页面查到,**本需求不复制它们的历史**。 - -### 2.1 为什么用事件表、而不是详情页实时聚合 - -`douyu_tasks` 现状 **222,679 行 / 687MB**,每天在写。若详情页实时聚合: - -- 每次开页要扫大表 + 解析巨型 JSON(单条兑换的 `result` ≈ 8KB); -- 噪声占绝大多数(`query_limited_goods` 6.7 万行、`query_change_bind_time` 3 万行是例行轮询); -- `douyu_tasks` 将来若做保留策略(规划 §11.4),账号历史会跟着消失。 - -所以事件表**精简、独立、可长期保留**。反过来说:**不回填也就不必去核对历史 JSON 的字段路径**, -风险与工作量都大幅下降。 - -## 3. 目标模型:`account_events`(账号事件流) - -一张 append-only 的事件表,**账号的时间线 = 这张表按时间倒序读**。 - -```python -class AccountEvent(Base): - __tablename__ = "account_events" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), index=True) - event_type: Mapped[str] = mapped_column(String(32), index=True) - occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True) - title: Mapped[str] = mapped_column(String(256), default="") # 直接可展示 - amount: Mapped[int | None] = mapped_column( - Integer, nullable=True - ) # 充值额/消耗积分(有符号) - balance_after: Mapped[int | None] = mapped_column( - Integer, nullable=True - ) # 变更后余额 - detail: Mapped[dict | None] = mapped_column( - JSON, nullable=True - ) # 精简后的细节(非原始大 JSON) - source: Mapped[str] = mapped_column( - String(24), default="" - ) # login/task/audit/manual - source_ref: Mapped[str] = mapped_column(String(96), unique=True) # 幂等键 - created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) -``` - -设计要点: - -- **`source_ref` 唯一键**:如 `douyu_task:123456`、`login_task:14166`、`audit:789`。 - 作用是**幂等**:收集器重跑、事件重复发射都不会造出重复时间线(§4 的水位线失守时靠它兜底); -- **不存原始大 JSON**:只存提取出的业务字段(商品名、单号、金额…),把 687MB 的包袱甩掉; -- **`amount` 有符号**:充值为正、兑换消耗为负,便于页面上做「累计充值 / 累计消耗」; -- **`occurred_at` 用业务发生时间**(不是入库时间):收集器取原任务行的 `finished_at || created_at`; - 直接发射的取当下时刻。 - -### 3.1 事件类型(初版) - -| event_type | 含义 | 来源 | -| --- | --- | --- | -| `imported` | 账号导入 | `accounts.created_at` | -| `login_ok` | 登录成功(含新 CK) | `login_tasks` / 解耦后的账号写入点 | -| `ck_invalid` | **CK 失效**(新增记录) | 巡检判定 | -| `relogin_ok` / `relogin_failed` | 自动重登结果 | `ck_relogin` 作业 | -| `bind_ok` / `rebind` | 绑定 / 换绑 | `douyu_tasks` | -| `recharge` | 充值到账 | `create_gold_qr` | -| `exchange` | 兑换(**含消耗积分**) | `exchange_goods` | -| `points_credit` | 积分到账(含**到期时间**) | `donate_elite_gift` | -| `points_expire` | 积分即将到期 | 由 `willExpireTime` 派生 | -| `elite_opened` | **精英宝典开通**(新增记录) | `douyu_runner_manual` | -| `esports_opened` | **电竞手册开通**(新增记录) | `douyu_runner_manual` | -| `assigned` / `tag_changed` / `remark_changed` | 人工操作 | `audit_logs`(需补 account_id,见 §7) | - -## 4. 业务事件怎么收录:~~增量收集器~~ → 已改为**直发**(不回填) - -> ## ⚠️ 本节已作废(2026-10-04,S5) -> -> 收集器方案**已放弃并删除代码**。原因(详见 -> [账号全生命周期设计](设计-账号全生命周期.md) §3.8): -> -> 1. **靠 allowlist 认业务类型必然漏**:新增类型没人想起维护名单就静默丢数据 —— -> 电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此**从来没进过时间线**; -> 2. **「任务 success」不等于「业务真的发生了」**:预约到货、已预兑、「检测到本来就 -> 已绑定」、积分未确认到账,都被标成 success,收集器照单全收写成事实; -> 3. **字段路径靠抽样猜**:二维码充值的 `result` 里根本没有金额,收集器因此恒写 -> `amount=None`,`recharge_total` 系统性少计; -> 4. 作业**默认关闭**,生产上它一直没跑。 -> -> 现在业务代码在动作成功那一刻用 `services/business_events.py` 的命名构造函数直发 -> (幂等键 `douyu_task:{id}`,与旧收集器一致,迁移窗口内可互相去重)。 -> -> 下文 §4.1 / §4.2 / §5.2 保留作为**历史记录**:其中的 `result` 字段实测结论仍然 -> 有价值(接入斗鱼侧接口时用得上),但**不要再按它实现收集器**。 - -既有历史**不导入**。业务事件(充值/兑换/积分到账/绑定)~~用**一个增量收集器**从 `douyu_tasks` -往后提取 —— 好处是**完全不侵入 runner 代码**(不必改 8 个业务文件),且新老任务类型统一处理~~。 - -### 4.1 收集器设计 - -``` -水位线(watermark):记录「已收集到 douyu_tasks.id = N」 -每轮(挂到现有 automation 调度器,5~15 分钟一次): - 1. 读 douyu_tasks WHERE id > N AND task_type IN (业务类型) ORDER BY id LIMIT 500 - 2. 按 §4.2 的映射提取字段 → 写 account_events(source_ref = "douyu_task:" 保证幂等) - 3. 更新水位线到本批最大 id -``` - -- 水位线存在 `automation_jobs.config`(JSON)或单独一小张表,**必须持久化**(进程重启不重来); -- `source_ref` 唯一键兜住「同一行被处理两次」(例如水位线写入失败后重跑); -- **起点 = 上线那一刻的 `MAX(douyu_tasks.id)`**,不回溯。 - -### 4.2 字段映射(已抽样核实,仅用于**新**数据) - -已实测的 `result` 结构(用于收集器解析): - -**`exchange_goods` → `exchange`** - -| 事件字段 | JSON 路径 | 实测样例 | -| --- | --- | --- | -| title | `commodity_name` | `套装-兔兔白日梦` | -| detail.commodity_id | `commodity_id` | `20260120QYOOB_bag10` | -| **amount(消耗)** | `score`,取负 | `-1280` | -| **balance_after** | `points_query.points` | `0` | -| detail.order_id / exchange_id | `order_id` / `exchange_id` | `147879` / `11241526` | -| detail.game_name | `game_name` | `唉痛恨透` | -| detail.game_channel | `game_channel` | `手Q / 安卓(android)` | - -**`create_gold_qr` → `recharge`** - -| 事件字段 | JSON 路径 | 实测样例 | -| --- | --- | --- | -| **amount(充值)** | `buy_num` / `pay_amount` | `98` | -| detail.order_id | `order_id` | `DYGF5C24C5E4EA84T238035` | -| detail.supplier_order_id | `supplier_order.order_id` | `200000229425420261003114601` | -| detail.supplier_finished_at | `supplier_order.finish_time` | `2026-10-03 11:46:07` | -| detail.channel | `recharge_channel` | `supplier_api` | -| detail.douyu_uid | `douyu_uid` | `678503799` | - -**`donate_elite_gift` → `points_credit`** - -| 事件字段 | JSON 路径 | 实测样例 | -| --- | --- | --- | -| **amount(到账)** | `gift_points_expected_gain` | `980` | -| **balance_after** | `points` / `points_query.points` | `1280` | -| detail.baseline / target | `gift_points_baseline` / `gift_points_target` | `300` / `1280` | -| **detail.points_expire_at** | `points_query.raw.data.willExpireTime` | `1798819199`(时间戳) | - -> 绑定类(`confirm_bind` / `*_esports_bind`)的 `result` 结构**尚未抽样**,实现前先各 dump 一条 -> (教训见 [CK 误删复盘](事故复盘-CK误删与恢复.md):字段假设必须实测)。收集器对 -> **解析不出的行要记 warning 并跳过**,不能让一行坏数据卡住整条水位线。 - -### 4.3 覆盖的事件量(预估) - -按实测的业务任务量,稳定后每天新增事件量级在**数百条**(充值/兑换/到账为主), -一年约 10 万条以内 —— 事件表长期保留没有压力。 - -## 5. 事件从哪来:两条路 - -### 5.1 直接发(CK 相关 + 开通类)—— 4 处 - -这几类**不是 `douyu_tasks` 里的行**,只能在代码里「状态被覆盖之前先记一笔」: - -| 场景 | 位置 | 事件 | -| --- | --- | --- | -| CK 判定失效 | `automation/jobs/ck_health.py`(判定 invalid 时) | `ck_invalid` | -| CK 重登成功/失败 | `login_service._write_login_success` / `ck_relogin`(**CK 解耦的同一处**) | `login_ok` / `relogin_ok` / `relogin_failed` | -| 精英宝典开通 | `douyu_runner_manual.py:377`(`elite_opened = True` 那一行) | `elite_opened` | -| 电竞手册开通 | `douyu_runner_manual.py:426`(`esports_bind_status = "esports_opened"`) | `esports_opened` | - -这 4 处正好是「当前状态被覆盖」的现场。实现上收敛成一个 helper: -`account_events_service.emit(db, account_id, type, title, amount=..., balance_after=..., detail=...)`, -并要求**发射失败不影响主流程**(事件是旁路,不能因为它让登录/兑换失败)。 - -### 5.2 收集器(业务类)—— 0 处侵入 - -充值 / 兑换 / 积分到账 / 绑定 这些**已经是 `douyu_tasks` 的行**,走 §4 的增量收集器, -**不动 runner 代码**。 - -> 这个分工的取舍:CK 与开通事件「只在内存里出现过」,不记就永远没有; -> 业务事件已经在表里有结构化落库,再改一遍 runner 属于重复工作。 - -## 6. 页面设计:`/accounts/:id`(账号详情) - -现在是**列表页**(`AccountsPage.tsx`),没有详情页。新增: - -``` -┌─ 顶部:账号身份 ────────────────────────────────────────┐ -│ 用户名 / 斗鱼昵称+UID / 标签 / 归属客服 / 导入时间(第 N 天) │ -│ 操作:分配 · 标签 · 备注 · 登录 · 检测 CK · 重登 · 导出 │ -└──────────────────────────────────────────────────────┘ -┌─ 状态卡(当前值:accounts 表)──────────────────────────┐ -│ 积分(含到期时间) · 鱼翅余额 · 兑换余额 │ -│ 精英宝典:已开通 / 到期时间 · 电竞手册:开通+绑定+可换绑 │ -│ CK:状态 · 最近检测 · 最近重登 ← CK 解耦后从 accounts 读 │ -└──────────────────────────────────────────────────────┘ -┌─ 统计条(由 account_events 聚合)──────────────────────┐ -│ 累计充值 ¥X · 累计兑换 N 次 / 消耗积分 M · CK 失效 K 次 │ -└──────────────────────────────────────────────────────┘ -┌─ 时间线(account_events 倒序 + 类型筛选 + 分页)────────┐ -│ 10-03 11:46 充值 ¥98 供应商直充成功 │ -│ 10-03 11:46 积分 +980 → 余额 1280(到期 12-31) │ -│ 10-03 11:46 兑换 -1280 套装-兔兔白日梦 │ -│ 10-02 17:17 登录 重新登录成功,Cookie 有效 │ -│ 10-01 09:12 CK失效 检测判定 CK 已失效 │ -│ 09-30 14:00 开通 精英宝典开通 │ -└──────────────────────────────────────────────────────┘ -``` - -- 接口:`GET /api/accounts/{id}/events?types=&page=&page_size=`、`GET /api/accounts/{id}/overview`; -- 权限:沿用账号可见性(客服只能看自己名下账号)+ 敏感字段按现有 `account:view_full` 控制; -- 入口:账号列表点用户名进详情;从 CK 页、登录任务页也可跳转。 - -## 7. 必须先修的缺口:`audit_logs` 关联不到账号 - -实测 `audit_logs.target` 是**自由文本**: - -| action | 样例 target | -| --- | --- | -| `account:assign` | `冰柳192`(用户名) | -| `account:import` | `导入103个`(一句话) | -| `recharge:douyu:create` | `douyu_batch:000619c494fd`(批次号) | -| `account:delete` | `hui60945`(用户名) | - -**没有 account_id**,只能拿用户名去模糊匹配 —— 用户名可改、可重复,**不可靠**。 - -建议:`audit_logs` 增加 `account_id`(可空)列,账号相关操作写入时带上。 -这样「谁在什么时候分配/改标签/删除了这个账号」才能进账号时间线。 -这是本次需求的前置项,改动很小(加一列 + 若干调用点传参)。 - -## 8. 与 CK 解耦怎么「一块弄」 - -两者的**写入点是同一批**,所以设计上共用一套约定,实施上排好顺序即可: - -| 事项 | CK 解耦 | 账号事件 | -| --- | --- | --- | -| 状态落点 | CK/健康/重登状态 → `accounts` | 新增 `account_events`(流水) | -| 写入点 | `_write_login_success`、`ck_health`、`ck_relogin` | 同上 + `elite/esports 开通` 两处 | -| 读取点 | Cookie 页、工作台、仪表盘 | 新增详情页 | - -**共享的一条原则**:`accounts` 存**当前状态**,`account_events` 存**发生过什么** —— -这与 CK 解耦「状态归账号、流水归历史表」是同一个模型。做完解耦,账号上就有了完整的 -「当前状态」,事件表补上「历史」,详情页就是把两者拼起来。 - -**建议顺序**(在 [CK 解耦的 7 个提交](设计-登录任务与CK解耦.md#10-提交计划7-个提交) 之后接): - -| 提交 | 内容 | 依赖 | -| --- | --- | --- | -| E1 | `feat(db): account_events 表 + audit_logs.account_id` | 无 | -| E2 | `feat(events): 事件发射 helper + CK 事件(失效/登录/重登)` | **CK 解耦已完成**(否则写入点还会变) | -| E3 | `feat(events): 开通事件(精英宝典 / 电竞手册)` | 无 | -| ~~E4~~ | ~~`feat(events): douyu_tasks 增量收集器 + 水位线`~~ **已废弃**(S5 改为直发并删除) | 无 | -| E5 | `feat(api): 账号事件与概览接口` | E1 | -| E6 | `feat(ui): 账号详情页(状态卡 + 统计 + 时间线)` | E5 | -| E7 | `docs: 落点与验收` | —— | - -> 原计划的「历史回填脚本」已取消(§2 已定不回填),E4 从「搬运 8.5 万行」变成 -> 「装一个水位线收集器」。 - -## 9. 风险与注意 - -1. **不改 runner 代码**:业务事件走收集器(§4),只有 CK/开通 4 处直接发(§5.1)—— - 把改动面压到最小; -2. **`source_ref` 唯一键是幂等的生命线**:收集器重跑、事件重复发射都靠它兜住, - 漏了就会污染时间线; -3. **收集器的水位线必须持久化**,且**解析失败要跳过并告警**,不能让一行坏数据卡住整条链路; -4. **发射事件是旁路**:必须保证 `emit()` 抛错不影响登录/兑换主流程(用 try 包住 + 记日志); -5. **积分口径要先定**(决策 C):`accounts.points` 与 `donate_elite_gift` 的 `points_query.points` - 有不一致的情况(如兑换后 `points_query.points=0`),时间线显示哪个要明确; -6. ~~绑定类任务的 `result` 结构尚未抽样(§4.2),收集器实现前先 dump 一条确认。~~ **已随收集器一并作废**:直发不再需要解析 `result`。 - -## 10. 待确认的决策点 - -| # | 决策 | 选项 | 建议 | -| --- | --- | --- | --- | -| **A** | 时间线是否收录「例行查询」类事件 | ①只收业务事件(推荐)②全都收 | ①:6.7 万行轮询进去后时间线没法看 | -| **B** | 详情页入口 | ①账号列表点用户名(推荐)②独立菜单 | ①更顺手;②可加,但主入口应是列表 | -| **C** | 积分显示口径 | ①以 `accounts.points` 为准,事件里记变更量(推荐)②以事件累加为准 | ①简单可靠;②容易和历史噪声打架 | -| **D** | 收集器的跑法 | ①挂到现有 automation 调度器(推荐)②独立定时任务 | ①复用现成的开关/运行记录/页面;②多一套机制 | -| **E** | `audit_logs` 加 `account_id` 是否随本次做 | ①做(否则人工操作进不了时间线)②先不做 | ①(§7) | -| ~~F~~ | ~~回填范围~~ | —— | ✅ **已定:不回填**,从上线起增量累积 | - ---- - -## 11. 落地记录(2026-10-03) - -已按本文实现,**已提交在 `dev`、未碰生产**(用户要求先本地验证)。迁移编号见 §11.4。 - -| 提交建议 | 内容 | 落点 | -| --- | --- | --- | -| E1 | 迁移 `20261003_0041`:`account_events` + `audit_logs.account_id` | `migrations/versions/20261003_0041_account_events.py`、`models.py`(`AccountEvent`)、`tests/test_migrations.py` | -| E2 | 事件服务 `emit()` + 任务映射 | `services/account_events_service.py` | -| E3 | 发射点:CK 失效 / 登录·重登成功 / 重登转人工 / 精英宝典·电竞手册开通 | `jobs/ck_health.py`、`services/login_service.py`、`jobs/ck_relogin.py`、`services/douyu_runner_manual.py` | -| E4 | 收集器作业(水位线在 `automation_jobs.config`) | `jobs/account_events_collect.py`,注册为「账号事件收集」 | -| E5 | 接口 | `routers/accounts.py`:`GET /{id}/overview`、`GET /{id}/events` | -| E6 | 页面 | `pages/AccountDetailPage.tsx`;路由 `/accounts/:id`;列表用户名可点入 | - -### 11.1 与设计的差异(实施时改的) - -1. **收集器的水位线放作业自己的 `config`**,不再寄生在 `ck_health` 的 config 里 —— - 顺带白拿「自动巡检」页的开关、运行记录与手动触发; -2. **`source_ref` 可空唯一,不用空串默认值**。原设计写的是 `server_default=""`:MySQL 唯一索引下 - **空串会互相冲突**,第二条「无幂等键」的事件就插不进去。改为可空(MySQL 允许 NULL 重复), - 并补了用例专门盯这点; -3. **电竞手册只在「真正开通」那一支发事件**。代码里有两处都会写 `esports_bind_status="esports_opened"`: - 支付流程成功后(真开通)与「基线查到已开通」(检测)。两处都发会每次检查刷一条,只取前者; -4. **重登失败只在「转人工」时发事件**:还要退避重试的每轮都会再来,发事件会刷屏; -5. **`emit` 用 SAVEPOINT 隔离**:失败只回滚这一条,不污染调用方事务(有用例覆盖)。 - -### 11.2 实施时踩到的坑 - -1. **`create_all` 不改已存在的表** —— 给 `audit_logs` 加列后,测试库里的旧表没有该列, - **22 个不相关测试全挂**(`Unknown column 'account_id'`)。改已有表的模型后,测试库要么手动 - `ALTER`,要么 `DROP` 让 `create_all` 重建。已在本地测试库补列;**这个坑值得写进通用约定**; -2. **REPEATABLE READ 快照**(又踩一次):用例里用另一个 session 写入事件后,原 session 查不到 —— - 与 P1b「成功被记成失败」同根因。用例里补 `commit()` 结束事务; -3. **`delete_account` 必须连带清事件**:`account_events` 有指向 `accounts` 的外键,不清就删不掉账号。 - 这是本次改动**引入的**问题,已修并有用例; -4. **测试数据的字符集**:用 `docker exec mysql < file` 灌中文时没带 `--default-character-set=utf8mb4`, - 数据被双重编码(`套装` → `套装`)。排查时先看 `HEX()` 就能立刻分清「存坏了」还是「读坏了」; -5. **⚠️ naive datetime 被当成「本地时间」(2026-10-04 审计发现并修复)**: - `emit()` 原来写的是 `(occurred_at or now).astimezone(UTC)`。对 **naive** 值,Python 会把它 - 当作**服务器本地时间**再换算 —— 开发机 UTC+8,事件时间整整**早 8 小时**。 - naive 的来源很常见:**裸 SQL** 读 DATETIME 列(收集器读 `douyu_tasks.finished_at`) - 会绕过 `UTCDateTime` 装饰器,拿到的就是 naive。 - 仓库的既定约定是「**naive = 已经是 UTC,原样用**」,`scheduler._as_utc` / - `yyb_service._as_utc` / `audit.py` 都遵守了,只有 `emit()` 漏了。 - 已抽成 `account_events_service.to_naive_utc()` 统一收口。 - - **这个 bug 在 TZ=UTC 的机器上完全不可见**(`astimezone(UTC)` 恰好是恒等变换), - 所以只在开发机上现形、CI/容器里全绿 —— 回归用例必须**显式把进程时区切到非 UTC** - 才有意义(见 `tests/test_account_events.py` 的 `_force_non_utc_timezone`)。 - - **若某套环境曾开启过收集器**,用这段按源任务时间对齐(幂等): - - ```sql - UPDATE account_events e - JOIN douyu_tasks t ON t.id = CAST(SUBSTRING(e.source_ref, 12) AS UNSIGNED) - SET e.occurred_at = COALESCE(t.finished_at, t.created_at) - WHERE e.source = 'task' AND e.source_ref LIKE 'douyu_task:%'; - ``` - -### 11.3 本地验证结果 - -| 项 | 结果 | -| --- | --- | -| 后端测试 | **363 通过**(新增 20 条:幂等、旁路隔离、映射、水位线、API、账号隔离) | -| 收集器 | 3 条业务任务 → 3 条事件;金额/余额/中文均正确;水位线 0 → 686 | -| 概览接口 | 累计充值 98、兑换 1 次、消耗积分 1280、导入时间、登录次数、最近 CK 检测均正确 | -| 时间线接口 | 倒序分页正确、按类型筛选正确、中文无乱码 | -| 前端 | `tsc -b` 通过、`vite build` 通过、模块转译正常(dev 5174 → 8800) | - -> 本地验证时把收集器**临时启用**并手动灌了 3 条测试任务(`batch_id='local-evt'`,仅本地开发库)。 -> 生产上该作业仍是 `enabled=false`,需在「自动巡检」页手动打开;首次运行只定水位线、不回溯历史。 - -### 11.4 迁移编号改号:`20261002_0040` → `20261003_0041` - -本功能最初取的号是 `20261002_0040`,`down_revision` 指向 `20261002_0039`。开发期间生产上并发 -落地了索引优化,它同样从 `20261002_0039` 起链、占了 `20261003_0040`。两边一旦合并, -**两个迁移平级 = 两个 alembic head**,`alembic upgrade head` 会直接报 -`Multiple head revisions are present`,`tests/test_migrations.py` 的单头断言也会失败。 - -处理(2026-10-03): - -1. 文件名改为 `20261003_0041_account_events.py`,`revision` 改 `20261003_0041`, - `down_revision` 改指 `20261003_0040`,接在索引优化之后; -2. `tests/test_migrations.py` 的 `HEAD_REVISION` 同步改 `20261003_0041`; -3. **改号是安全的**,因为 `20261002_0040` 从未被生产库应用过(`dev` 未部署)。 - 本机开发库 `douyu_login` 的 `alembic_version` 已是 `20261003_0040`,且 `account_events` - 表已存在 —— 迁移写成幂等的(先 `sa.inspect` 查存在再建),所以升级到 `0041` 只会空跑一次 - 并把版本号推进,不会重复建表。 - -> ⚠️ 教训与规划 §C.1 一致:**迁移编号一旦被任何库应用过就不能再改名**。 -> 这次能改,纯粹是因为它还没进生产。 diff --git a/docs/迁移方案-live-hub-py独立部署.md b/docs/迁移方案-live-hub-py独立部署.md deleted file mode 100644 index 445ba1f..0000000 --- a/docs/迁移方案-live-hub-py独立部署.md +++ /dev/null @@ -1,291 +0,0 @@ -# live-hub-py 独立部署迁移方案(负载分流) - -> 采集时间:2026-10-03 12:53 ~ 13:05 CST -> 源服务器:`order+douyu_huya` / 47.114.114.225(阿里云 cn-hangzhou,4 vCPU / 7.1 GiB / 49 GB 系统盘) -> 目标服务器:**待购买**(用户确认「还没有购买,有计划分开」) -> 迁移对象:**live-hub-py**(用户选定,理由见 §2) -> 本文所有负载数字均为本次实测,非估算;工时为含缓冲的区间估计。 - ---- - -## 0. 结论(TL;DR) - -| 问题 | 结论 | -| :--- | :--- | -| 负载为什么忽高忽低 | 4 核机器上混跑 2 套业务 + 2 个数据库,实例级 CPU 在 **46% ~ 380%** 之间剧烈摆动(实测 24 个采样点),任一方突发就把 4 核打满,排队后 load average 长期挂在 **10 左右** | -| 迁哪个 | **live-hub-py**。它自洽(App + MySQL 一起走)、逻辑数据只有 1.1 GB、只牵动 `live.khhao.com` 一个域名;order_site 是四个域名的入口网关,搬迁代价高得多 | -| 目标机要什么规格 | **4 vCPU / 8 GB / 40~60 GB 系统盘 / 10 Mbps**,**必须买在阿里云 cn-hangzhou**(否则 OSS 内网端点失效) | -| 要做什么 | 11 项改动面,其中 3 项是真正的坑:`.env` 里的 `APP_ENCRYPTION_KEY` 必须原样照抄、MySQL 卷要改成新的本地卷、compose 里的 `external: order-site-net` 要摘掉 | -| 要多久 | **顺利半天(4~6 小时)**,其中**停服窗口 20~40 分钟**;含演练与回滚预案 **1 个工作日** | -| 迁完能降到多少 | 主服峰值砍掉约 40%~60%,15 分钟负载从 ~10 降到 **4~6**;但 **order_site 自己峰值还能到 3.2 核**,SQL 重构/索引不做,下次还会打满 | -| 买机器之前 | 立刻能回收 **约 8 GB 磁盘**(binlog 3.6 G + 构建缓存 2 G + 无用镜像 2.2 G),磁盘水位从 84% 降到 ~68% | - ---- - -## 1. 现场实测:负载为什么忽高忽低 - -### 1.1 采样方法 - -在源服务器上每 5 秒采一次 `loadavg` + `docker stats`,连续 24 个点(12:54:04 ~ 12:57:04): - -```bash -for i in $(seq 1 24); do - echo "$(date +%T) load:$(cut -d' ' -f1-3 /proc/loadavg)" - docker stats --no-stream --format "{{.Name}} {{.CPUPerc}}" - sleep 5 -done -``` - -### 1.2 实测曲线(节选,单位 %,4 核 = 400%) - -| 时刻 | load1 | douyu-login | mysql | postgres | backend | web | 合计/400% | idle | -| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 12:55:22 | 7.31 | 0.41 | 4.54 | 8.45 | 29.65 | 2.58 | **46%** | 86.7% | -| 12:54:44 | 10.04 | 4.28 | 1.16 | 107.52 | 20.52 | 4.82 | **138%** | 14.0% | -| 12:54:20 | 11.24 | 27.38 | 84.40 | 138.22 | 54.83 | 4.92 | **310%** | 28.3% | -| 12:54:12 | 11.09 | 38.97 | 66.31 | 222.80 | 51.11 | 12.98 | **392%** | 8.9% | -| 12:55:38 | 7.03 | 0.77 | 57.64 | 222.32 | 94.40 | 4.66 | **380%** | 0.0% | -| 12:56:18 | 7.58 | 79.33 | 138.41 | 127.95 | 55.01 | 6.05 | **407%** | 1.7% | -| 12:56:49 | 8.92 | 124.48 | 45.71 | 42.25 | 31.83 | 9.81 | **254%** | 3.8% | - -**怎么读这张表**:合计在 **46% → 407%** 之间来回跳,`idle` 在 **0% ~ 86.7%** 之间跳。这不是「持续满载」,而是「**反复打满—回落—再打满**」,正是用户观感里的「忽高忽低」。 - -### 1.3 单实例摆动幅度(同一次采样内 min → max) - -| 容器 | 归属 | CPU 摆动 | 说明 | -| :--- | :--- | :--- | :--- | -| `order_site-postgres-1` | order_site | **2.43% → 222.80%** | 单实例最大摆动,parallel worker 打满约 2 核 | -| `live-hub-py-mysql-1` | live-hub-py | **1.16% → 138.41%** | 任务批次一到就跑满,批次结束立刻回落 | -| `live-hub-py-douyu-login-1` | live-hub-py | **0.41% → 124.48%** | 登录/充值批次并发,典型突发型 | -| `order_site-backend-1` | order_site | **13.89% → 145.92%** | 跟 Postgres 同涨同落 | - -### 1.4 系统层证据 - -```text -load average: 10.36, 9.38, 10.72 # 15 分钟负载稳定在 10.2~10.75 -4 vCPU / Mem 7.1Gi used 4.1Gi / swap used 577Mi -%Cpu(s): ... 0.0 id ... 20.9 wa # 采样中出现 20.9% 的 IO wait -/dev/vda3 49G 39G 7.7G 84% / # 磁盘只剩 7.7G -live-hub-py-mysql-1 BLOCK I/O 396GB / 23.1GB # 累计读 396GB -``` - -**三条结论**: - -1. **突发型负载来自 live-hub-py**(登录批次 + MySQL 批次查询),这是「忽高忽低」的直接来源。 -2. **持续型高位来自 order_site**(Postgres 常态 100%~220%,backend 30%~145%),它决定了「低」也低不到哪去。 -3. **两者峰值会叠加**(12:56:18 合计 407%),互相抢核 → 延迟放大 → 队列堆积 → load 10。 - -### 1.5 历史结论的沿用与修正 - -`docs/生产服务器高负载排查与优化报告.md` 里「PostgreSQL JIT 导致 197% CPU」的判断成立,12:42 执行 `jit = off` 后 Postgres 从 197.75% 掉到 1.58%。**但本次复测显示它又回到了 100%~222%** —— 说明 `jit = off` 只是止血,`getWorkerOrderOverview` 的 CTE + 并行 worker 才是根,仍需按原报告做 SQL 重构(§9)。 - ---- - -## 2. 为什么先迁 live-hub-py(而不是 order_site) - -| 维度 | live-hub-py | order_site | -| :--- | :--- | :--- | -| 对外入口 | 仅 `live.khhao.com` 一个域名 | **四个域名的网关**:`ks.khhao.com`、`worker.khhao.com`、`live.khhao.com`、`mall.khhao.com` 全在它的 Caddy(`order_site-web-1`)里终结 TLS | -| 监听端口 | 容器内 8800,宿主 8000(新机空闲) | 宿主 **80 / 443**,目标机若已有 Caddy 直接冲突 | -| 数据库 | MySQL,逻辑数据 **881 MB**(`douyu_tasks` 一张表 759 MB) | PostgreSQL,库 **5236 MB**(10 张大表,最大 `admin_audit_logs` 769 MB) | -| 卷体积 | 卷名义 4.9 GB,其中 **binlog 3.6 GB 不用搬** | 卷 5.3 GB,基本都要搬 | -| 跨项目依赖 | 接了 `order_site_default` 网络、读 order_site PG —— 但 `ORDER_SITE_LOOKUP_ENABLED=false`,**当前是休眠的** | 反向无依赖(已确认 order_site 不访问 livehub 任何东西) | -| 迁移改动面 | 11 项 | 20+ 项(还要拆网关、拆 DNS、合 Caddy) | -| 工时 | **半天** | **1~1.5 天** | - -**一句话**:live-hub-py 是一个能整体搬走的自治单元;order_site 是「搬它就得把整个入口层一起搬」的重量级单元。 - ---- - -## 3. 目标机规格建议 - -| 项 | 建议 | 依据 | -| :--- | :--- | :--- | -| vCPU | **4 核**(下限 2 核) | 实测 livehub 单体峰值:app 124% + mysql 138% ≈ **2.6 核同时突发**;2 核会排队 | -| 内存 | **8 GB**(下限 4 GB) | 实测 app 611 MiB + mysql 897 MiB ≈ 1.5 GiB,加上 build cache 与页缓存,4 GB 会紧张 | -| 系统盘 | **40~60 GB ESSD** | 镜像 1.87 GB + MySQL 1.12 GB + DB 1.1 GB + 代码 1.2 GB + 首次构建缓存 5~8 GB | -| 带宽 | **≥ 5 Mbps,建议 10 Mbps** | 容器实测 NET I/O 2.95 GB 入 / 234 MB 出(约 2 小时),均值约 3.3 Mbps,突发更高 | -| **地域** | **阿里云 cn-hangzhou(关键)** | `.env` 里 `OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com` —— **内网端点只在同地域可用**。换地域或换云商必须改成公网 `oss-cn-hangzhou.aliyuncs.com`,并接受公网流量费与更高延迟 | -| 操作系统 | Ubuntu 24.04 + Docker + compose plugin | 与源机一致,源机当前 Docker Compose v5.x | -| 安全组 | 22(限自己的 IP)、80/443(方案 A 自签 TLS 需要);**8000 不要对公网开放** | 源机上 8000 是 `0.0.0.0` 裸暴露的,迁移时顺手收紧 | - -> **同地域的额外好处**:DB dump 可以经 **OSS 内网中转**(源机 put → 新机 get),不走公网、不计公网流量、速度是内网级;也可以直接走阿里云同地域公网,通常也比跨云快得多。 - ---- - -## 4. 迁移改动面清单(要做什么) - -### A. 代码与镜像 - -| # | 事项 | 说明 | -| :--- | :--- | :--- | -| A1 | 从 gitea 拉代码 | `git clone https://gitea.hao858.com/yml/live-hub-py.git`,切到生产提交 `294c6cd`(`main`,已确认与 `origin/main` 一致) | -| A2 | 构建镜像 | `docker compose build`。多阶段构建:前端 npm build + yyb-worker node_modules + Python(uv) 运行时。首次全量构建 **15~25 分钟** | -| A3 | 镜像加速 | compose 里已写死 `docker.m.daocloud.io/library/mysql:8.4` 等;构建参数 `USE_CHINA_MIRRORS=true` 已在 `.env` 中确认 | - -### B. 配置改写(3 个真坑) - -| # | 事项 | 不做的后果 | -| :--- | :--- | :--- | -| **B1** | `.env` 里 `APP_ENCRYPTION_KEY`、`JWT_SECRET_KEY` **必须一字不差照抄** | 换了 `APP_ENCRYPTION_KEY` → **账号密码、邮箱密码、Cookie、代理密钥等历史密文全部解不开**,等于数据报废 | -| **B2** | `MYSQL_VOLUME_EXTERNAL` 改为 `false`,`MYSQL_VOLUME_NAME` 用新名字(如 `live-hub-py_mysql-data`) | 源机靠 `external: true` 复用旧卷 `douyu_login_py_mysql-data`。新机上若沿用 external,Docker 会**自动建一个空卷** —— 2026-10-02 已经因此发生过一次「看起来数据全丢」 | -| **B3** | `docker-compose.yml` 摘掉 `networks: order-site-net`(`external: order_site_default`)与服务的该网络 | 新机上不存在 `order_site_default` 网络,`compose up` 直接报错起不来 | -| B4 | `ORDER_SITE_DATABASE_URL` 处理 | 该功能当前是关的(`ORDER_SITE_LOOKUP_ENABLED=false`),**建议保持关闭并在文档留痕**。将来要开,需为跨机 PG 访问单独做 TLS 代理 + IP 白名单 | -| B5 | `APP_PORT` | 新机上 8000 空闲,可沿用;若与既有服务冲突再改 | -| B6 | `COOKIE_SECURE=true` 保持 | 前置 TLS 必须继续有,否则 Cookie 下发异常 | - -### C. 数据搬运 - -| # | 事项 | 体量 | 做法 | -| :--- | :--- | :--- | :--- | -| C1 | MySQL 全量 | 逻辑 881 MB | `mysqldump --single-transaction --routines --triggers --events douyu_login`,压缩后约 150~250 MB | -| C2 | MySQL 增量(切换窗口) | 停机期间写入量 | 停旧容器后重跑一次 dump,只这一次的差异 | -| C3 | **虎牙设备指纹状态** | `data/huya_fp_states/` 2.4 MB + `data/huya_device_profiles.json` 308 KB | **必须搬**,这是虎牙设备指纹的连续性,丢了要重新养 | -| C4 | 验证码截图 | `data/captcha_captures/` 208 MB | 可搬可不搬(排查用样本);不搬能省 15 分钟 | -| C5 | 旧 sqlite | `data/web.db` 24 MB | **不用搬**(7 月 25 日之后未更新,历史遗留) | -| C6 | 日志 | `logs/` 590 MB | **不用搬**,留在旧机做追溯 | -| C7 | 备份件 | `backups/login_tasks_20261001_182959.sql` 32 MB | 可选 | -| C8 | binlog | 3.6 GB | **不用搬**(无从库,已确认 `SHOW REPLICAS` 为空) | - -### D. 入口与 DNS - -| # | 事项 | 说明 | -| :--- | :--- | :--- | -| D1 | **方案 A(推荐)**:新机自建 Caddy,`live.khhao.com` 的 A 记录改指新机 | 新机 Caddy 自动签证书;需安全组开 80/443。目前 `live.khhao.com` → 47.114.114.225,证书由旧机 Caddy 签发(已确认 `live.khhao.com` 证书存在)。**回滚只需把 DNS 改回去** | -| D2 | **方案 B(备选)**:旧机 Caddy 保留 `live.khhao.com`,反代到新机 `IP:8000` | 无 DNS 改动、无证书操作,新机安全组只放行旧机 IP。**缺点**:旧机仍是入口单点,且多一跳跨公网 | - -### E. 依赖外部服务(无需改动,但要确认可达) - -| 服务 | 地址 | 说明 | -| :--- | :--- | :--- | -| Roundcube 邮箱验证码 | `http://111.229.206.54:8000/`(代码默认值,`.env` 未覆盖) | 公网可达,与源机无关 | -| OSS | `oss-cn-hangzhou-internal.aliyuncs.com` / bucket `live-hub-assets` | **依赖目标机在 cn-hangzhou** | -| 鱼翅直充供应商 | `FISH_FIN_RECHARGE_*`(4 个密钥 + 回调 URL) | 公网,回调地址若含旧机域名需一并改 | -| 应用宝 Worker | 容器内回环 `127.0.0.1:8810` | 同容器,自动跟随 | - -### F. 不需要迁移的东西(已确认) - -- **没有 crontab**(`crontab -l` 为空),**没有自定义 systemd timer**(只有 sysstat/logrotate 等系统默认项)。 -- 定时任务全在应用内(`AUTOMATION_ENABLED=true`),随容器搬走;各作业开关默认关闭,在 `/automation` 页面单独控制。 -- yyb-worker 状态目录 `data/yyb-worker-jobs/` 为空。 - ---- - -## 5. 分阶段执行计划与工时 - -| 阶段 | 内容 | 停服 | 工时 | -| :--- | :--- | :---: | ---: | -| **T0 准备**(买机前就能做) | 清理源机磁盘;确认代码已推 gitea;导出 `.env`;跑一次全量 `mysqldump` 并校验可导入 | 否 | 0.5 h | -| **T1 新机初始化** | 安全组;装 Docker + compose;建目录;从 gitea clone;写 `.env`(B1);改 compose(B2/B3/B5) | 否 | 0.5 h | -| **T2 构建与预热** | `docker compose build`(15~25 min);起 MySQL;导入 T0 的全量 dump;`alembic` 由 entrypoint 自动跑 | 否 | 0.5~1 h | -| **T3 预跑验证** | 起 app,跑通 health + 管理员登录 + 只读页面;**用小批量账号各跑 1 个斗鱼/虎牙任务**验证换 IP 后的风控 | 否 | 0.5 h | -| **T4 切换窗口** | 旧机 `compose stop douyu-login`(冻结写)→ 增量 dump → 导入 → 新机 `compose up` → 冒烟 → 改 DNS | **是** | **20~40 min** | -| **T5 验证与收尾** | WebSocket、OSS 上传、自动化作业、Cookie 检测/重登;旧机保留停止态容器与卷作为回滚 | 否 | 1 h | -| **T6 观察期** | 观察 3~7 天:任务成功率、风控拦截、资源曲线;之后再删旧机数据 | 否 | 3~7 天旁观 | - -**合计**: - -- **顺利路径:半天(4~6 小时)**,其中真正停服 **20~40 分钟**。 -- **稳妥路径:1 个工作日**(多出一次演练 + 回滚验证)。 -- 若目标机选**腾讯云**:T2 数据中转改走公网(+20~40 min),且 `OSS_ENDPOINT` 必须改公网端点(改动面 +1,且有流量费)。 - ---- - -## 6. 停机窗口与回滚 - -**切换窗口内的顺序(务必按序)**: - -```bash -# 1) 冻结旧机写入 -cd /root/live-hub-py && docker compose stop douyu-login - -# 2) 旧机增量 dump(此时库是静止的) -docker exec live-hub-py-mysql-1 sh -c \ - 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers --events douyu_login' \ - | gzip > /tmp/livehub-final.sql.gz - -# 3) 经 OSS 内网中转(同地域时免公网流量) -# 源机 put → 新机 get;或用 ossutil 直接 cp - -# 4) 新机导入(新机 MySQL 必须已按 compose 的 --default-time-zone=+00:00 启动) -gunzip -c livehub-final.sql.gz | docker exec -i live-hub-py-mysql-1 \ - mysql -uroot -p"$MYSQL_ROOT_PASSWORD" douyu_login - -# 5) 起服务 + 冒烟 -docker compose up -d && curl -fsS http://127.0.0.1:8000/api/health - -# 6) 改 DNS:live.khhao.com A 记录 → 新机 IP -``` - -**回滚**:`live.khhao.com` 的 A 记录改回 `47.114.114.225`,旧机 `docker compose start douyu-login`。旧机数据与镜像在观察期内**一律不删**。 - -**时区坑(本次必须复核)**:全库时间列存 UTC 裸值,MySQL 靠 `--default-time-zone=+00:00` 对齐。导入前后**不要手工改容器时区**,也不要拿 `SELECT NOW()` 当北京时间比对(会被慢 8 小时误导 —— README 里已记录踩过这个坑)。`mysqldump` 默认 `--tz-utc` 在这一口径下是安全的。 - ---- - -## 7. 风险清单 - -| 风险 | 等级 | 缓解 | -| :--- | :--- | :--- | -| **换公网 IP 触发斗鱼/虎牙风控** | **高** | T3 阶段用小批量账号先验证;项目本身有代理池与预检机制可兜底;异常时立刻回滚 DNS | -| `APP_ENCRYPTION_KEY` 抄错 | **高** | 迁移前用旧 `.env` 对一条已加密记录做解密自检,再切流量 | -| MySQL 卷命名踩坑导致「假丢数据」 | 中 | B2 强制改成新本地卷;导入后立即 `SELECT COUNT(*) FROM douyu_tasks` 比对 222679 行 | -| OSS 内网端点在新地域不可达 | 中 | 机器买在 cn-hangzhou;若换地域,用 `ossutil` 做一次上传自检 | -| 跨项目订单查询功能失效 | 低 | 该功能当前是关闭的;保持关闭并在文档留痕 | -| 磁盘不足(构建缓存峰值) | 低 | T0 先回收 8 GB;构建完 `docker builder prune` | -| 旧机 80/443 上 `live.khhao.com` 的 Caddy 块残留 | 低 | 观察期结束后再从旧 Caddyfile 里删掉该 site 块 | - ---- - -## 8. 迁完的预期收益 - -**立刻缓解**: - -- 主服少掉 **0.5 ~ 2.2 核的突发负载** + **约 1.5 GiB 内存**。 -- 少掉 MySQL 的 **396 GB 累计块读**,IO wait(实测峰值 20.9%)显著下降。 -- 磁盘回收:MySQL 卷 4.9 GB 搬走后,源机 84% → 约 **70%**。 -- **波动源头被移走**:主服 load 15 分钟均值预期从 **~10.5 降到 4~6**。 - -**仍然没解决的(必须说清)**: - -- `order_site-postgres-1` **单独**峰值就能到 **222.8%**,加 backend 145.9% ≈ **3.2 核**。迁走 livehub 后,order_site 独占 4 核仍然会在高峰期打满。 -- 所以迁 livehub 是**必要条件、不是充分条件**。后续二选一: - - ① 按 `docs/生产服务器高负载排查与优化报告.md` 做 `getWorkerOrderOverview` 的 CTE 重构(`UNION` → `UNION ALL`)+ 打手端概览缓存,消除并行 worker 突刺; - - ② 或把 order_site 留在 4 核机上但**升配到 8 核**。 -- 建议顺序:**先迁 livehub(半天)→ 观察一周 → 再决定 order_site 是优化还是升配/搬迁**。这样每次只动一个变量,出问题好定位。 - ---- - -## 9. 买机器之前就能做的立即降压(零成本) - -> 以下都在源机上执行,**不停服**。合计可回收约 **8 GB 磁盘**,并把 MySQL 的慢查询日志 IO 降下来。 - -```bash -# 1) 先补一份全量备份再动 binlog(目前只有 10-01 的一份手工备份) -cd /root/live-hub-py && docker exec live-hub-py-mysql-1 sh -c \ - 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers douyu_login' \ - | gzip > /root/live-hub-py/backups/full-$(date +%Y%m%d).sql.gz - -# 2) 清 binlog:已确认无从库(SHOW REPLICAS 为空),保留最近 3 天保 PITR 能力 -docker exec live-hub-py-mysql-1 mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \ - -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 3 DAY);" -# 并把保留期从 30 天收紧到 7 天(改 compose 的 mysql command): -# --binlog-expire-logs-seconds=604800 -# 预期回收 ~3.6 GB - -# 3) 清构建缓存(当前 6.29 GB,可回收 2.07 GB) -docker builder prune -f - -# 4) 清已退出的旧容器与无用镜像(预期回收 ~2.2 GB) -docker rm douyu-login douyu-login-db -docker rmi douyu_login_py-douyu-login docker.m.daocloud.io/minio/minio pc-crmeb-pc - -# 5) 慢查询日志目前 --long-query-time=1,阈值过低、写入太频繁 -# 排查期可留,稳定后建议调到 3~5 秒或关闭 -``` - -**同时建议**(不花钱): - -- `/tmp/Caddyfile.fmt|orig|new` 是旧编辑残留,确认无用后删掉。 -- 源机上 8000 端口 `0.0.0.0` 裸暴露且不经 Caddy(`live.khhao.com` 是走 Caddy 的),迁移时应顺带收紧为只监听内网。 -- 源机 `/home/order_site_migrate`(17 MB)看名字是上次迁移残留,确认后再删。 diff --git a/docs/鱼翅充值api/对接信息.md b/docs/鱼翅充值api/对接信息.md index d165819..b404fed 100644 --- a/docs/鱼翅充值api/对接信息.md +++ b/docs/鱼翅充值api/对接信息.md @@ -1,7 +1,12 @@ -店铺ID:A20260813141939 -默认账号 -B178660196602145988 -默认密码 -afac3645 -AppKey: 2AJjbWmZ28cu723Q -AppSecret: 5SDFbYEMgDITOKYzDUhBOQAPyTYBQ4UK +# 鱼翅充值对接说明 + +供应商地址、账号、AppKey 和 AppSecret 只通过环境变量注入,不写入文档或数据库: + +- `FISH_FIN_RECHARGE_BASE_URL` +- `FISH_FIN_RECHARGE_APP_ID` +- `FISH_FIN_RECHARGE_APP_KEY` +- `FISH_FIN_RECHARGE_APP_SECRET` +- `FISH_FIN_RECHARGE_NOTIFY_URL` + +代码入口为 `core/douyu/recharge_api.py` 和 +`web/backend/routers/douyu.py` 的供应商回调接口。调试日志不得输出完整凭据。 diff --git a/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py b/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py index 27d32d2..911a87e 100644 --- a/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py +++ b/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py @@ -10,6 +10,7 @@ from __future__ import annotations import sqlalchemy as sa from alembic import op + from web.backend.services.login_failure import classify_failure revision: str = "20261008_0052" diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index 8d0b6dd..1dfb1c0 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -34,6 +34,8 @@ from .login import ( test_sms_url_endpoint, ) from .login import router as login_router +from .pay import redirect_huya_pay +from .pay import router as pay_router from .register import router as register_router from .workbench import ( cleanup_orphan_tasks, @@ -52,8 +54,6 @@ from .workbench import ( update_huya_workbench_accounts, ws_huya_logs, ) -from .pay import redirect_huya_pay -from .pay import router as pay_router from .workbench import router as workbench_router router = APIRouter(prefix="/api/huya", tags=["虎牙"]) diff --git a/web/backend/routers/huya/pay.py b/web/backend/routers/huya/pay.py index 9f8239b..c11a0f6 100644 --- a/web/backend/routers/huya/pay.py +++ b/web/backend/routers/huya/pay.py @@ -15,7 +15,6 @@ from ...database import get_db from ...models import HuyaTask from ...services.huya_pay_link import verify_pay_token - router = APIRouter(tags=["虎牙"]) diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 678a942..8b7b631 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -67,9 +67,12 @@ def _match_suit_item(want: str, rows: list[dict[str, Any]]) -> dict[str, Any] | # 优先找带「套装」前缀的 for it in rows: cname = str(it.get("commodityName") or "").strip() - if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS): - if "套装" in cname: - return it + if ( + core in cname + and "套装" in cname + and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS) + ): + return it # 3. 兜底匹配不带排除词的项 for it in rows: