From a3c3a46b9c016d21dc0e65e23012be63f0e3f6db Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 8 Oct 2026 12:33:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(tooling):=20=E4=BF=AE=E5=A4=8D=20pyright=20?= =?UTF-8?q?=E8=A7=A3=E9=87=8A=E5=99=A8=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=88=E5=8D=AB=EF=BC=8C=E8=A1=A5=E9=BD=90=20ruff?= =?UTF-8?q?=20=E9=97=A8=E7=A6=81=E4=B8=8E=E4=B8=A4=E5=A4=84=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyrightconfig.json 钉死 Python 3.12 虚拟环境,消除由于查找系统 Python 3.9 带来的假性报错 - 修复 huya/login.py 引用不存在函数的问题并补充单元测试 - 修复 browser_harvest.py 对 set 下标访问的错误并提升报错信息质量 - 虎牙 6 个子路由显式导入依赖,消除 import * 带来的未定义变量报警 - 规范化 Pydantic Field(default=...) 默认值声明 - 完善 solver.py / app_login.py 等核心路径类型守卫与 @overload 重载 - 在 pyproject.toml 中显式锁定 ruff lint 规则集 (E4, E7, E9, F, B, I, BLE) - 门禁脚本同步增加 ruff check 检查并在 AGENTS.md 补齐实践经验总结 --- .githooks/pre-commit | 6 +- .githooks/pre-push | 4 +- AGENTS.md | 13 +- README.md | 6 +- core/huya/app_login.py | 54 +++++++- core/huya/browser_harvest.py | 29 ++++- core/huya/elite_session.py | 8 +- core/huya/verification/solver.py | 36 ++++-- core/huya/wup_protocol.py | 2 +- dev.sh | 6 + pyproject.toml | 4 + pyrightconfig.json | 24 +++- tests/test_huya_auto_sms_login.py | 120 ++++++++++++++++++ tests/test_huya_browser_harvest.py | 49 +++++++ ...20261008_0052_relogin_failure_kind_sync.py | 1 + web/backend/routers/accounts.py | 1 - web/backend/routers/audit.py | 8 +- web/backend/routers/huya/_common.py | 14 +- web/backend/routers/huya/accounts.py | 48 ++++++- web/backend/routers/huya/cookies.py | 25 +++- web/backend/routers/huya/devices.py | 20 ++- web/backend/routers/huya/login.py | 45 ++++++- web/backend/routers/huya/register.py | 22 +++- web/backend/routers/huya/workbench.py | 47 ++++++- web/backend/schemas.py | 62 ++++----- .../automation/jobs/douyu_tasks_retention.py | 8 +- web/backend/services/douyu_runner_manual.py | 8 +- web/backend/services/douyu_runner_overview.py | 9 +- web/backend/services/huya_runner_core.py | 6 +- web/backend/services/huya_runner_recharge.py | 6 +- .../huya-exchange-helper/generate_icons.py | 4 +- 31 files changed, 599 insertions(+), 96 deletions(-) create mode 100644 tests/test_huya_auto_sms_login.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e1229b1..ca20246 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -29,7 +29,11 @@ done <<< "$staged" if [ "${#python_files[@]}" -gt 0 ]; then echo "[pre-commit] 检查 Python 格式..." uv run --group dev ruff format --check "${python_files[@]}" - echo "[pre-commit] Python 格式通过" + # ⚠️ 只跑 format 不够:format 不管「未使用导入 / 未排序导入 / 宽 except」这类 + # 问题,之前全仓攒了 6 条(含一个 F401 死导入)没人拦。两条一起跑才算校验。 + echo "[pre-commit] 检查 Python lint..." + uv run --group dev ruff check "${python_files[@]}" + echo "[pre-commit] Python 格式 + lint 通过" fi if [ "$frontend_changed" -eq 1 ]; then diff --git a/.githooks/pre-push b/.githooks/pre-push index 9ae57fd..6f7550e 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -44,7 +44,9 @@ done <<< "$push_lines" if [ "${#python_files[@]}" -gt 0 ]; then echo "[pre-push] 检查 Python 格式..." uv run --group dev ruff format --check "${python_files[@]}" - echo "[pre-push] Python 格式通过" + echo "[pre-push] 检查 Python lint..." + uv run --group dev ruff check "${python_files[@]}" + echo "[pre-push] Python 格式 + lint 通过" fi if [ "$frontend_changed" -eq 1 ]; then diff --git a/AGENTS.md b/AGENTS.md index 766950e..28bdea4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,14 @@ 关键是**反向验证** —— 把 bug 注入回去,确认用例真的会变红,再还原。 「跑了一下没报错」不算验证。 - **提交/推送会跑钩子**,所以本地必须按同样标准自查: - - `pre-commit`:`ruff format --check`(**只跑 `ruff check` 不够**,格式不对会直接拦下提交) + - `pre-commit`:`ruff format --check` **和** `ruff check`(两个都要跑,见坑 #8) + 前端 `tsc -b`; - - `pre-push`:上面这些 + **全量 pytest**。 + - `pre-push`:上面这些 + **全量 pytest**(`TYPECHECK=1` 时额外跑 Pyright)。 - **断言要看 `model_dump()`(序列化结果)**,只看属性会假绿(原因见坑 #2)。 - **别信「测试通过」** —— 用例可能是假绿的(原因见坑 #3)。 +- **Pyright 只查 `core`/`utils`/`web`,`tests` 有意排除**(原因和重新打开的方法都写在 + `pyrightconfig.json` 的注释里)。别为了让它闭嘴去给 FastAPI 路由补返回值注解 —— + 那等于加了 `response_model`,会真的裁剪响应体。 ## 4. 数据库与迁移 @@ -100,8 +103,10 @@ docker compose up -d douyu-login | 5 | **`docker compose run` 默认读 stdin**:用 `ssh 'bash -s' < 脚本` 喂进去时,它会把脚本剩下的行吃掉 | 用 `docker compose run -T --rm ... web/backend/migrations/versions/` 自己算一遍 head(没被任何 `down_revision` 引用的那个) | | 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` | | 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` | diff --git a/README.md b/README.md index 7d7d02c..44389a5 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ uv run --group dev pytest # 或 ./dev.sh test uv run --group dev pytest tests/test_cookie_operations.py ./dev.sh format tests conftest.py ./dev.sh format-check tests conftest.py +./dev.sh lint-check tests conftest.py ./dev.sh type-check # 渐进式 Pyright docker compose --profile test run --rm test ``` @@ -168,9 +169,12 @@ docker compose --profile test run --rm test ./dev.sh frontend-check # 单独跑前端 TypeScript ``` -- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查;改了 `web/frontend/` 则跑 `tsc -b` +- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查 **+ `ruff check` lint**;改了 `web/frontend/` 则跑 `tsc -b` - **pre-push**:上述检查 + 全量 pytest - Pyright 默认不开(噪音大):`TYPECHECK=1 git push` + —— 注意 `pyrightconfig.json` 已钉死 `.venv`/Python 3.12;**没有这段配置时 pyright 会去 + 摸 macOS 自带的 Python 3.9**,读成 SQLAlchemy 1.4 并凭空多报约 2600 条假错(2926 vs 273)。 + 若在 IDE 里看到成片「Mapped 不是泛型 / 联合类型需要 3.10+」,先查解释器有没有选对。 - 紧急绕过:`git push --no-verify` 或 `SKIP_TESTS=1 git push` ## 技术栈 diff --git a/core/huya/app_login.py b/core/huya/app_login.py index 42cdaac..5533e6c 100644 --- a/core/huya/app_login.py +++ b/core/huya/app_login.py @@ -31,6 +31,7 @@ import struct import time import uuid from collections.abc import Mapping +from typing import Literal, overload from urllib.parse import parse_qs, quote, urlparse import requests @@ -245,7 +246,7 @@ def solve_safe_auth( k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items() } - app_id = str(q.get("appId") or "5002") + app_id = q.get("appId") or "5002" last_err: Exception | None = None for attempt in range(max_retry): solver = HuyaVerificationSolver( @@ -293,6 +294,42 @@ def parse_real_uid(resp: bytes) -> int: raise HuyaAppLoginError("无法从登录响应解析真实 UID") +@overload +def login_cred_with_flow( + account: str, + password: str, + max_rounds: int = ..., + device_info: dict | None = ..., + proxies: dict | None = ..., + include_device_token: Literal[False] = ..., + sms_url: str = ..., +) -> tuple[bytes, int]: ... + + +@overload +def login_cred_with_flow( + account: str, + password: str, + max_rounds: int = ..., + device_info: dict | None = ..., + proxies: dict | None = ..., + include_device_token: Literal[True] = ..., + sms_url: str = ..., +) -> tuple[bytes, int, str]: ... + + +@overload +def login_cred_with_flow( + account: str, + password: str, + max_rounds: int = ..., + device_info: dict | None = ..., + proxies: dict | None = ..., + include_device_token: bool = ..., + sms_url: str = ..., +) -> tuple[bytes, int] | tuple[bytes, int, str]: ... + + def login_cred_with_flow( account: str, password: str, @@ -405,7 +442,11 @@ class QrRole: self.req_counter = random.randint(40_000_000, 41_000_000) self.s.headers.update( { - "User-Agent": UA_PC if pc else mobile_user_agent(device_info), + "User-Agent": ( + UA_PC + if pc or device_info is None + else mobile_user_agent(device_info) + ), "Origin": UDB_BASE, "content-type": "application/json;charset=UTF-8", "Accept": "*/*", @@ -550,8 +591,7 @@ class HuyaAppPasswordLogin: jar = session.cookies if hasattr(jar, "items"): pairs = [ - (str(key), "" if value is None else str(value)) - for key, value in jar.items() + (key, "" if value is None else value) for key, value in jar.items() ] else: pairs = [ @@ -576,13 +616,13 @@ class HuyaAppPasswordLogin: raw_cred = ( base64.urlsafe_b64encode(cred).decode("ascii").rstrip("=") if isinstance(cred, bytes) - else str(cred or "") + else (cred or "") ) # 本次登录/指纹响应明确返回的字段,必须覆盖旧 Session 值。 set_value("udb_cred", raw_cred, overwrite=True) if uid: - set_value("udb_uid", int(uid), overwrite=True) - set_value("yyuid", int(uid), overwrite=True) + set_value("udb_uid", uid, overwrite=True) + set_value("yyuid", uid, overwrite=True) set_value("udb_biztoken", biztoken, overwrite=True) set_value("udb_version", "1.0", overwrite=True) set_value("udb_origin", "0", overwrite=True) diff --git a/core/huya/browser_harvest.py b/core/huya/browser_harvest.py index e044412..769ba58 100755 --- a/core/huya/browser_harvest.py +++ b/core/huya/browser_harvest.py @@ -136,11 +136,18 @@ def launch( proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True ) + # 上面用的是 stderr=PIPE,所以 proc.stderr 一定不是 None(静态类型是 + # IO[str] | None)。收一次窄,顺便在真拿不到 stderr 时给出明确报错。 + stderr = proc.stderr + if stderr is None: + proc.kill() + raise RuntimeError("Chrome stderr 未接通,无法读取调试端口") ws_url = None deadline = time.time() + 20 + last_probe_error: Exception | None = None while time.time() < deadline: # 1) 从 DevTools 输出的 ws url(browser 级) - line = proc.stderr.readline() + line = stderr.readline() if "DevTools listening on" in line: browser_ws = line.strip().split(" ")[-1] else: @@ -151,23 +158,33 @@ def launch( f"http://127.0.0.1:{port}/json/list", timeout=2 ) as resp: targets = json.load(resp) - pages = { + # 必须是 list 而不是 set:下面要按下标取「第一个」page target, + # set 不可下标。这里曾写成集合推导式,pages[0] 抛出的 TypeError + # 被下面的宽 except 吞掉,于是每次都静默退回 browser 级 endpoint, + # 页面级探测长期失效却无人察觉。回归用例: + # tests/test_huya_browser_harvest.py::test_launch_prefers_page_target_over_browser_endpoint + pages = [ t for t in targets if t.get("type") == "page" and t.get("webSocketDebuggerUrl") - } + ] if pages: ws_url = pages[0]["webSocketDebuggerUrl"] break - except Exception: # noqa: BLE001, S110 - 端口未就绪时轮询重试 - pass + except Exception as exc: # noqa: BLE001 - 端口未就绪时轮询重试 + # 不静默:记下最后一次探测异常,端口最终没起来时一并报出来, + # 避免「代码 bug」被读成「端口没起来」。 + last_probe_error = exc # 3) 兜底:browser 级 endpoint if browser_ws: ws_url = browser_ws break if not ws_url: proc.kill() - raise RuntimeError("Chrome 调试端口未就绪") + detail = ( + f";最后一次 CDP 探测异常: {last_probe_error!r}" if last_probe_error else "" + ) + raise RuntimeError(f"Chrome 调试端口未就绪{detail}") return proc, ws_url diff --git a/core/huya/elite_session.py b/core/huya/elite_session.py index 3926db4..9fe8562 100644 --- a/core/huya/elite_session.py +++ b/core/huya/elite_session.py @@ -35,7 +35,13 @@ class HuyaEliteWssSession: self.logger = logger or (lambda _message: None) self.http = HuyaHttpClient(logger=self.logger) self.loop = asyncio.new_event_loop() - self.client: HuyaWssClient | None = None + # 会话内**始终**持有 client:下面(本函数的 try 之前)无条件创建, + # close() 只置 self._closed,从不把它改回 None。原先标成 `| None = None` + # 是陈旧声明,代价是 20 处 `self.client.xxx(...)` 全被报「可能为 None」。 + # 顺带说明:_run() 里 `self.client is None` 这条兜底因此是死代码 —— + # 而且它本来就被求值顺序绕过(`self.client.xxx()` 作为实参先于 _run 执行), + # 保留它只为防御未来有人真的把 client 置空。 + self.client: HuyaWssClient self._closed = False if kind == "activity": diff --git a/core/huya/verification/solver.py b/core/huya/verification/solver.py index f10ceeb..6dd4086 100644 --- a/core/huya/verification/solver.py +++ b/core/huya/verification/solver.py @@ -65,7 +65,7 @@ def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]: parsed = SimpleCookie() parsed.load(cookie) return {key: morsel.value for key, morsel in parsed.items()} - return {str(key): str(value) for key, value in cookie.items()} + return {key: value for key, value in cookie.items()} class HuyaVerificationSolver: @@ -214,7 +214,7 @@ class HuyaVerificationSolver: background = cv2.cvtColor(cv2.Canny(background, 100, 200), cv2.COLOR_GRAY2RGB) result = cv2.matchTemplate(background, target, cv2.TM_CCOEFF_NORMED) _, _, _, max_loc = cv2.minMaxLoc(result) - return int(max_loc[0]) + return max_loc[0] @staticmethod def _opencv_rotate_angle( @@ -331,7 +331,10 @@ class HuyaVerificationSolver: if Path(font_path).is_file(): try: return ImageFont.truetype(font_path, size) - except Exception: + except OSError: + # 字体文件在、但 FreeType 打不开或格式不对(实测「cannot open + # resource」和「unknown file format」都是 OSError)—— 换下一个候选。 + # 收窄异常类型:宽 except 会把参数写错之类的真 bug 一起吞掉。 continue return ImageFont.load_default() @@ -627,7 +630,7 @@ class HuyaVerificationSolver: restored_bg, tip_bytes, int(data.get("location_y") or 0) ) slider_box = self.ocr.ocr_slider_box(composite) - yolo_x = int(slider_box[0] - 13) + yolo_x = slider_box[0] - 13 opencv_x = self._opencv_slider_x(tip_bytes, restored_bg) if abs(opencv_x - yolo_x) < 10: logger.debug("虎牙滑块识别距离: yolo={}, opencv={}", yolo_x, opencv_x) @@ -666,7 +669,7 @@ class HuyaVerificationSolver: if tip_bytes: merged = self._vertical_concat_align_left(bg_bytes, tip_bytes) points = self.ocr.ocr_click_points(merged) - final_points = [[int(x), int(y) - 44] for x, y in points] + final_points = [[x, y - 44] for x, y in points] else: question = str(data.get("question") or "") target_chars = self._extract_target_chars_from_question(question) @@ -675,7 +678,7 @@ class HuyaVerificationSolver: synth_tip = self._synthesize_text_tip(target_chars) merged = self._vertical_concat_align_left(bg_bytes, synth_tip) points = self.ocr.ocr_click_points(merged) - final_points = [[int(x), int(y) - 44] for x, y in points] + final_points = [[x, y - 44] for x, y in points] tip_bytes = synth_tip else: logger.info(f"虎牙点选题目未能提取明确目标字: {question}") @@ -699,11 +702,11 @@ 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, - "endTime": int(track_points[-1][-1]) - int(track_points[0][-1]), + "endTime": track_points[-1][-1] - track_points[0][-1], } act = self.js_ctx.call("get_act", act_data, self.huyapk) self.behavior_events.append( @@ -726,11 +729,11 @@ class HuyaVerificationSolver: def _send_slider_verify(self, url: str, point: int) -> dict: track_points = generate_slide_track(point) act_data = { - "point": int(point), + "point": point, "travel": format_track(track_points), "code": self.code, "sceneType": self.scene_type, - "endTime": int(track_points[-1][-1]) - int(track_points[0][-1]), + "endTime": track_points[-1][-1] - track_points[0][-1], } act = self.js_ctx.call("get_act", act_data, self.huyapk) self.behavior_events.append( @@ -759,7 +762,7 @@ class HuyaVerificationSolver: current_time = start_time for point in points: current_time += random.randint(1000, 2000) - click_data.append([int(point[0]), int(point[1]), current_time]) + click_data.append([point[0], point[1], current_time]) interval_time_list = [item[-1] for item in click_data] act_data = { @@ -958,11 +961,18 @@ class HuyaVerificationSolver: or "/auth/xz/" in vurl or "rotate" in self.strategy_url ): - last_result = self._send_rotate_verify(vurl, float(point)) + if isinstance(point, (int, float)): + last_result = self._send_rotate_verify(vurl, float(point)) + else: + logger.warning(f"旋转验证识别返回非数值结果: {point}") + continue elif isinstance(point, list): last_result = self._send_click_verify(vurl, point) + elif point is not None: + last_result = self._send_slider_verify(vurl, point) else: - last_result = self._send_slider_verify(vurl, int(point)) + logger.warning("验证码识别未返回有效结果") + continue if isinstance(last_result, dict) and ( last_result.get("authId") or last_result.get("nextAuthUrl") diff --git a/core/huya/wup_protocol.py b/core/huya/wup_protocol.py index e7586a3..5cb3c68 100644 --- a/core/huya/wup_protocol.py +++ b/core/huya/wup_protocol.py @@ -56,7 +56,7 @@ class WupRequest: os.write_struct(0, struct_data) elif isinstance(struct_data, dict): os.write_struct_begin(0) - for tag_id, (field_name, field_value) in enumerate(struct_data.items()): + for tag_id, (_, field_value) in enumerate(struct_data.items()): self._write_field(os, tag_id, field_value) os.write_struct_end() elif isinstance(struct_data, (list, tuple)): diff --git a/dev.sh b/dev.sh index 4ad60e4..2abbf3a 100755 --- a/dev.sh +++ b/dev.sh @@ -96,6 +96,12 @@ if [ "${1:-}" = "format-check" ]; then exec uv run --group dev python -m ruff format --check "$@" fi +# 与 format-check 分开:ruff format 不查未使用导入/未排序导入/宽 except 这类问题。 +if [ "${1:-}" = "lint-check" ]; then + shift + exec uv run --group dev python -m ruff check "$@" +fi + if [ "${1:-}" = "type-check" ]; then shift exec uv run --group dev python -m pyright "$@" diff --git a/pyproject.toml b/pyproject.toml index 5b27551..d73ecec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ packages = ["core", "utils", "web"] [tool.pytest.ini_options] testpaths = ["tests"] +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "B", "I", "BLE"] +ignore = ["E402", "E731", "E741", "B904", "B905"] + [tool.ruff.lint.per-file-ignores] # FastAPI evaluates dependency metadata in route parameter defaults by design. "web/backend/**/*.py" = ["B008"] diff --git a/pyrightconfig.json b/pyrightconfig.json index 2a5cc81..d63cebf 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,6 @@ { - "include": ["core", "utils", "web", "tests"], + // tests/ 有意**不**做类型检查(见下方说明),所以不放进 include。 + "include": ["core", "utils", "web"], "exclude": [ "**/__pycache__", "artifacts", @@ -10,5 +11,24 @@ ], "typeCheckingMode": "basic", "reportMissingImports": "none", - "reportMissingModuleSource": "none" + "reportMissingModuleSource": "none", + // 必须显式钉死解释器:否则 pyright 会去 PATH 上摸 /usr/bin/python3(macOS 自带 3.9), + // 读到用户级 ~/Library/Python/3.9 里的 SQLAlchemy 1.4 + 把语义版本当日 3.9, + // 凭空多出约 2600 条假错(详见 pyright --verbose 的 "Assuming Python version")。 + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.12", + // ── 为什么 tests/ 不做类型检查 ─────────────────────────────────────────── + // 打开 tests 会多出约 119 条,且**全部是同一类噪音**,不是测试写错了: + // 1) FastAPI 路由函数没有(也不该有)返回值注解,pyright 只能从 return + // 字面量反推成一个很窄的 dict 类型,于是 `result["items"][0]` 报 + // 「No overloads for __getitem__」;给路由补注解=给 FastAPI 加了 + // response_model,会真的裁剪响应体,绝不能为了消警告去加。 + // 2) 测试故意传 `db=None` 走 mock 分支,而形参注解是 Session。 + // 3) `resp.json()` 静态类型是 object,下标取值必然报错。 + // 这三类要么得改生产签名、要么得在测试里撒 cast/ignore,属于 AGENTS.md 说的 + //「没人要的重构」。测试的正确性本来就由 pytest **真跑一遍**来保证,而 + // 未定义名/未用导入已由 `ruff check`(含 F 系列)覆盖。 + // 要临时把 tests 也检查一遍:把 "tests" 加回上面的 include 再跑 + // `./dev.sh type-check`,看完记得改回来。 } diff --git a/tests/test_huya_auto_sms_login.py b/tests/test_huya_auto_sms_login.py new file mode 100644 index 0000000..67a9ec1 --- /dev/null +++ b/tests/test_huya_auto_sms_login.py @@ -0,0 +1,120 @@ +"""虎牙「短信接码一键登录」端点:调用契约与成功/失败两条分支。 + +背景(这是本文件的**存在理由**,别删):该端点曾引用一个从来不存在的 +``core.huya.auto_register.auto_register_huya_phone``(git 全历史都没有 ``def``), +所以每次调用都在 import 阶段抛 ``ImportError`` → HTTP 500,且因为导入写在函数体 +内部,静态检查之外没有任何东西拦得住它。用例锁死「端点必须调用真实存在的 +``register_huya_with_sms_line``」这个契约,名字再被改就不会静默回归。 +""" + +import pytest +from fastapi import HTTPException + +from core.huya.auto_register import HuyaAutoRegisterResult +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import HuyaAccount, User +from web.backend.routers.huya.login import auto_sms_login_account + +# 能被 parse_huya_cookie_line 认出来的最小虎牙 CK(含 yyuid= / udb_uid=)。 +LOGIN_COOKIE = "yyuid=100; udb_uid=100" + + +def _ok_result(item, **kwargs): + return HuyaAutoRegisterResult( + phone=item.phone, + provider=item.provider, + success=True, + status="success", + message="接码成功", + cookie=LOGIN_COOKIE, + ) + + +class TestAutoSmsLoginAccount: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="operator", password_hash="hash", role="super_admin") + self.account = HuyaAccount( + uid="", + yyuid="", + cookie="", + game_phone="13800000000", + sms_url="https://sms.example.com/get?p=1", + ) + self.session.add_all([self.user, self.account]) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _call(self): + return auto_sms_login_account( + self.account.id, db=self.session, current=self.user + ) + + def test_calls_existing_register_helper_and_saves_cookie(self, monkeypatch): + """核心契约:必须调用真实存在的注册函数,并把返回的 CK 回填落库。""" + calls: dict = {} + + def fake_register(item, **kwargs): + calls["item"] = item + calls["kwargs"] = kwargs + return _ok_result(item, **kwargs) + + # 用字符串形式 patch 目标模块属性:端点内部是 `from ... import ...`, + # 每次调用都会重新取模块属性,所以这层 patch 必须生效。 + monkeypatch.setattr( + "core.huya.auto_register.register_huya_with_sms_line", fake_register + ) + + out = self._call() + + assert out["success"] is True + # 断言序列化结果,不看属性(AGENTS.md 坑 #2) + assert out["account"].model_dump()["uid"] == "100" + assert calls["item"].phone == "13800000000" + assert calls["item"].url == "https://sms.example.com/get?p=1" + # 超时/轮询间隔是端点的既有行为,不能被顺手改掉 + assert calls["kwargs"]["wait_seconds"] == 90 + assert calls["kwargs"]["poll_interval"] == 2 + + self.session.refresh(self.account) + assert self.account.status == "active" + assert self.account.login_channel == "sms" + assert "udb_uid=100" in self.account.cookie + + def test_failure_marks_account_login_failed(self, monkeypatch): + """注册失败要落库成 login_failed 并返回 502,而不是假装成功。""" + + def fake_register(item, **kwargs): + return HuyaAutoRegisterResult( + phone=item.phone, + provider=item.provider, + success=False, + status="failed", + message="接码平台超时", + ) + + monkeypatch.setattr( + "core.huya.auto_register.register_huya_with_sms_line", fake_register + ) + + with pytest.raises(HTTPException) as excinfo: + self._call() + + assert excinfo.value.status_code == 502 + assert "接码平台超时" in str(excinfo.value.detail) + + self.session.refresh(self.account) + assert self.account.status == "login_failed" + + def test_missing_phone_or_sms_url_is_rejected_before_import(self): + """缺手机号/接码链接要在调用注册前 400,不能先崩在 import 上。""" + self.account.game_phone = "" + self.session.commit() + + with pytest.raises(HTTPException) as excinfo: + self._call() + + assert excinfo.value.status_code == 400 diff --git a/tests/test_huya_browser_harvest.py b/tests/test_huya_browser_harvest.py index 289a8f6..358d908 100644 --- a/tests/test_huya_browser_harvest.py +++ b/tests/test_huya_browser_harvest.py @@ -1,3 +1,5 @@ +import io +import json from pathlib import Path from core.huya import browser_harvest @@ -51,3 +53,50 @@ def test_launch_can_show_window_when_requested(monkeypatch): browser_harvest.launch(Path("/tmp/test-huya-profile"), 9556, headless=False) assert "--headless=new" not in calls[0] + + +def test_launch_prefers_page_target_over_browser_endpoint(monkeypatch): + """页面级 ws 必须优先于 browser 级(Network 域只能挂在 page target 上)。 + + 这里原本写的是 ``pages[0]``,而 ``pages`` 是集合推导出来的 **set** —— + ``set`` 不可下标,抛出的 TypeError 又被外层 ``except Exception: pass`` + 吞掉,于是每次都退回 browser 级 endpoint:页面级探测**从来没有成功过一次**, + 而且没有任何日志能看出来。这就是本用例存在的意义。 + """ + page_ws = "ws://127.0.0.1/devtools/page/PAGE" + browser_ws = "ws://127.0.0.1/devtools/browser/test" + payload = json.dumps([{"type": "page", "webSocketDebuggerUrl": page_ws}]) + + monkeypatch.setattr( + browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess() + ) + # _FakeProcess.stderr 会给出 browser 级 endpoint,用来证明「选中了页面级」 + monkeypatch.setattr( + browser_harvest.urllib.request, + "urlopen", + lambda *args, **kwargs: io.StringIO(payload), + ) + + _proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9557) + + assert ws_url == page_ws + assert ws_url != browser_ws + + +def test_launch_falls_back_to_browser_endpoint_without_page_target(monkeypatch): + """没有 page target 时才允许退回 browser 级 endpoint。""" + browser_ws = "ws://127.0.0.1/devtools/browser/test" + payload = json.dumps([{"type": "service_worker", "webSocketDebuggerUrl": "ws://x"}]) + + monkeypatch.setattr( + browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess() + ) + monkeypatch.setattr( + browser_harvest.urllib.request, + "urlopen", + lambda *args, **kwargs: io.StringIO(payload), + ) + + _proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9558) + + assert ws_url == browser_ws 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/accounts.py b/web/backend/routers/accounts.py index eb49722..46b7d51 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -56,7 +56,6 @@ from ..services.account_lifecycle import ( set_manual_state, ) from ..services.account_service import ( - cookie_account_ids_query, parse_and_build_accounts, ) from ..services.account_value import ( diff --git a/web/backend/routers/audit.py b/web/backend/routers/audit.py index c78f010..f3b5477 100644 --- a/web/backend/routers/audit.py +++ b/web/backend/routers/audit.py @@ -58,10 +58,12 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict: for task, account in tasks_and_accounts: task_result = task.result if isinstance(task.result, dict) else {} channel = str(task_result.get("recharge_channel") or "") + # 先落到局部变量再判 isinstance:直接写 + # `x.get(k) if isinstance(x.get(k), dict) else {}` 是两次独立取值, + # 类型检查器无法据此收窄,会报「可能为 None」。 + raw_supplier_order = task_result.get("supplier_order") supplier_order = ( - task_result.get("supplier_order") - if isinstance(task_result.get("supplier_order"), dict) - else {} + raw_supplier_order if isinstance(raw_supplier_order, dict) else {} ) supplier_order_id = str( supplier_order.get("order_id") diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index 58006b4..d820a59 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -544,4 +544,16 @@ def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]: return removed, missing -__all__ = [name for name in globals() if not name.startswith("__")] +# 运行期导出清单:显式列出「非 dunder」的全部模块级名字(**含下划线开头的内部 +# helper**),因为 python 的 `import *` 只要模块定义了 `__all__` 就照单全收。 +# +# ⚠️ 六个 huya 路由模块(accounts/cookies/register/workbench/login/devices)原本 +# 用 `from ._common import *`,全部已改为**显式导入**。原因不是风格洁癖:pyright +# 不会执行上面的推导式,只能按 PEP 8 保守假设「下划线名不导出」,于是那 108 处 +# helper 调用全被报成「未定义」—— 更糟的是,它让这 6 个模块的类型检查彻底失明 +# (helper 改名/删除时静态检查一声不吭,只能等运行期 500;曾因此漏掉一个 +# `ImportError` 死端点)。新增代码请一律用显式导入。 +# +# 这里保留计算式 `__all__` 是为了不改动任何运行期行为:万一还有别处 `import *`, +# 行为与改造前完全一致。pyright 无法静态求值它,故就地忽略该诊断。 +__all__ = [name for name in globals() if not name.startswith("__")] # pyright: ignore[reportUnsupportedDunderAll] diff --git a/web/backend/routers/huya/accounts.py b/web/backend/routers/huya/accounts.py index 5754cac..c7a2dd4 100644 --- a/web/backend/routers/huya/accounts.py +++ b/web/backend/routers/huya/accounts.py @@ -2,7 +2,53 @@ from fastapi import APIRouter -from ._common import * +from ._common import ( + DEFAULT_EXCHANGE_TARGET, + AccountAssign, + AccountBulkSelection, + AccountBulkTag, + AccountTag, + BatchAssign, + Depends, + HTTPException, + HuyaAccount, + HuyaBrowserExchangeRequest, + HuyaCookieImport, + HuyaPasswordAccountImport, + HuyaTask, + HuyaWorkbenchAccount, + Query, + Session, + User, + _account_out, + _account_out_light, + _can_view_huya_all, + _can_view_huya_assigned, + _can_view_huya_cookie, + _clear_huya_account_references, + _filter_huya_accounts_query, + _huya_cookie_state_map, + _huya_password_state_map, + _require_huya_perm, + _require_huya_tag_permission, + _selected_huya_account_ids, + _visible_huya_accounts_query, + browser_login_script, + cookie_pairs, + defer, + ensure_huya_config, + exchange_target_options, + exchange_target_url, + func, + get_current_user, + get_db, + huya_config_value, + import_huya_cookies, + import_huya_password_accounts, + normalize_huya_cookie, + or_, + record_audit, +) router = APIRouter() diff --git a/web/backend/routers/huya/cookies.py b/web/backend/routers/huya/cookies.py index 0de3d15..1c78197 100644 --- a/web/backend/routers/huya/cookies.py +++ b/web/backend/routers/huya/cookies.py @@ -2,7 +2,30 @@ from fastapi import APIRouter -from ._common import * +from ._common import ( + Depends, + HTTPException, + HuyaAccount, + Query, + Session, + StreamingResponse, + User, + _can_view_huya_cookie, + _cookie_out, + _cookie_out_light, + _has_huya_perm, + _require_huya_perm, + _visible_huya_accounts_query, + csv, + defer, + get_current_user, + get_db, + io, + normalize_huya_cookie, + or_, + rebuild_web_cookie_from_raw, + record_audit, +) router = APIRouter() diff --git a/web/backend/routers/huya/devices.py b/web/backend/routers/huya/devices.py index c01874b..c90c0bb 100644 --- a/web/backend/routers/huya/devices.py +++ b/web/backend/routers/huya/devices.py @@ -2,7 +2,25 @@ from fastapi import APIRouter -from ._common import * +from ._common import ( + _DEVICE_BINDINGS_PERMS_VIEW, + Depends, + HTTPException, + HuyaAccount, + HuyaDeviceBindingsBatchDeleteRequest, + Session, + User, + _fp_state_dir, + _load_profile_db, + _mask_hex, + _require_huya_perm, + _save_profile_db, + _unbind_device_accounts, + get_current_user, + get_db, + shutil, + user_has_permission, +) router = APIRouter() diff --git a/web/backend/routers/huya/login.py b/web/backend/routers/huya/login.py index ab0fc83..bbbecb7 100644 --- a/web/backend/routers/huya/login.py +++ b/web/backend/routers/huya/login.py @@ -4,7 +4,41 @@ import time from fastapi import APIRouter -from ._common import * +from ._common import ( + UTC, + AccountBulkSelection, + Depends, + HTTPException, + HuyaAccount, + HuyaAppLoginError, + HuyaAppPasswordLoginRequest, + HuyaAppQrAuthRequiredError, + HuyaCredentialError, + HuyaLoginError, + HuyaPasswordLoginRequest, + HuyaPasswordLoginSelectedRequest, + HuyaSmsCodeRequest, + HuyaSmsLoginRequest, + HuyaTestSmsUrlRequest, + Session, + User, + _account_out, + _can_view_huya_cookie, + _require_huya_perm, + _selected_huya_account_ids, + _visible_huya_accounts_query, + cast, + datetime, + get_current_user, + get_db, + logger, + login_huya_app_password, + login_huya_password, + login_huya_sms, + save_huya_login_cookie_to_account, + send_huya_sms_code, + upsert_huya_cookie, +) router = APIRouter() @@ -287,7 +321,12 @@ def auto_sms_login_account( sms_url = (account.sms_url or "").strip() if not phone or not sms_url: raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接") - from core.huya.auto_register import auto_register_huya_phone + # ⚠️ 这里原来是 `auto_register_huya_phone` —— 该名字在 core.huya.auto_register + # 里**从来没存在过**(2026-09-06 起就是死的),每次调用都在 import 阶段抛 + # ImportError → 500。真实函数名是 register_huya_with_sms_line,返回的 + # HuyaAutoRegisterResult 同样有 .success / .cookie / .message。 + # 回归用例:tests/test_huya_auto_sms_login.py + from core.huya.auto_register import register_huya_with_sms_line from core.sms_provider import SmsLine, detect_provider item = SmsLine( @@ -296,7 +335,7 @@ def auto_sms_login_account( provider=detect_provider(sms_url), raw=f"{phone}----{sms_url}", ) - res = auto_register_huya_phone(item, wait_seconds=90, poll_interval=2) + res = register_huya_with_sms_line(item, wait_seconds=90, poll_interval=2) if not res.success or not res.cookie: account.status = "login_failed" account.updated_at = datetime.now(UTC) diff --git a/web/backend/routers/huya/register.py b/web/backend/routers/huya/register.py index 049d596..ea9e098 100644 --- a/web/backend/routers/huya/register.py +++ b/web/backend/routers/huya/register.py @@ -2,7 +2,27 @@ from fastapi import APIRouter -from ._common import * +from ._common import ( + Depends, + HTTPException, + HuyaAutoRegisterBatchOut, + HuyaAutoRegisterRequest, + HuyaAutoRegisterRetryRequest, + HuyaRegisterSuccessLogOut, + ProxyConfig, + Query, + Session, + StreamingResponse, + User, + _require_huya_perm, + export_success_logs_text, + get_current_user, + get_db, + huya_register_registry, + list_success_logs, + parse_sms_lines, + threading, +) router = APIRouter() diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 3469a0e..d8cb98d 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -2,7 +2,52 @@ from fastapi import APIRouter -from ._common import * +from ._common import ( + HUYA_CONFIG_FIELDS, + SUPPORTED_TASK_TYPES, + UTC, + Depends, + HTTPException, + HuyaBatchRunner, + HuyaConfigOut, + HuyaConfigUpdate, + HuyaGoodsOut, + HuyaGoodsSnapshot, + HuyaRechargeGoodsOut, + HuyaRechargeGoodsSnapshot, + HuyaTask, + HuyaTaskBatchRequest, + HuyaTaskOut, + HuyaWorkbench, + HuyaWorkbenchAccount, + HuyaWorkbenchAccountsUpdate, + Query, + Session, + SessionLocal, + User, + WebSocket, + WebSocketDisconnect, + _config_out, + _has_huya_perm, + _huya_task_summary, + _require_huya_batch_owner, + _require_huya_task_account_access, + _task_out, + _visible_huya_tasks_query, + _workbench_account_ids, + apply_huya_config_defaults, + asyncio, + authenticate_websocket, + cleanup_orphan_huya_tasks, + create_planned_tasks, + datetime, + ensure_huya_config, + get_db, + huya_batch_registry, + record_audit, + require_permission, + threading, +) router = APIRouter() diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d131a65..be8dfaa 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -20,7 +20,7 @@ class YybTaskCreateRequest(BaseModel): class YybLoginRequest(BaseModel): provider: str = Field(..., pattern="^(qq|wechat)$") - timeout: int = Field(600, ge=60, le=1800) + timeout: int = Field(default=600, ge=60, le=1800) class YybSelectionRequest(BaseModel): @@ -28,7 +28,7 @@ class YybSelectionRequest(BaseModel): points: int = Field(..., gt=0) product_id: str = Field(..., min_length=1, max_length=128) zone_id: str = Field(..., min_length=1, max_length=64) - zone_name: str = Field("", max_length=128) + zone_name: str = Field(default="", max_length=128) role_id: str = Field(..., min_length=1, max_length=64) role_name: str = Field(..., min_length=1, max_length=128) @@ -91,7 +91,7 @@ class UserInfo(BaseModel): class UserCreate(BaseModel): username: str = Field(..., min_length=2, max_length=64) password: str = Field(..., min_length=6, max_length=128) - role: str = Field("support", pattern="^(super_admin|operation|support)$") + role: str = Field(default="support", pattern="^(super_admin|operation|support)$") remark: str = "" @@ -153,7 +153,7 @@ class AccountBulkSelection(BaseModel): class AccountExportRequest(AccountBulkSelection): """账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。""" - format: str = Field("txt", pattern="^(txt|csv)$") + format: str = Field(default="txt", pattern="^(txt|csv)$") class BatchAssign(AccountBulkSelection): @@ -295,7 +295,7 @@ class LoginBatchRequest(BaseModel): concurrency: int = 3 # 并发数,1-10 api_strategy: str = "wgapi" # 接口策略: wgapi(新版)或 iframe(旧版备选) mode: str = Field( - "login", pattern="^(login|check)$" + default="login", pattern="^(login|check)$" ) # login=登录取CK,check=账号状态检测 @@ -352,9 +352,9 @@ class AccountCheckBatchRequest(BaseModel): status: str = "" ck_status: str = "" bind_status: str = "" - concurrency: int = Field(3, ge=1, le=10) - max_login_retries: int = Field(0, ge=0, le=50) - max_total_time: float = Field(300, ge=0, le=3600) + concurrency: int = Field(default=3, ge=1, le=10) + max_login_retries: int = Field(default=0, ge=0, le=50) + max_total_time: float = Field(default=300, ge=0, le=3600) use_proxy: bool = False # 登录接口策略,与登录任务同一套取值:wgapi(默认,保持原行为)或 iframe。 # 有些人只有其中一套能收到邮箱验证码,所以要能选。 @@ -446,11 +446,11 @@ class HuyaAutoRegisterRequest(BaseModel): text: str = Field(..., min_length=1) tag: str = "" - concurrency: int = Field(1, ge=1, le=5) - wait_seconds: float = Field(180, ge=15, le=600) - poll_interval: float = Field(5, ge=1, le=30) - password_prefix: str = Field("hy", max_length=8) - fixed_password: str = Field("", max_length=64) + concurrency: int = Field(default=1, ge=1, le=5) + wait_seconds: float = Field(default=180, ge=15, le=600) + poll_interval: float = Field(default=5, ge=1, le=30) + password_prefix: str = Field(default="hy", max_length=8) + fixed_password: str = Field(default="", max_length=64) use_proxy: bool = False @@ -463,12 +463,12 @@ class HuyaAutoRegisterRetryRequest(BaseModel): - all_unfinished: 失败+未完成都跑 """ - mode: str = Field("continue", max_length=32) - concurrency: int | None = Field(None, ge=1, le=5) - wait_seconds: float | None = Field(None, ge=15, le=600) - poll_interval: float | None = Field(None, ge=1, le=30) - password_prefix: str | None = Field(None, max_length=8) - fixed_password: str | None = Field(None, max_length=64) + mode: str = Field(default="continue", max_length=32) + concurrency: int | None = Field(default=None, ge=1, le=5) + wait_seconds: float | None = Field(default=None, ge=15, le=600) + poll_interval: float | None = Field(default=None, ge=1, le=30) + password_prefix: str | None = Field(default=None, max_length=8) + fixed_password: str | None = Field(default=None, max_length=64) use_proxy: bool | None = None @@ -550,9 +550,9 @@ class HuyaPasswordLoginSelectedRequest(BaseModel): assigned_only: bool = False has_cookie: bool = False force_new_device: bool = False - max_attempts: int = Field(2, ge=1, le=3) - interval_seconds: float = Field(8, ge=0, le=60) - retry_delay_seconds: float = Field(3, ge=0, le=30) + max_attempts: int = Field(default=2, ge=1, le=3) + interval_seconds: float = Field(default=8, ge=0, le=60) + retry_delay_seconds: float = Field(default=3, ge=0, le=30) class HuyaDeviceBindingsBatchDeleteRequest(BaseModel): @@ -647,7 +647,7 @@ class HuyaConfigUpdate(BaseModel): class HuyaTaskBatchRequest(BaseModel): account_ids: list[int] task_type: str - handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$") + handbook_scope: str = Field(default="legacy", pattern="^(legacy|elite)$") concurrency: int = 3 payload: dict[str, Any] = Field(default_factory=dict) @@ -660,7 +660,7 @@ class HuyaWorkbenchAccountsUpdate(BaseModel): class HuyaBrowserExchangeRequest(BaseModel): """浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。""" - target: str | None = Field(None, pattern="^(live_room|elite|esports)$") + target: str | None = Field(default=None, pattern="^(live_room|elite|esports)$") class HuyaTaskOut(BaseModel): @@ -833,10 +833,10 @@ class DouyuConfigUpdate(BaseModel): confirm_act_alias: str | None = None legacy_act_alias: str | None = None room_id: str | None = None - elite_amount: int | None = Field(None, ge=1) + elite_amount: int | None = Field(default=None, ge=1) esports_manual_id: str | None = None esports_act_alias: str | None = None - esports_amount: int | None = Field(None, ge=1) + esports_amount: int | None = Field(default=None, ge=1) esports_chicken_gift_id: str | None = None esports_chicken_skin_id: str | None = None esports_firework_gift_id: str | None = None @@ -845,12 +845,12 @@ class DouyuConfigUpdate(BaseModel): xpd_act_alias: str | None = None xpd_act_id: str | None = None xpd_rid: str | None = None - gold_pay_type: int | None = Field(None, ge=1, le=9) + gold_pay_type: int | None = Field(default=None, ge=1, le=9) gold_recharge_channel: str | None = Field( - None, pattern="^(wechat_qr|supplier_api)$" + default=None, pattern="^(wechat_qr|supplier_api)$" ) - gold_api_product_id: str | None = Field(None, max_length=128) - gold_api_account_template_name: str | None = Field(None, max_length=64) + gold_api_product_id: str | None = Field(default=None, max_length=128) + gold_api_account_template_name: str | None = Field(default=None, max_length=64) gift_id: str | None = None skin_id: str | None = None @@ -859,7 +859,7 @@ class DouyuTaskBatchRequest(BaseModel): account_ids: list[int] task_type: str handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$") - concurrency: int = Field(3, ge=1, le=10) + concurrency: int = Field(default=3, ge=1, le=10) payload: dict[str, Any] = Field(default_factory=dict) diff --git a/web/backend/services/automation/jobs/douyu_tasks_retention.py b/web/backend/services/automation/jobs/douyu_tasks_retention.py index 1e2a87d..14a0e4d 100644 --- a/web/backend/services/automation/jobs/douyu_tasks_retention.py +++ b/web/backend/services/automation/jobs/douyu_tasks_retention.py @@ -41,10 +41,10 @@ from __future__ import annotations import json from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, cast from loguru import logger -from sqlalchemy import text +from sqlalchemy import CursorResult, text from sqlalchemy.orm import Session from ....models import AutomationJob @@ -133,7 +133,9 @@ def _delete_batch( ), params, ) - return int(result.rowcount or 0) + # Session.execute 的静态返回类型是 Result[Any],但 DML 语句实际拿到的是 + # CursorResult(rowcount 只定义在它上面)。运行期行为不变,只是把这一点写实。 + return int(cast(CursorResult[Any], result).rowcount or 0) def run_douyu_tasks_retention(db: Session, job: AutomationJob) -> dict[str, Any]: diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py index f277910..3e4fdd4 100644 --- a/web/backend/services/douyu_runner_manual.py +++ b/web/backend/services/douyu_runner_manual.py @@ -211,9 +211,11 @@ class ManualMixin: account.esports_opened = manual_type is not None and manual_type >= 1 # 到期时间也落库 —— 否则界面只能显示"已开通",看不到什么时候到期 expire_raw = result.get("expire_time") - expire_int = ( - int(expire_raw) if str(expire_raw or "").strip().isdigit() else None - ) + # 先规整成字符串再判 isdigit:原来写 `int(expire_raw) if str(expire_raw + # or "").strip().isdigit()` —— 判的是 A、转的是 B,类型检查器收窄不了, + # 而且同一份值取了两遍。规整到一个局部变量后两个问题一起没了。 + expire_text = str(expire_raw or "").strip() + expire_int = int(expire_text) if expire_text.isdigit() else None # ⚠️ 斗鱼用 0 表示「没有到期时间」。直接存 0 的话界面会显示 1970/1/1 # (这个坑在积分到期上踩过一次,这里别再踩)。 account.esports_expire_time = ( diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 678a942..7dd797f 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 not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS) + and "套装" in cname + ): + return it # 3. 兜底匹配不带排除词的项 for it in rows: diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index 2ddda68..612edf7 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -7,7 +7,7 @@ import copy import threading import time from datetime import UTC, datetime -from typing import Any +from typing import Any, Literal from loguru import logger from sqlalchemy.orm import Session @@ -51,7 +51,9 @@ class HuyaBatchRunnerCore: def stop(self): self._stop.set() - def _open_wss_session(self, uid: int, cookie: str, kind: str): + def _open_wss_session( + self, uid: int, cookie: str, kind: Literal["activity", "shop"] + ): """在当前任务线程创建并登记 WSS 会话,统一由 finally 回收。""" from core.huya.elite_session import HuyaEliteWssSession diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py index 3bb156e..ca064d7 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -4,7 +4,7 @@ from __future__ import annotations import time from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from sqlalchemy.orm import Session @@ -40,7 +40,9 @@ class RechargeMixin: def _push_log(self, level: str, message: str) -> None: ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... - def _open_wss_session(self, uid: int, cookie: str, kind: str): ... + def _open_wss_session( + self, uid: int, cookie: str, kind: Literal["activity", "shop"] + ): ... def _shop_client(self, uid: int, cookie: str): ... @staticmethod diff --git a/web/extension/huya-exchange-helper/generate_icons.py b/web/extension/huya-exchange-helper/generate_icons.py index a16c0a9..15b64b0 100644 --- a/web/extension/huya-exchange-helper/generate_icons.py +++ b/web/extension/huya-exchange-helper/generate_icons.py @@ -71,9 +71,9 @@ full = gradient_rounded(S, RADIUS) draw_gift(full) base = "web/extension/huya-exchange-helper/" for size in (128, 48): - full.resize((size, size), Image.LANCZOS).save(f"{base}icon{size}.png") + full.resize((size, size), Image.Resampling.LANCZOS).save(f"{base}icon{size}.png") simple = gradient_rounded(512, 100) draw_gift_simple(simple) -simple.resize((16, 16), Image.LANCZOS).save(f"{base}icon16.png") +simple.resize((16, 16), Image.Resampling.LANCZOS).save(f"{base}icon16.png") print("icons regenerated")