From 9e81421ebac8b10017fb4ab2c7cc187c49632786 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 26 Aug 2026 22:45:52 +0800 Subject: [PATCH] fix(indexer): close quality ratchet regression --- app/modules/indexer/parser/__init__.py | 7 ++- app/modules/indexer/parser/rousi.py | 21 ++++---- docs/refactor/backend-architecture-review.md | 39 ++++++++++++++- .../fixtures/architecture/mypy-baseline.json | 8 +-- .../fixtures/architecture/ruff-baseline.json | 6 --- tests/test_rousi_spider.py | 50 +++++++++++++++++++ 6 files changed, 104 insertions(+), 27 deletions(-) diff --git a/app/modules/indexer/parser/__init__.py b/app/modules/indexer/parser/__init__.py index b9dea832b..6c94616c0 100644 --- a/app/modules/indexer/parser/__init__.py +++ b/app/modules/indexer/parser/__init__.py @@ -8,13 +8,12 @@ from urllib.parse import urljoin, urlsplit from requests import Session -from app.runtime.settings import get_runtime_setting - from app.adapters.network.cloudflare import under_challenge -from app.runtime.log import logger from app.adapters.network.http import RequestUtils from app.domain.site import SiteUtils from app.foundation import size as size_tools +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting # 站点框架 @@ -170,7 +169,7 @@ class SiteParserBase(metaclass=ABCMeta): self._torrent_seeding_headers = None # 错误信息 - self.err_msg = None + self.err_msg: Optional[str] = None def site_schema(self) -> SiteSchema: """ diff --git a/app/modules/indexer/parser/rousi.py b/app/modules/indexer/parser/rousi.py index cd9b561c1..586625ee0 100644 --- a/app/modules/indexer/parser/rousi.py +++ b/app/modules/indexer/parser/rousi.py @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- import json -from urllib.parse import urljoin from typing import Optional, Tuple - -from app.runtime.log import logger -from app.runtime.settings import get_runtime_setting +from urllib.parse import urljoin from app.adapters.network.http import RequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools from app.modules.indexer.parser import SiteParserBase, SiteSchema +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting class RousiSiteUserInfo(SiteParserBase): @@ -86,15 +85,12 @@ class RousiSiteUserInfo(SiteParserBase): return if not isinstance(data, dict): - # 基类把 err_msg 推断为 None,运行时契约允许解析器写入错误文本。 - self.err_msg = "用户数据响应结构无效" # type: ignore[assignment] + self.err_msg = "用户数据响应结构无效" logger.warning(f"{self._site_name} API 响应结构无效") return if data.get("code") != 0: - self.err_msg = str( # type: ignore[assignment] - data.get("message") or "未知错误" - ) + self.err_msg = str(data.get("message") or "未知错误") logger.warning(f"{self._site_name} API 错误: {self.err_msg}") return @@ -108,8 +104,11 @@ class RousiSiteUserInfo(SiteParserBase): self.user_level = user_info.get("level_text") or user_info.get("role_text") # 注册时间:统一格式为 YYYY-MM-DD HH:MM:SS - join_at = time_tools.normalize_datetime( - str(user_info.get("registered_at") or "") + registered_at = user_info.get("registered_at") + join_at = ( + time_tools.normalize_datetime(registered_at) + if isinstance(registered_at, str) + else None ) if join_at: # 确保格式为 YYYY-MM-DD HH:MM:SS (19位) diff --git a/docs/refactor/backend-architecture-review.md b/docs/refactor/backend-architecture-review.md index d2373e141..68be61e68 100644 --- a/docs/refactor/backend-architecture-review.md +++ b/docs/refactor/backend-architecture-review.md @@ -86,8 +86,9 @@ | 0 | 历史任务清账、现行架构图、外部契约核对和宿主基线对齐 | 已推送 | `d234c7132`;远端同 SHA;ahead/behind `0/0`;架构契约 `71 passed` | | 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已推送 | `6062b0661`;远端同 SHA;ahead/behind `0/0`;Mypy 11994、Ruff 976 | | 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 已推送 | `265d3c6d1`;远端同 SHA;ahead/behind `0/0`;Application 77.76%,Domain 79.24% | -| 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 已全量验证 | generation owner 已实现;全量 `6391 passed, 6 skipped`;等待提交推送 | -| Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 进行中 | 本地门禁已通过;等待批次 3 推送及远端 0/0 复核 | +| 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 已推送 | `50b85235d`;远端同 SHA;ahead/behind `0/0`;全量 `6391 passed, 6 skipped` | +| 4 | 收口并行 Rousi 提交暴露的质量门禁回退 | 已全量验证 | 最终合并 SHA 全量 `6404 passed, 6 skipped`;Mypy 11983、Ruff 973;等待提交推送 | +| Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 本地完成 | 等待批次 4 推送及最终远端 0/0 复核 | ### 批次 0:审计与基线对齐 @@ -195,6 +196,35 @@ Gateway join 预算后保留 owner 并允许再次停止;故障注入证明首 官方插件 ABI 语义门禁均通过,架构/质量专项 `50 passed`。插件参考仓只有不参与语义判定的 schema/provenance 漂移,且本地分支落后其远端 2 个提交,本轮保持参考仓只读且不刷新指纹。 +交付证据:提交在并行 Rousi 和 Ruff baseline 提交之后重放为 `50b85235d`,已推送到 +`origin/v3`;`git ls-remote` 返回同一 SHA,`HEAD...origin/v3` 为 `0/0`。 + +### 批次 4:并行提交质量门禁集成 + +批次 3 最终回归期间,远端合入 `5484223d1`(Rousi PeerGo 个人 API Key)和 +`b1c415089`(对应 Spider Ruff 低水位)。前者行为专项通过,但在 fail-closed 门禁下暴露两条 +新增 Mypy 错误;Parser 触达文件和基类还各有一条已消除但未固化的 Ruff 导入债务。 + +准入边界:只修复当前主线提交造成的类型回退及同文件低水位,不扩张站点解析器重构。基类 +`err_msg` 显式标注为真实的 `Optional[str]`,Rousi 在调用时间解析器前验证 `registered_at` +为字符串;由此净消除 10 条既有 assignment 债务。触达文件导入排序完成后,Mypy 低水位 +从 11993 收紧为 11983,Ruff 从远端固化后的 975 继续收紧为 973。 + +停止条件:Rousi 行为、解析器相关回归、Mypy/Ruff 只读 ratchet、适用架构门禁和最终全量 +测试通过;批次独立提交推送;台账定稿后远端保持 `0/0`。 + +本地验收结果: + +* Rousi `14 passed`;与 SunnyPT、TorrentLeech 和 Yema 合计 `33 passed`,触达文件 Ruff 通过, + Pylint `10.00/10`; +* Mypy `11983`、Ruff `973` 的只读 ratchet 通过,Coverage 仍为 Application 77.76%、 + Domain 79.24%;锁文件、Schema、宿主架构、复杂度、async blocking、service locator、 + task ownership 和官方插件 ABI 语义门禁均通过;架构/质量专项 `50 passed`; +* 在不含共享工作树并行改动的干净 detached worktree 中,包含并行 Rousi 提交和本批源码/ + 测试的 4 分片全量回归为 `6402 passed, 6 skipped`;随后远端 `ee53d6df0` 交付 #6449 + durable event 修复,其 blob 与共享工作树副本逐字一致。本批重放到该提交后,durable 专项 + `5 passed`,最终合并 SHA 的 4 分片全量为 `6404 passed, 6 skipped`。 + ## 五、验证矩阵 每个批次按改动范围选择下列命令,Final 全部执行: @@ -240,6 +270,11 @@ git rev-list --left-right --count HEAD...origin/v3 | 2026-08-26 | 批次 3 启动 | 仅收口阶段 62 已记录的 QQ heartbeat owner,不扩张公开 `QQBot.stop()` 合同 | | 2026-08-26 | 批次 3 专项验收 | 三条故障注入并行重复 4 轮稳定;生命周期 `96 passed`;静态、所有权和架构门禁通过 | | 2026-08-26 | 批次 3 全量验收 | 最终快照 4 分片 `6391 passed, 6 skipped`;全部适用主仓门禁通过;插件 ABI 语义无变化 | +| 2026-08-26 | 批次 3 交付 | `50b85235d` 已推送;远端同 SHA;ahead/behind `0/0` | +| 2026-08-26 | 批次 4 启动 | 只收口并行 Rousi 提交的类型回退和触达文件低水位;不扩大解析器重构 | +| 2026-08-26 | 批次 4 本地验收 | 解析器 `33 passed`;干净 worktree 全量 `6402 passed, 6 skipped`;全部适用门禁通过 | +| 2026-08-26 | 同步并行 #6449 | `ee53d6df0` 已在远端;共享工作树 blob 完全相同,rebase 后无重复 diff | +| 2026-08-26 | 最终合并验收 | durable `5 passed`;最终 SHA 全量 `6404 passed, 6 skipped`;质量门禁通过 | ## 七、本轮停止条件 diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c0dd84e93..76c498ce8 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -2258,7 +2258,7 @@ }, "app/modules/indexer/parser/__init__.py": { "arg-type": 20, - "assignment": 5, + "assignment": 4, "comparison-overlap": 1, "no-untyped-call": 4, "no-untyped-def": 14, @@ -2363,7 +2363,7 @@ }, "app/modules/indexer/parser/sunnypt.py": { "arg-type": 2, - "assignment": 14, + "assignment": 10, "type-arg": 2 }, "app/modules/indexer/parser/tnode.py": { @@ -2373,7 +2373,7 @@ "type-arg": 1 }, "app/modules/indexer/parser/torrent_leech.py": { - "assignment": 8, + "assignment": 7, "no-untyped-call": 2, "type-arg": 1 }, @@ -2384,7 +2384,7 @@ "type-arg": 1 }, "app/modules/indexer/parser/yema.py": { - "assignment": 17, + "assignment": 13, "type-arg": 1 }, "app/modules/indexer/parser/zhixing.py": { diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 365a75d2e..bf376b392 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -692,9 +692,6 @@ "app/modules/indexer/__init__.py": { "I001": 1 }, - "app/modules/indexer/parser/__init__.py": { - "I001": 1 - }, "app/modules/indexer/parser/bitpt.py": { "I001": 1 }, @@ -728,9 +725,6 @@ "app/modules/indexer/parser/nexus_rabbit.py": { "I001": 1 }, - "app/modules/indexer/parser/rousi.py": { - "I001": 1 - }, "app/modules/indexer/parser/small_horse.py": { "I001": 1 }, diff --git a/tests/test_rousi_spider.py b/tests/test_rousi_spider.py index 37b462e77..3339003a7 100644 --- a/tests/test_rousi_spider.py +++ b/tests/test_rousi_spider.py @@ -40,6 +40,18 @@ def _build_indexer(apikey: str = "rousi-secret", proxy: bool = False) -> dict: } +def _build_user_parser() -> RousiSiteUserInfo: + """构造无需真实网络请求的 Rousi 用户数据解析器。""" + return RousiSiteUserInfo( + site_name="Rousi Pro", + url="https://rousi.pro/", + site_cookie="", + apikey="rousi-secret", + token=None, + ua="MoviePilot-Test", + ) + + @pytest.fixture() def rousi_spider(monkeypatch): """构造不依赖真实数据库配置的 RousiSpider。""" @@ -228,6 +240,44 @@ def test_user_parser_reads_peergo_profile_with_personal_api_key(monkeypatch): assert parser.leeching_size == 10737418240 +@pytest.mark.parametrize( + ("payload", "expected_error"), + [ + ([], "用户数据响应结构无效"), + ({"code": 1, "message": 619}, "619"), + ({"code": 1, "message": None}, "未知错误"), + ], +) +def test_user_parser_normalizes_profile_errors( + payload: object, + expected_error: str, +) -> None: + """无效资料响应必须给调用方稳定的字符串错误消息。""" + parser = _build_user_parser() + + parser._parse_user_base_info(json.dumps(payload)) + + assert parser.err_msg == expected_error + + +@pytest.mark.parametrize("registered_at", [None, 619, {"unexpected": "value"}]) +def test_user_parser_ignores_non_string_registration_time( + registered_at: object, +) -> None: + """非字符串注册时间不得被强转为看似有效的日期输入。""" + parser = _build_user_parser() + payload = { + "code": 0, + "data": { + "registered_at": registered_at, + }, + } + + parser._parse_user_base_info(json.dumps(payload)) + + assert parser.join_at is None + + def test_site_connectivity_uses_peergo_personal_api_key(monkeypatch): """Rousi 连接测试应以 Bearer 个人 API Key 请求兼容资料接口。""" captured = {}