From 9bcebc2c527c3294e4923a02fe09d438e740bff6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 18 Oct 2026 10:37:19 +0800 Subject: [PATCH] fix(community): prewarm and refresh medal snapshots in background --- app/application/community/medals.py | 226 +++++++++++++---- app/application/system.py | 2 + app/domain/github.py | 2 + app/schemas/github.py | 1 + app/startup/composition/system.py | 17 +- app/startup/lifecycle/__init__.py | 20 ++ docs/architecture-overview.md | 6 +- docs/refactor/optimization-checklist.md | 2 +- docs/testing.md | 5 + .../architecture/dependency-baseline.json | 7 +- .../startup-performance-baseline.json | 6 +- tests/test_community_medal_background.py | 233 ++++++++++++++++++ tests/test_community_medals.py | 69 ++++-- tests/test_lifecycle_shutdown.py | 8 + tests/test_system_application.py | 17 ++ 15 files changed, 534 insertions(+), 87 deletions(-) create mode 100644 tests/test_community_medal_background.py diff --git a/app/application/community/medals.py b/app/application/community/medals.py index 972d7f495..ea93cc1c0 100644 --- a/app/application/community/medals.py +++ b/app/application/community/medals.py @@ -1,5 +1,5 @@ """ -社区勋章查询:核验实例 GitHub 身份、读取 Wiki 名单并按凭据隔离缓存。 +社区勋章后台预热与定期核验;页面请求仅读取按实例凭据隔离的内存快照。 """ from __future__ import annotations @@ -10,13 +10,11 @@ import time from collections.abc import Awaitable, Callable from dataclasses import replace -from app.domain.github import ( - GithubAuthTransportError, - GithubCommunityPort, - GithubCommunityProfile, -) +from app.domain.github import GithubAuthTransportError, GithubCommunityPort, GithubCommunityProfile +from app.runtime.log import logger +from app.runtime.tasks import TaskRegistry -# 名单由 Wiki 定时任务更新,实例侧缓存六小时;主动刷新也至少间隔一分钟,避免放大外部请求。 +# 后台每分钟检查身份与有效开关,名单正常六小时更新一次;失败或主动刷新至少间隔一分钟。 _CACHE_SECONDS = 21600 _REFRESH_COOLDOWN_SECONDS = 60 _QUERY_TIMEOUT_SECONDS = 45 @@ -24,78 +22,202 @@ _QUERY_TIMEOUT_SECONDS = 45 class CommunityMedalService: """ - 按实例 Token 对应的 GitHub 数字 ID 查询社区勋章,不向普通本地用户继承荣誉。 + 主动维护实例 GitHub 身份的勋章快照,不把核验和名单下载放进用户查询链路。 - 计算与人工颁发都在 Wiki 名单完成,实例只核验身份并查表;身份只认 ``GET /user`` - 返回的数字 ID,不接受前端或参数声明的用户名。功能是否开放由本机设置与 MoviePilot - Server 远程开关共同决定,关闭时不核验身份也不拉取名单。 + 后台任务由 lifespan 的 TaskRegistry 取消及回收;请求只检查本地凭据和本机开关, + 不等待后台刷新锁、OAuth 刷新或远端功能开关。旧结果只允许在同一凭据下继续展示。 """ def __init__( - self, *, token_reader: Callable[[], Awaitable[str]], transport: GithubCommunityPort, - enabled_reader: Callable[[], Awaitable[bool]], clock: Callable[[], float] = time.time, + self, *, token_reader: Callable[[], Awaitable[str]], token_snapshot: Callable[[], str], + transport: GithubCommunityPort, enabled_reader: Callable[[], Awaitable[bool]], + local_enabled: Callable[[], bool], clock: Callable[[], float] = time.time, ) -> None: """ - 注入服务端凭据、传输端口与远程开关;缓存只保存 Token 摘要键和公开名单记录。 + 注入后台使用的外部能力和查询路径使用的无网络本地快照。 - ``enabled_reader`` 读取本机与服务端合成的社区勋章有效开关,取不到时必须返回 False; - 未配置 Token 时它应按服务端全局开关作答,使已开放的功能返回 ``unconfigured``。 + :param token_reader: 仅由后台使用的可刷新凭据读取器 + :param token_snapshot: 只读当前本地凭据,不触发 OAuth 刷新 + :param transport: GitHub 身份和公开勋章名单传输端口 + :param enabled_reader: 后台核验本机与远程合成的有效开关 + :param local_enabled: 无 I/O 的本机开关读取器,关闭后立即隐藏快照 + :param clock: 返回 Unix 秒的时钟,测试可注入 """ self._token_reader = token_reader + self._token_snapshot = token_snapshot + self._local_enabled = local_enabled self._enabled_reader = enabled_reader self._transport = transport self._clock = clock self._lock = asyncio.Lock() + self._wake = asyncio.Event() + self._task: asyncio.Task[None] | None = None self._key = "" + self._revision = 0 + self._enabled: bool | None = None self._profile = GithubCommunityProfile() self._attempted_at = float("-inf") + self._updating = False + self._force_refresh = False + + def start(self, tasks: TaskRegistry) -> None: + """ + 启动唯一后台预热任务,不等待网络;重复启动不会重复创建任务。 + + :param tasks: 当前 lifespan 的后台任务登记器,负责取消和有限等待 + """ + if self._task is None or self._task.done(): + self._task = tasks.create(self._run(), owner="community.medals") + + @property + def enabled(self) -> bool: + """ + 返回当前身份已经核验的有效开关,供全局用户设置直接读取。 + + :return: 本机开启且后台已确认对当前凭据开放 + """ + self._synchronize_identity() + return self._local_enabled() and self._enabled is True + + @property + def refreshing(self) -> bool: + """ + 返回后台是否尚在预热或刷新,供前端短暂查询结果而不启动新的外部核验。 + + :return: 已启动且尚未完成的后台刷新状态 + """ + return bool( + self._task is not None and not self._task.done() + and (self._enabled is None or self._updating or self._force_refresh) + and self._local_enabled() + ) + + def invalidate(self) -> None: + """ + 配置或授权变更后立即撤销旧显示,并唤醒已有后台任务重新核验。 + """ + self._revision += 1 + self._key = "" + self._enabled = None + self._profile = GithubCommunityProfile() + self._attempted_at = float("-inf") + self._wake.set() + + def _synchronize_identity(self) -> str: + """ + 仅按本地凭据摘要撤销旧身份;在途外部请求不能重新发布撤销前的结果。 + + :return: 当前凭据摘要,未配置时为空串 + """ + token = self._token_snapshot() + key = hashlib.sha256(token.encode()).hexdigest() if token else "" + if key != self._key: + self._revision += 1 + self._key = key + self._enabled = None + self._profile = GithubCommunityProfile(state="unavailable" if token else "unconfigured") + self._attempted_at = float("-inf") + self._wake.set() + return key async def profile(self, *, refresh: bool = False) -> GithubCommunityProfile: """ - 合并并发查询并按凭据缓存;名单拉取失败保留同一 Token 的上次结果并标记过期。 + 直接返回当前快照;主动刷新只通知后台,不等待任何外部请求或刷新锁。 - 开关关闭时直接返回 ``disabled``,不触发任何 GitHub 请求,也不改动已有缓存, - 以便开关重新打开后沿用冷却与缓存策略。 + :param refresh: 请求后台尽快刷新,仍遵守一分钟冷却 + :return: 同一凭据的当前快照,以及后台刷新状态 """ - if not await self._enabled_reader(): + key = self._synchronize_identity() + if not self._local_enabled() or self._enabled is False: return GithubCommunityProfile(state="disabled") + if refresh and self._clock() - self._attempted_at >= _REFRESH_COOLDOWN_SECONDS: + self._force_refresh = True + self._wake.set() + profile = self._profile if self._enabled else GithubCommunityProfile(state="unavailable") + stale = profile.stale or bool( + key and profile.checked_at is not None and self._clock() - profile.checked_at >= _CACHE_SECONDS + ) + return replace(profile, stale=stale, refreshing=self.refreshing) + + async def update(self, *, force: bool = False) -> None: + """ + 后台检查有效开关,并在首次、到期或失败冷却后更新名单。 + + :param force: 忽略六小时名单周期,但不绕过一分钟冷却 + """ async with self._lock: - token = await self._token_reader() - key = hashlib.sha256(token.encode()).hexdigest() if token else "" - if key != self._key or not token: - self._key = key - self._profile = GithubCommunityProfile() - self._attempted_at = float("-inf") - if not token: - return self._profile - now = self._clock() - elapsed = now - self._attempted_at - if elapsed < _REFRESH_COOLDOWN_SECONDS or ( - not refresh and elapsed < _CACHE_SECONDS and not self._profile.stale - and self._profile.state == "ready" - ): - return self._profile - self._attempted_at = now + self._synchronize_identity() + if not self._local_enabled(): + self._enabled = False + return + revision = self._revision + self._updating = True try: async with asyncio.timeout(_QUERY_TIMEOUT_SECONDS): + enabled = await self._enabled_reader() + if not self._current(revision): + return + self._enabled = enabled + if not enabled or not self._key: + return + elapsed = self._clock() - self._attempted_at + if elapsed < _REFRESH_COOLDOWN_SECONDS or ( + not force and elapsed < _CACHE_SECONDS and not self._profile.stale + and self._profile.state == "ready" + ): + return + self._attempted_at = self._clock() + token = await self._token_reader() + if not self._current(revision) or hashlib.sha256(token.encode()).hexdigest() != self._key: + return user = await self._transport.get_user(token) if user.user_id <= 0: raise GithubAuthTransportError("GitHub 身份无效") snapshot = await self._transport.get_medal_snapshot(user.user_id) - # Token 在查询期间切换时,旧账号的异步结果不能重新点亮入口。 - if await self._token_reader() != token: - self._key = "" - self._profile = GithubCommunityProfile() - return self._profile - self._profile = GithubCommunityProfile( - state="ready", login=user.login, user_id=user.user_id, medals=snapshot.medals, - generated_at=snapshot.generated_at, checked_at=int(now), - ) + if not self._current(revision): + return + self._profile = GithubCommunityProfile( + state="ready", login=user.login, user_id=user.user_id, medals=snapshot.medals, + generated_at=snapshot.generated_at, checked_at=int(self._clock()), + ) except (GithubAuthTransportError, TimeoutError) as error: - if isinstance(error, GithubAuthTransportError) and error.unauthorized: - self._profile = GithubCommunityProfile(state="unavailable") - else: - self._profile = replace(self._profile, state=( - "ready" if self._profile.checked_at is not None else "unavailable" - ), stale=True) - return self._profile + if self._current(revision): + if isinstance(error, GithubAuthTransportError) and error.unauthorized: + self._profile = GithubCommunityProfile(state="unavailable") + else: + self._profile = replace(self._profile, stale=True) + except Exception: + if self._current(revision): + self._profile = replace(self._profile, stale=True) + raise + finally: + self._updating = False + + def _current(self, revision: int) -> bool: + """ + 发布外部结果前重新检查本地身份与开关,禁止配置变更后回填旧账号。 + + :param revision: 发起查询时的身份世代 + :return: 结果是否仍可发布到当前快照 + """ + self._synchronize_identity() + return revision == self._revision and self._local_enabled() + + async def _run(self) -> None: + """ + 启动即预热,之后每分钟维护开关并按需更新名单;故障不会终止后续自动重试。 + """ + while True: + force = self._force_refresh + self._force_refresh = False + self._wake.clear() + try: + await self.update(force=force) + except Exception: + # 只记录固定消息,不把凭据读取异常的内容写入日志。 + logger.warning("社区勋章后台更新失败,将在下一周期重试") + try: + async with asyncio.timeout(_REFRESH_COOLDOWN_SECONDS): + await self._wake.wait() + except TimeoutError: + continue diff --git a/app/application/system.py b/app/application/system.py index ec49d781e..1885b68f0 100644 --- a/app/application/system.py +++ b/app/application/system.py @@ -386,6 +386,8 @@ class SystemService: result = self.settings.update_many(env) success_updates = {key: value for key, value in result.items() if value[0]} failed_updates = {key: value for key, value in result.items() if value[0] is False} + if self.github_community is not None and {"GITHUB_TOKEN", "COMMUNITY_MEDALS_ENABLE"}.intersection(success_updates): + self.github_community.invalidate() if "GITHUB_TOKEN" in success_updates and self.github_auth is not None: await self.github_auth.forget_oauth_metadata() if failed_updates: diff --git a/app/domain/github.py b/app/domain/github.py index 2b1344d5d..6134fdf00 100644 --- a/app/domain/github.py +++ b/app/domain/github.py @@ -111,6 +111,7 @@ class GithubCommunityProfile: :param generated_at: 名单生成时间文本;名单不可用时为 None :param checked_at: 本机最近检查的时间戳,单位为秒;尚未检查时为 None :param stale: 当前结果是否来自已过期但仍允许展示的缓存 + :param refreshing: 后台正在预热或更新,读取接口不会等待其完成 """ state: str = "unconfigured" @@ -120,6 +121,7 @@ class GithubCommunityProfile: generated_at: str | None = None checked_at: int | None = None stale: bool = False + refreshing: bool = False class GithubCommunityPort(Protocol): diff --git a/app/schemas/github.py b/app/schemas/github.py index 0f8334a54..5051994c5 100644 --- a/app/schemas/github.py +++ b/app/schemas/github.py @@ -40,6 +40,7 @@ class GithubCommunityProfile(BaseModel): # type: ignore[misc] generated_at: str | None = Field(default=None, description="Wiki 名单的生成时间(ISO 8601 UTC)") checked_at: int | None = Field(default=None, description="实例最近一次成功核对名单的时间戳") stale: bool = Field(default=False, description="最近一次刷新失败,当前为同一 Token 的上次结果") + refreshing: bool = Field(default=False, description="后台正在预热或更新;接口已立即返回当前快照") # Pydantic 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。 diff --git a/app/startup/composition/system.py b/app/startup/composition/system.py index 40aa17a13..baaeefd6a 100644 --- a/app/startup/composition/system.py +++ b/app/startup/composition/system.py @@ -257,6 +257,8 @@ class _ServerInfoAdapter(ServerInfoPort): 保存运行设置提供器,用于读取本机社区勋章开关。 """ self._settings = settings + # 组合根绑定同一勋章服务,用户设置只读后台已经完成的开关核验。 + self.community: CommunityMedalService | None = None async def user_global(self) -> dict[str, Any]: """ @@ -267,7 +269,8 @@ class _ServerInfoAdapter(ServerInfoPort): "USER_UNIQUE_ID": MoviePilotServerHelper.get_user_uuid(), "SUBSCRIBE_SHARE_MANAGE": is_admin, "WORKFLOW_SHARE_MANAGE": is_admin, - "COMMUNITY_MEDALS_ENABLED": await self.community_medals_enabled(), + "COMMUNITY_MEDALS_ENABLED": self.community.enabled if self.community else False, + "COMMUNITY_MEDALS_REFRESHING": self.community.refreshing if self.community else False, } async def community_medals_enabled(self) -> bool: @@ -443,6 +446,13 @@ def compose_system_service( client_id=GITHUB_DEVICE_CLIENT_ID, ) server_info = _ServerInfoAdapter(settings) + community = CommunityMedalService( + token_reader=github_auth.access_token, + token_snapshot=lambda: str(settings.get("GITHUB_TOKEN") or "").strip(), + local_enabled=lambda: bool(settings.get("COMMUNITY_MEDALS_ENABLE")), + transport=github_client, enabled_reader=server_info.community_medals_enabled, + ) + server_info.community = community return SystemService( settings=settings, system_config=system_config, @@ -458,8 +468,5 @@ def compose_system_service( plugin_mutation=plugin_system_config_mutation, rule_group_mutation=rule_group_mutation, github_auth=github_auth, - github_community=CommunityMedalService( - token_reader=github_auth.access_token, transport=github_client, - enabled_reader=server_info.community_medals_enabled, - ), + github_community=community, ) diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 50e19059f..4d98b1191 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -306,6 +306,19 @@ async def stop_modules_component(app: FastAPI) -> bool: return converged +def initialize_community_medals(app: FastAPI) -> None: + """ + 将实例勋章预热交给宿主任务登记器,启动阶段不等待任何外部请求。 + + :param app: 已发布 HostRuntime 和后台任务登记器的应用 + """ + runtime = getattr(app.state, "host_runtime", None) + if not isinstance(runtime, HostRuntime): + raise RuntimeError("HostRuntime 尚未发布,无法维护社区勋章缓存") + if runtime.system.github_community is not None: + runtime.system.github_community.start(app.state.task_registry) + + def initialize_log_runtime(app: FastAPI) -> None: """ 创建并发布当前 lifespan 独占的文件日志 writer。 @@ -530,6 +543,13 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: start_timeout_seconds=300, stop_timeout_seconds=300, ), + LifecycleComponent( + name="社区勋章缓存", + dependencies=("模块服务", "后台任务登记器"), + start=lambda: initialize_community_medals(app), + start_order=71, + start_timeout_seconds=30, + ), LifecycleComponent( name="插件服务装配", dependencies=("模块服务",), diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d4f9e4064..a9be416cb 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -763,7 +763,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1123 | -| 内部导入边 | 9,576 | +| 内部导入边 | 9,579 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 49(债务已清零,49 条精确 containment) | @@ -778,7 +778,7 @@ Outbox 按 topic 有界并行恢复复用 `ThreadHelper`,生命周期关闭通 外部 MCP schema 紧凑投影由纯函数模块 `app.agent.tools.projection` 拥有,仅在请求头显式 opt-in 后应用;新增 1 个模块和 1 条合法端点依赖边,未新增 SCC。`list_exposed_tools` 返回类型注解使端点类型债务减少 5 项。 -目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,576;站点 API 依赖显式导入 SiteQueryPort 与 SiteStagingPort,新增 1 条合法类型边;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,579;站点 API 依赖显式导入 SiteQueryPort 与 SiteStagingPort,新增 1 条合法类型边;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 @@ -863,3 +863,5 @@ Jellyfin webhook 路径恢复复用 `app.runtime.cache.MemoryBackend` 的有界 插件只发送一份结构化结果和纯文本回退,不包含渠道名称或平台格式逻辑。 IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法依赖边(含父包展开),未新增模块、循环或越层依赖。 + +社区勋章缓存由启动生命周期主动预热,并通过宿主 TaskRegistry 定期维护;查询只读本地快照,不等待远端功能开关、身份核验或名单下载。新增 3 条合法 Application 到 Runtime 依赖边(含父包展开),用于任务归属和固定消息日志,未新增模块、SCC、越层依赖或直接网络出口。 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index d85fc5b92..4a979a52a 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1123 / 9,576 | Outbox 恢复的日志与关闭屏障新增 5 条合法依赖边(含父包展开),未新增模块或 SCC。 `dependency-baseline.json` 当前快照;豆瓣短评与影评新增 `app.schemas.comment` 一个模块和 3 条合法依赖边(含父包展开),模块方法契约同步登记同步与异步入口,未新增 SCC 或跨层直连。 `dependency-baseline.json` 当前快照;站点 API 依赖显式导入 `SiteQueryPort` 与 `SiteStagingPort`,新增 1 条合法类型边;集数定位直接导入 FileItem owner,替换 1 条间接导出依赖边,模块数、边数、SCC 与边界不变;监控分发器直接导入 FileItem owner,以 1 条 Schema 依赖边替换间接导出依赖,模块数、边数、SCC 与边界不变;工作流扫描、整理与下载动作直接复用已有 Schema owner,新增 3 条合法依赖边,模块数、SCC 与边界不变;工作流媒体过滤与下载查询复用已有 Schema 元素类型,新增 2 条合法依赖边,模块数、SCC 与边界不变;TheAudioDB 相关艺术家结果复用既有领域类型,新增 2 条合法 Chain 到 Domain 类型依赖边(含父包展开),模块数、SCC 与边界不变;群晖 Chat 渠道类型改为直接导入已有 Schema owner,移除 1 条间接导出依赖边,模块数、SCC 与边界不变;系统链、转移规划、订阅查询和规则服务改为直接导入已有 Schema owner,新增 2 条 owner 依赖边并移除 4 条间接导出依赖边,模块数、SCC 与层间边界不变;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖;TMDb 认证与片单读取复用模块既有异常,新增 2 条同模块依赖边;Session 类型使扫描器识别原有两处 close,发送与会话关闭路径未变;Audiences 构造参数复用既有 RequestUtils 类型,新增 3 条合法依赖边(含父包展开);插件实例历史脏行清理复用既有 `app.schemas.plugin.validate_plugin_id` 进行 fail-closed 身份校验,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC | +| 宿主 Python 模块 / 内部依赖边 | 1123 / 9,579 | Outbox 恢复的日志与关闭屏障新增 5 条合法依赖边(含父包展开),未新增模块或 SCC。 `dependency-baseline.json` 当前快照;豆瓣短评与影评新增 `app.schemas.comment` 一个模块和 3 条合法依赖边(含父包展开),模块方法契约同步登记同步与异步入口,未新增 SCC 或跨层直连。 `dependency-baseline.json` 当前快照;站点 API 依赖显式导入 `SiteQueryPort` 与 `SiteStagingPort`,新增 1 条合法类型边;集数定位直接导入 FileItem owner,替换 1 条间接导出依赖边,模块数、边数、SCC 与边界不变;监控分发器直接导入 FileItem owner,以 1 条 Schema 依赖边替换间接导出依赖,模块数、边数、SCC 与边界不变;工作流扫描、整理与下载动作直接复用已有 Schema owner,新增 3 条合法依赖边,模块数、SCC 与边界不变;工作流媒体过滤与下载查询复用已有 Schema 元素类型,新增 2 条合法依赖边,模块数、SCC 与边界不变;TheAudioDB 相关艺术家结果复用既有领域类型,新增 2 条合法 Chain 到 Domain 类型依赖边(含父包展开),模块数、SCC 与边界不变;群晖 Chat 渠道类型改为直接导入已有 Schema owner,移除 1 条间接导出依赖边,模块数、SCC 与边界不变;系统链、转移规划、订阅查询和规则服务改为直接导入已有 Schema owner,新增 2 条 owner 依赖边并移除 4 条间接导出依赖边,模块数、SCC 与层间边界不变;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖;TMDb 认证与片单读取复用模块既有异常,新增 2 条同模块依赖边;Session 类型使扫描器识别原有两处 close,发送与会话关闭路径未变;Audiences 构造参数复用既有 RequestUtils 类型,新增 3 条合法依赖边(含父包展开);插件实例历史脏行清理复用既有 `app.schemas.plugin.validate_plugin_id` 进行 fail-closed 身份校验,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/testing.md b/docs/testing.md index fb6dd43de..a95d2018c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -76,6 +76,11 @@ focused 测试验证,不要求启动产品全套测试。 ## 外部依赖:一律 mock,零真实网络 +社区勋章使用 `tests/test_community_medal_background.py` 验证启动主动预热、无人访问时的六小时 +更新、失败后的自动重试及 TaskRegistry 关闭。外部下载或远端开关被屏障阻塞时,查询必须在 +屏障释放前返回当前快照;冷启动通过 `refreshing` 表示后台尚未完成,不能把刷新锁或网络等待 +转移到页面请求。`tests/test_community_medals.py` 保留凭据隔离、有效开关和名单协议覆盖。 + 测试**禁止**发起任何真实外部请求,包括但不限于 TMDB(`api.themoviedb.org`)、LLM 目录(`models.dev`)、下载器、媒体服务器、MP 服务器(`movie-pilot.org` 的共享识别 API)、以及任意外链图片/资源。**验收标准是全量跑测零真实出站**。 两种标准做法: diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e5f6558f9..518372127 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -974,8 +974,8 @@ "runtime_only": true } }, - "edge_count": 9576, - "edge_sha256": "8a61cc6ef9d1103bd072d68ece8669e06c3d22bae26d1ee3499f5d77bae68915", + "edge_count": 9579, + "edge_sha256": "fbbb828033ea217b8461f87d24cba67d08d93086bac88007f6445dbf34ec2eb2", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3566,6 +3566,9 @@ "app.application.commands -> app.schemas.types", "app.application.community.medals -> app.domain", "app.application.community.medals -> app.domain.github", + "app.application.community.medals -> app.runtime", + "app.application.community.medals -> app.runtime.log", + "app.application.community.medals -> app.runtime.tasks", "app.application.configuration -> app.application", "app.application.configuration -> app.application.database", "app.application.configuration -> app.schemas", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 6622bb71a..067e0a47c 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -161,7 +161,7 @@ ], "median_startup_ms": 0.694, "median_full_lifespan_ms": 1.909, - "enabled_component_count": 32, + "enabled_component_count": 33, "enabled_components": [ "文件日志", "后台任务登记器", @@ -176,6 +176,7 @@ "路由", "插件运行时装配", "模块服务", + "社区勋章缓存", "插件服务装配", "插件备份恢复", "消息队列", @@ -295,7 +296,7 @@ ], "median_startup_ms": 0.553, "median_full_lifespan_ms": 1.197, - "enabled_component_count": 20, + "enabled_component_count": 21, "enabled_components": [ "文件日志", "后台任务登记器", @@ -310,6 +311,7 @@ "路由", "插件运行时装配", "模块服务", + "社区勋章缓存", "插件服务装配", "消息队列", "整理后台服务", diff --git a/tests/test_community_medal_background.py b/tests/test_community_medal_background.py new file mode 100644 index 000000000..41d6a9566 --- /dev/null +++ b/tests/test_community_medal_background.py @@ -0,0 +1,233 @@ +""" +社区勋章主动预热、后台更新和页面非阻塞快照读取回归覆盖。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from app.application.community import medals +from app.application.community.medals import CommunityMedalService +from app.domain.github import GithubAuthTransportError, GithubMedalSnapshot, GithubUser +from app.runtime.tasks import TaskRegistry +from app.startup import lifecycle + + +@pytest_asyncio.fixture +async def background(monkeypatch): + """ + 使用受控时钟和传输替身启动真实维护任务,用例结束时统一取消所有任务。 + """ + monkeypatch.setattr(medals, "_REFRESH_COOLDOWN_SECONDS", 0.01) + settings = {"token": "first", "enabled": True, "time": 1000.0} + transport = SimpleNamespace( + get_user=AsyncMock(return_value=GithubUser("artist", 42)), + get_medal_snapshot=AsyncMock(return_value=GithubMedalSnapshot("t", ({"key": "pioneer"},))), + ) + enabled = AsyncMock(return_value=True) + token = AsyncMock(side_effect=lambda: settings["token"]) + service = CommunityMedalService( + token_reader=token, token_snapshot=lambda: settings["token"], + local_enabled=lambda: settings["enabled"], enabled_reader=enabled, + transport=transport, clock=lambda: settings["time"], + ) + tasks = TaskRegistry() + yield service, transport, settings, tasks, enabled, token + assert await tasks.shutdown(timeout_seconds=1) + + +async def _until(predicate): + """ + 有界等待后台可观察结果,避免依赖测试机器执行速度。 + + :param predicate: 表示目标状态已经出现的无副作用断言条件 + """ + async with asyncio.timeout(1): + while not predicate(): + await asyncio.sleep(0.001) + + +@pytest.mark.asyncio +async def test_startup_prewarms_without_any_user_request(background): + """ + 启动任务即主动拉取名单,重复启动只有一个 owner,页面查询不再调用外部能力。 + """ + service, transport, _, tasks, enabled, token = background + service.start(tasks) + service.start(tasks) + await _until(lambda: transport.get_medal_snapshot.await_count == 1) + assert len(tasks.records) == 1 + assert tasks.records[0].owner == "community.medals" + calls = (enabled.await_count, token.await_count) + profile = await service.profile() + assert profile.state == "ready" + assert profile.user_id == 42 + assert (enabled.await_count, token.await_count) == calls + assert transport.get_medal_snapshot.await_count == 1 + + +@pytest.mark.asyncio +async def test_periodic_expiry_and_failure_recovery_need_no_page_request(background): + """ + 六小时到期后无需用户请求就主动更新,暂时失败保留旧结果且下一周期自动恢复。 + """ + service, transport, settings, tasks, _, _ = background + service.start(tasks) + await _until(lambda: transport.get_medal_snapshot.await_count == 1) + transport.get_medal_snapshot.side_effect = GithubAuthTransportError("offline") + settings["time"] += 21600 + await _until(lambda: transport.get_medal_snapshot.await_count == 2) + assert (await service.profile()).stale + assert (await service.profile()).medals == ({"key": "pioneer"},) + transport.get_medal_snapshot.side_effect = None + transport.get_medal_snapshot.return_value = GithubMedalSnapshot("t2", ({"key": "helper"},)) + settings["time"] += 60 + await _until(lambda: transport.get_medal_snapshot.await_count == 3) + profile = await service.profile() + assert not profile.stale + assert profile.medals == ({"key": "helper"},) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cold", [True, False]) +async def test_blocked_download_never_blocks_snapshot_query(background, cold): + """ + 无缓存时立刻报告预热,有旧缓存时立刻返回旧勋章;二者均不等待外部阻塞或刷新锁。 + """ + service, transport, settings, tasks, _, _ = background + if not cold: + await service.update() + settings["time"] += 21600 + started, release = asyncio.Event(), asyncio.Event() + + async def blocked(_user_id): + """ + 在名单读取边界制造可控阻塞。 + + :param _user_id: 当前经核验的 GitHub 用户标识 + :return: 释放屏障后的公开名单 + """ + started.set() + await release.wait() + return GithubMedalSnapshot("t2", ({"key": "helper"},)) + + transport.get_medal_snapshot.side_effect = blocked + service.start(tasks) + await asyncio.wait_for(started.wait(), timeout=1) + result = await asyncio.wait_for(service.profile(), timeout=0.05) + assert result.refreshing + assert result.state == ("unavailable" if cold else "ready") + assert result.medals == (() if cold else ({"key": "pioneer"},)) + release.set() + await _until(lambda: not service.refreshing) + assert (await service.profile()).medals == ({"key": "helper"},) + + +@pytest.mark.asyncio +async def test_remote_feature_check_does_not_delay_cached_query(background): + """ + 开关重新核验阻塞时,读取已有快照也不调用或等待远端开关。 + """ + service, _, _, tasks, enabled, token = background + await service.update() + started = asyncio.Event() + + async def blocked(): + """ + 暂停远端开关查询,等待测试收尾取消。 + """ + started.set() + await asyncio.Event().wait() + + enabled.side_effect = blocked + service.start(tasks) + await asyncio.wait_for(started.wait(), timeout=1) + calls = token.await_count + assert (await asyncio.wait_for(service.profile(), timeout=0.05)).state == "ready" + assert token.await_count == calls + + +@pytest.mark.asyncio +async def test_identity_change_discards_inflight_result_and_revokes_old_snapshot(background): + """ + 旧凭据请求尚未结束时换账号,查询立即撤下旧身份且迟到结果不能回填。 + """ + service, transport, settings, tasks, _, _ = background + await service.update() + started, release = asyncio.Event(), asyncio.Event() + + async def blocked(_user_id): + """ + 持有旧身份的在途名单响应。 + + :param _user_id: 旧身份的数字标识 + :return: 释放后到达的旧名单 + """ + started.set() + await release.wait() + return GithubMedalSnapshot("old", ({"key": "pioneer"},)) + + transport.get_medal_snapshot.side_effect = blocked + settings["time"] += 60 + update = tasks.create(service.update(force=True), owner="tests.community.update") + await asyncio.wait_for(started.wait(), timeout=1) + settings["token"] = "second" + assert (await service.profile()).medals == () + release.set() + await update + assert (await service.profile()).medals == () + transport.get_medal_snapshot.side_effect = None + transport.get_user.return_value = GithubUser("other", 99) + await service.update() + assert (await service.profile()).user_id == 99 + + +@pytest.mark.asyncio +async def test_local_disable_and_token_removal_are_immediate(background): + """ + 本机关闭或删除凭据立即撤销入口,不等后台下一个周期。 + """ + service, _, settings, _, _, _ = background + await service.update() + settings["enabled"] = False + assert (await service.profile()).state == "disabled" + assert not service.enabled + settings["enabled"] = True + settings["token"] = "" + assert (await service.profile()).medals == () + assert not service.enabled + + +@pytest.mark.asyncio +async def test_manual_refresh_returns_old_result_then_updates_in_background(background): + """ + 刷新按钮只唤醒后台,当前请求立即返回;后续只读查询可以观察更新结果。 + """ + service, transport, settings, tasks, _, _ = background + service.start(tasks) + await _until(lambda: transport.get_medal_snapshot.await_count == 1) + settings["time"] += 60 + transport.get_medal_snapshot.return_value = GithubMedalSnapshot("new", ({"key": "helper"},)) + result = await service.profile(refresh=True) + assert result.refreshing + assert result.medals == ({"key": "pioneer"},) + await _until(lambda: transport.get_medal_snapshot.await_count == 2) + assert (await service.profile()).medals == ({"key": "helper"},) + + +def test_lifecycle_wires_startup_worker_to_host_registry(): + """ + 生命周期从真实 HostRuntime 发布后台维护,并复用宿主登记器负责关闭。 + """ + app = FastAPI() + runtime = MagicMock(spec=lifecycle.HostRuntime) + community = MagicMock(spec=CommunityMedalService) + runtime.system = SimpleNamespace(github_community=community) + app.state.host_runtime = runtime + app.state.task_registry = TaskRegistry() + lifecycle.initialize_community_medals(app) + community.start.assert_called_once_with(app.state.task_registry) diff --git a/tests/test_community_medals.py b/tests/test_community_medals.py index 914a76d63..d1a3969cb 100644 --- a/tests/test_community_medals.py +++ b/tests/test_community_medals.py @@ -24,6 +24,18 @@ _CODE = { _PIONEER = {"key": "pioneer", "at": "2024", "note": "Logo 设计"} +async def _updated_profile(service, refresh=False): + """ + 显式运行一次后台维护再读取快照,保留原有缓存和传输边界用例的验证范围。 + + :param service: 测试勋章服务 + :param refresh: 是否主动核验名单 + :return: 后台维护后的公开快照 + """ + await service.update(force=refresh) + return await service.profile() + + @pytest.fixture def community(): """ @@ -54,7 +66,8 @@ def community(): return settings["enabled"] service = CommunityMedalService( - token_reader=token_reader, transport=transport, enabled_reader=enabled_reader, + token_reader=token_reader, token_snapshot=lambda: settings["token"], + local_enabled=lambda: settings["enabled"], transport=transport, enabled_reader=enabled_reader, clock=lambda: settings["time"], ) return service, transport, settings @@ -66,7 +79,7 @@ async def test_medals_are_looked_up_by_verified_numeric_id(community): 名单按 GET /user 返回的数字 ID 查表,而不是登录名。 """ service, transport, _ = community - result = await service.profile() + result = await _updated_profile(service) assert result.state == "ready" assert (result.login, result.user_id) == ("artist", 42) assert result.medals == (_CODE,) @@ -83,7 +96,7 @@ async def test_user_absent_from_registry_is_ready_without_medals(community): """ service, _, settings = community settings["medals"] = {} - result = await service.profile() + result = await _updated_profile(service) assert result.state == "ready" assert result.medals == () @@ -94,18 +107,18 @@ async def test_cache_refresh_cooldown_and_concurrent_readers(community): 同一 Token 的并发读取合并,刷新按钮不能绕过一分钟冷却,缓存六小时。 """ service, transport, settings = community - first, second = await asyncio.gather(service.profile(), service.profile()) + first, second = await asyncio.gather(_updated_profile(service), _updated_profile(service)) assert first == second assert transport.get_medal_snapshot.await_count == 1 - await service.profile(refresh=True) + await _updated_profile(service, refresh=True) assert transport.get_medal_snapshot.await_count == 1 settings["time"] += 61 - await service.profile() + await _updated_profile(service) assert transport.get_medal_snapshot.await_count == 1 - await service.profile(refresh=True) + await _updated_profile(service, refresh=True) assert transport.get_medal_snapshot.await_count == 2 settings["time"] += 21600 - await service.profile() + await _updated_profile(service) assert transport.get_medal_snapshot.await_count == 3 @@ -115,16 +128,16 @@ async def test_registry_failure_preserves_only_same_token_snapshot(community): 名单拉取失败保留同 Token 旧结果并标记过期,换 Token 后不能借用上一人的勋章。 """ service, transport, settings = community - original = await service.profile() + original = await _updated_profile(service) settings["time"] += 61 transport.get_medal_snapshot.side_effect = GithubAuthTransportError("registry down") - stale = await service.profile(refresh=True) + stale = await _updated_profile(service, refresh=True) assert stale.state == "ready" assert stale.medals == original.medals assert stale.checked_at == original.checked_at assert stale.stale settings["token"] = "second" - failed = await service.profile() + failed = await _updated_profile(service) assert failed.state == "unavailable" assert failed.medals == () assert failed.login is None @@ -137,11 +150,11 @@ async def test_stale_result_retries_after_cooldown_and_recovers(community): """ service, transport, settings = community transport.get_medal_snapshot.side_effect = GithubAuthTransportError("registry down") - assert (await service.profile()).state == "unavailable" + assert (await _updated_profile(service)).state == "unavailable" transport.get_medal_snapshot.side_effect = None transport.get_medal_snapshot.return_value = GithubMedalSnapshot("t", (_PIONEER,)) settings["time"] += 61 - recovered = await service.profile() + recovered = await _updated_profile(service) assert recovered.state == "ready" assert recovered.medals == (_PIONEER,) assert not recovered.stale @@ -153,14 +166,14 @@ async def test_unauthorized_or_removed_token_removes_medals(community): 失效和删除凭据都不能继续显示已撤销身份的勋章。 """ service, transport, settings = community - assert (await service.profile()).medals + assert (await _updated_profile(service)).medals settings["time"] += 61 transport.get_user.side_effect = GithubAuthTransportError("expired", unauthorized=True) - revoked = await service.profile(refresh=True) + revoked = await _updated_profile(service, refresh=True) assert revoked.state == "unavailable" assert revoked.medals == () settings["token"] = "" - assert (await service.profile()).state == "unconfigured" + assert (await _updated_profile(service)).state == "unconfigured" @pytest.mark.asyncio @@ -178,7 +191,7 @@ async def test_token_switch_during_query_discards_late_result(community): return GithubUser("artist", 42) transport.get_user.side_effect = user - result = await service.profile() + result = await _updated_profile(service) assert result.medals == () assert result.login is None @@ -190,12 +203,13 @@ async def test_endpoint_returns_registry_records_without_token(community): """ service, _, settings = community settings["medals"] = {42: (_PIONEER, {**_CODE, "glow": "gold"})} + await service.update() reply = await get_community_profile(refresh=False, community=service) payload = json.loads(reply.model_dump_json()) assert reply.success assert payload["data"]["medals"] == [_PIONEER, {**_CODE, "glow": "gold"}] assert set(payload["data"]) == { - "state", "login", "user_id", "medals", "generated_at", "checked_at", "stale", + "state", "login", "user_id", "medals", "generated_at", "checked_at", "stale", "refreshing", } assert "first" not in reply.model_dump_json() @@ -207,13 +221,13 @@ async def test_disabled_switch_skips_identity_and_registry(community): """ service, transport, settings = community settings["enabled"] = False - result = await service.profile(refresh=True) + result = await _updated_profile(service, refresh=True) assert result.state == "disabled" assert result.medals == () transport.get_user.assert_not_awaited() transport.get_medal_snapshot.assert_not_awaited() settings["enabled"] = True - assert (await service.profile()).state == "ready" + assert (await _updated_profile(service)).state == "ready" @pytest.mark.asyncio @@ -223,6 +237,7 @@ async def test_endpoint_reports_disabled_without_medals(community): """ service, transport, settings = community settings["enabled"] = False + await service.update() reply = await get_community_profile(refresh=False, community=service) payload = json.loads(reply.model_dump_json()) assert reply.success @@ -312,6 +327,11 @@ async def test_effective_switch_with_local_setting_on(server, features, preview, scenario.update(features=features, preview=preview, login=login) expected = features == "on" or (bool(login) and preview) adapter = _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": True}) + adapter.community = CommunityMedalService( + token_reader=AsyncMock(return_value=""), token_snapshot=lambda: "", local_enabled=lambda: True, + transport=SimpleNamespace(), enabled_reader=adapter.community_medals_enabled, + ) + await adapter.community.update() assert (await adapter.user_global())["COMMUNITY_MEDALS_ENABLED"] is expected assert await adapter.community_medals_enabled() is expected assert calls.features.await_count == 1 @@ -396,9 +416,11 @@ async def test_community_state_follows_effective_switch(server, local, login, fe transport = SimpleNamespace(get_user=AsyncMock(), get_medal_snapshot=AsyncMock()) service = CommunityMedalService( - token_reader=token_reader, transport=transport, + token_reader=token_reader, token_snapshot=lambda: "token" if login else "", + local_enabled=lambda: local, transport=transport, enabled_reader=adapter.community_medals_enabled, ) + await service.update() reply = await get_community_profile(refresh=False, community=service) assert reply.success assert reply.data.state == state @@ -426,9 +448,10 @@ async def test_preview_user_reaches_registry_while_global_off(server): get_medal_snapshot=AsyncMock(return_value=GithubMedalSnapshot("t", (_PIONEER,))), ) service = CommunityMedalService( - token_reader=token_reader, transport=transport, enabled_reader=adapter.community_medals_enabled, + token_reader=token_reader, token_snapshot=lambda: "token", local_enabled=lambda: True, + transport=transport, enabled_reader=adapter.community_medals_enabled, ) - result = await service.profile() + result = await _updated_profile(service) assert result.state == "ready" assert result.medals == (_PIONEER,) diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 0a222e297..ad5b98299 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -36,6 +36,11 @@ def _isolate_task_registry(): def _assert_completed_once(mock: MagicMock) -> None: + """ + 根据同步或异步回调类型验证生命周期恰好执行一次。 + + :param mock: 对应生命周期回调的替身 + """ if isinstance(mock, AsyncMock): mock.assert_awaited_once() else: @@ -80,6 +85,7 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict: "replay_pending_transfers", "init_command", "init_workflow", + "initialize_community_medals", ): monkeypatch.setattr(lifecycle, name, MagicMock()) monkeypatch.setattr(lifecycle, "configure_plugin_runtime_services", MagicMock()) @@ -778,6 +784,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: "路由", "插件运行时装配", "模块服务", + "社区勋章缓存", "插件服务装配", "消息队列", "插件备份恢复", @@ -827,6 +834,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: "路由", "插件运行时装配", "模块服务", + "社区勋章缓存", "插件服务装配", "消息队列", "AI智能体会话", diff --git a/tests/test_system_application.py b/tests/test_system_application.py index 48523b81c..2fa169b35 100644 --- a/tests/test_system_application.py +++ b/tests/test_system_application.py @@ -153,6 +153,23 @@ async def test_update_environment_rejects_validation_and_partial_failure() -> No assert list(published_keys) == ["PORT"] +@pytest.mark.anyio +@pytest.mark.parametrize("key", ["GITHUB_TOKEN", "COMMUNITY_MEDALS_ENABLE"]) +async def test_medal_configuration_change_wakes_background_without_waiting(key: str) -> None: + """ + 勋章相关配置提交成功后立即失效并唤醒后台,不在保存路径执行外部核验。 + + :param key: 会影响勋章身份或本机可见性的部署设置项 + """ + service, ports = _build_service() + community = Mock() + service.github_community = community + ports.settings.update_many.return_value = {key: (True, "ok")} + result = await service.update_environment({key: "value"}) + assert result.success + community.invalidate.assert_called_once_with() + + @pytest.mark.anyio async def test_update_environment_publishes_successful_batch() -> None: """