mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 14:24:58 +08:00
fix(community): prewarm and refresh medal snapshots in background
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 动态类型无法由当前检查配置完整推断,保留既有类型检查豁免。
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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=("模块服务",),
|
||||
|
||||
@@ -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、越层依赖或直接网络出口。
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
@@ -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)、以及任意外链图片/资源。**验收标准是全量跑测零真实出站**。
|
||||
|
||||
两种标准做法:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
"路由",
|
||||
"插件运行时装配",
|
||||
"模块服务",
|
||||
"社区勋章缓存",
|
||||
"插件服务装配",
|
||||
"消息队列",
|
||||
"整理后台服务",
|
||||
|
||||
233
tests/test_community_medal_background.py
Normal file
233
tests/test_community_medal_background.py
Normal file
@@ -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)
|
||||
@@ -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,)
|
||||
|
||||
|
||||
@@ -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智能体会话",
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user