From 9181ad2b65e2b8ad19b34742de06fa96fd3df217 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 15 Oct 2026 22:15:02 +0800 Subject: [PATCH] feat(community): serve community medals from the Wiki registry behind remote switches Co-Authored-By: Claude Opus 5.5 --- app/adapters/external/github.py | 140 +++-- app/adapters/external/server.py | 43 +- app/api/dependencies/github.py | 10 +- app/api/endpoints/github.py | 10 +- app/application/community/__init__.py | 2 +- app/application/community/contributions.py | 98 ---- app/application/community/medals.py | 96 ++++ .../resources/system_settings_catalog.json | 138 ++--- app/application/system.py | 4 +- app/domain/github.py | 50 +- app/runtime/config.py | 2 + app/schemas/github.py | 52 +- app/startup/composition/system.py | 34 +- docs/architecture-overview.md | 4 +- docs/mcp-api.md | 67 ++- docs/refactor/agent-api-surface-audit.json | 4 +- docs/refactor/agent-api-surface-audit.md | 2 +- docs/refactor/optimization-checklist.md | 2 +- docs/rules/05-architecture.md | 2 +- scripts/generate_agent_api_surface_audit.py | 2 +- .../architecture/dependency-baseline.json | 18 +- tests/fixtures/community/medals_excerpt.json | 23 + tests/test_api_response.py | 2 + tests/test_community_contributions.py | 173 ------ tests/test_community_medals.py | 507 ++++++++++++++++++ tests/test_server_adapter_parity.py | 5 +- 26 files changed, 993 insertions(+), 497 deletions(-) delete mode 100644 app/application/community/contributions.py create mode 100644 app/application/community/medals.py create mode 100644 tests/fixtures/community/medals_excerpt.json delete mode 100644 tests/test_community_contributions.py create mode 100644 tests/test_community_medals.py diff --git a/app/adapters/external/github.py b/app/adapters/external/github.py index d4d48137b..9aca2f8c6 100644 --- a/app/adapters/external/github.py +++ b/app/adapters/external/github.py @@ -1,27 +1,34 @@ -"""GitHub OAuth Device Flow 的网络适配器。""" +"""GitHub OAuth Device Flow 与社区勋章名单的网络适配器。""" from __future__ import annotations import re from typing import Any -from urllib.parse import parse_qs, urlparse from app.adapters.network.http import AsyncRequestUtils from app.domain.github import ( + COMMUNITY_MEDAL_SCHEMA, GithubAuthPort, GithubAuthTransportError, GithubDeviceCode, - GithubRepositoryContribution, + GithubMedalSnapshot, GithubSettingsReader, GithubTokenExchange, GithubUser, ) +from app.foundation.url import UrlUtils from app.runtime.settings import get_runtime_setting # Device Flow 的 client_id 本身不是秘密;沿用当前内置 Copilot 登录使用的公开应用标识, # 后续如果注册专用 OAuth App,只需在组合根替换这一常量,不会影响授权协议和存储合同。 GITHUB_DEVICE_CLIENT_ID = "Ov23li8tweQw6odWQebz" +# 社区勋章公开名单,由 MoviePilot-Wiki 定时任务计算与人工登记后发布。 +COMMUNITY_MEDALS_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/community/medals.json" + +# 已知字段的类型约束;名单记录中这些字段类型不符时整条丢弃,其余字段原样透传。 +_MEDAL_DATE = re.compile(r"\d{4}(-\d{2}-\d{2})?") + class _RuntimeGithubSettings: """把 runtime 设置读取端口适配为 GitHub 客户端所需的 get 接口。""" @@ -133,63 +140,82 @@ class GithubAuthClient(GithubAuthPort): raise GithubAuthTransportError("GitHub 未返回有效的用户身份") return GithubUser(login=login, user_id=user_id) - async def get_repository_contribution( - self, access_token: str, user: GithubUser, repository: str, area: str, branch: str, - ) -> GithubRepositoryContribution: - """分别查询合并 PR 与作者提交;只解析分页数字,不请求外部 Link 地址。""" - if not re.fullmatch(r"[A-Za-z0-9-]{1,39}", user.login): - raise GithubAuthTransportError("GitHub 用户身份无效") - headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github+json"} - search = await self._community_get( - "https://api.github.com/search/issues", headers, - {"q": f"repo:jxxghp/{repository} is:pr is:merged author:{user.login}", "per_page": 1}, - ) - payload = self._json_payload(search) - count = payload.get("total_count") - if payload.get("incomplete_results") is not False or type(count) is not int or count < 0: - raise GithubAuthTransportError("GitHub 贡献统计尚未完成") - commits_response = await self._community_get( - f"https://api.github.com/repos/jxxghp/{repository}/commits", headers, - {"author": user.login, "sha": branch, "per_page": 1}, - ) - commits = self._commit_count(commits_response) - return GithubRepositoryContribution(repository, area, commits, count) + async def get_medal_snapshot(self, user_id: int) -> GithubMedalSnapshot: + """拉取 Wiki 勋章名单并只返回该数字 ID 的记录。 - async def _community_get( - self, url: str, headers: dict[str, str], params: dict[str, Any], - ) -> Any: - """将限流、失效与网络错误显式保留为查询失败,不能投影为零贡献。""" - try: - response = await self._request(headers=headers).get_res( - url, params=params, raise_exception=True, - ) - except Exception as error: # noqa: BLE001 - 不泄露 Token 或远端错误正文 - raise GithubAuthTransportError("暂时无法核对社区贡献") from error - if response is None or response.status_code >= 400: - unauthorized = response is not None and response.status_code == 401 - raise GithubAuthTransportError("暂时无法核对社区贡献", unauthorized=unauthorized) - return response + 名单是公开文件,请求不携带实例 Token,避免凭据泄露给 GitHub 镜像站。 + 配置 ``GITHUB_PROXY`` 时先走镜像站,失败再经 ``PROXY``(未配置时直连)访问原地址, + 与插件市场读取 Wiki 的出口顺序一致。 + """ + cause: Exception | None = None + for url, direct in self._medal_routes(): + try: + response = await self._request(direct=direct).get_res(url, raise_exception=True) + if response is None or response.status_code >= 400: + raise GithubAuthTransportError("社区勋章名单请求失败") + return self._medal_snapshot(self._json_payload(response), user_id) + except Exception as error: # noqa: BLE001 - 镜像站可能以 200 返回错误页,继续尝试下一出口 + cause = error + raise GithubAuthTransportError("暂时无法获取社区勋章名单") from cause + + def _medal_routes(self) -> list[tuple[str, bool]]: + """返回勋章名单的请求地址及是否绕过部署代理;镜像站优先且不叠加代理。""" + routes: list[tuple[str, bool]] = [] + mirror = str(self._settings.get("GITHUB_PROXY") or "").strip() + if mirror: + routes.append((f"{UrlUtils.standardize_base_url(mirror)}{COMMUNITY_MEDALS_URL}", True)) + routes.append((COMMUNITY_MEDALS_URL, False)) + return routes + + @classmethod + def _medal_snapshot(cls, payload: dict[str, Any], user_id: int) -> GithubMedalSnapshot: + """校验名单顶层协议并取出目标 ID 的记录;其他持有人的记录不在此处展开校验。 + + 名单形如 ``holders["<数字 ID>"] = {"login": ..., "medals": [...]}``;持有人对象中的 + ``login`` 等其他字段与顶层 ``unlinked``(无 GitHub 账号的共建者)都不参与实例查表。 + """ + schema = payload.get("schema") + generated_at = payload.get("generated_at") + holders = payload.get("holders") + if type(schema) is not int or schema != COMMUNITY_MEDAL_SCHEMA: + raise GithubAuthTransportError("社区勋章名单版本不受支持") + if not isinstance(generated_at, str) or not generated_at.strip() or not isinstance(holders, dict): + raise GithubAuthTransportError("社区勋章名单格式无效") + holder = holders.get(str(user_id)) + if holder is None: + records: list[Any] = [] + elif isinstance(holder, dict) and isinstance(holder.get("medals"), list): + records = holder["medals"] + else: + raise GithubAuthTransportError("社区勋章名单格式无效") + return GithubMedalSnapshot( + generated_at=generated_at.strip(), + medals=tuple(record for record in records if cls._valid_medal(record)), + ) @staticmethod - def _commit_count(response: Any) -> int: - """per_page=1 时末页即提交总数;无分页链接时只接受零或一条记录。""" - try: - payload = response.json() - except Exception as error: # noqa: BLE001 - 外部 JSON 协议边界 - raise GithubAuthTransportError("GitHub 提交统计无效") from error - if not isinstance(payload, list) or len(payload) > 1: - raise GithubAuthTransportError("GitHub 提交统计无效") - for part in response.headers.get("Link", "").split(","): - if 'rel="last"' in part: - query = parse_qs(urlparse(part.split(">")[0].strip().lstrip("<")).query) - page = query.get("page", [""])[0] - if not page.isdigit() or int(page) < 1 or not payload: - raise GithubAuthTransportError("GitHub 提交分页无效") - return int(page) - return len(payload) + def _valid_medal(record: Any) -> bool: + """判断单条勋章记录可否发布。 - def _request(self, *, headers: dict[str, str] | None = None) -> AsyncRequestUtils: - """构造 GitHub OAuth 请求客户端,不复用当前业务 Token 请求头。""" + 记录必须是带非空字符串 ``key`` 的对象;未认识的勋章键和字段为向前兼容原样透传, + 已知字段类型不符时整条丢弃,避免前端渲染出半残勋章,也不让一条坏记录拖垮整份名单。 + """ + if not isinstance(record, dict) or not isinstance(record.get("key"), str) or not record["key"]: + return False + tier = record.get("tier") + years = record.get("years") + checks = ( + tier is None or (type(tier) is int and 1 <= tier <= 4), + "at" not in record or (isinstance(record["at"], str) and _MEDAL_DATE.fullmatch(record["at"]) is not None), + years is None or (isinstance(years, list) and all(type(year) is int for year in years)), + "facts" not in record or isinstance(record["facts"], dict), + "serial" not in record or record["serial"] is None or type(record["serial"]) in (int, str), + all(field not in record or isinstance(record[field], str) for field in ("specialty", "basis", "note")), + ) + return all(checks) + + def _request(self, *, headers: dict[str, str] | None = None, direct: bool = False) -> AsyncRequestUtils: + """构造 GitHub 请求客户端,不复用当前业务 Token 请求头;访问镜像站时不叠加部署代理。""" request_headers = { "Accept": "application/json", "Content-Type": "application/json", @@ -198,7 +224,7 @@ class GithubAuthClient(GithubAuthPort): request_headers.update(headers or {}) return AsyncRequestUtils( headers=request_headers, - proxies=self._settings.get("PROXY"), + proxies=None if direct else self._settings.get("PROXY"), timeout=20, verify=True, trust_env=False, diff --git a/app/adapters/external/server.py b/app/adapters/external/server.py index 5baed205c..470195a63 100644 --- a/app/adapters/external/server.py +++ b/app/adapters/external/server.py @@ -77,6 +77,7 @@ class MoviePilotServerHelper: _WORKFLOW_FORK_PATH = "/workflow/fork" _RECOGNIZE_SHARE_PATH = "/recognize/share" _USER_PERMISSIONS_PATH = "/user/permissions" + _USER_FEATURES_PATH = "/user/features" _LOCAL_REPO_PREFIX = "local://" _user_uid: Optional[str] = None _github_user: Optional[str] = None @@ -229,6 +230,32 @@ class MoviePilotServerHelper: timeout=5, ) + @classmethod + async def async_user_features(cls): + """ + 异步查询服务端对所有实例相同的全局功能开关。 + """ + return await cls._async_get( + cls._server_url(cls._USER_FEATURES_PATH), + include_user_uid=False, + timeout=5, + ) + + @classmethod + @cached(maxsize=1, ttl=60, empty_ttl=15, local_only=True) + async def async_get_features(cls) -> Dict[str, Any]: + """ + 获取服务端全局功能开关(如 ``community_medals``),失败时返回空对象。 + + 结果与实例身份无关,用单一键缓存:成功 60 秒,使开关变更在一分钟内生效;服务端 + 不可达或非 200 得到的空结果只缓存 15 秒。仅缓存于进程内存。 + """ + try: + return cls._handle_mapping_response(await cls.async_user_features()) + except Exception as err: + logger.debug(f"获取服务端全局功能开关失败:{str(err)}") + return {} + @classmethod def get_user_permissions(cls) -> Dict[str, Any]: """ @@ -246,11 +273,25 @@ class MoviePilotServerHelper: @classmethod async def async_get_user_permissions(cls) -> Dict[str, Any]: """ - 异步获取当前用户在服务端配置中的权限。 + 异步获取当前用户在服务端配置中的权限;未登录 GitHub 时不发请求并返回空权限。 """ github_user = await cls.async_get_github_user() if not github_user: return {} + return await cls._async_fetch_user_permissions(github_user) + + @classmethod + @cached(maxsize=4, ttl=60, empty_ttl=15, local_only=True) + async def _async_fetch_user_permissions(cls, github_user: str) -> Dict[str, Any]: + """ + 按 GitHub 用户名查询并短时缓存服务端权限。 + + 前端每次加载会同时请求 ``/system/global/user`` 与 ``/github/community``,两者都依赖 + 此结果(共享管理权限与社区勋章预览),短时缓存避免每次页面加载向服务端重复往返。 + 缓存键为用户名,换账号后不沿用旧结论。成功结果缓存 60 秒,使服务端开关与管理员名单变更在一分钟内生效;服务端 + 不可达或非 200 得到的空权限只缓存 15 秒,故障恢复后尽快重新判定。仅缓存于进程内存, + 权限随本实例 GitHub 身份变化,不共享到 Redis。 + """ try: return cls._handle_mapping_response( await cls.async_user_permissions(github_user) diff --git a/app/api/dependencies/github.py b/app/api/dependencies/github.py index b6393db36..648a4506e 100644 --- a/app/api/dependencies/github.py +++ b/app/api/dependencies/github.py @@ -3,7 +3,7 @@ from fastapi import Depends, HTTPException from app.api.context import get_host_runtime -from app.application.community.contributions import CommunityContributionService +from app.application.community.medals import CommunityMedalService from app.application.github_auth import GithubAuthService from app.startup.composition.context import HostRuntime @@ -17,10 +17,10 @@ def get_github_auth_service( return runtime.system.github_auth -def get_community_contribution_service( +def get_community_medal_service( runtime: HostRuntime = Depends(get_host_runtime), -) -> CommunityContributionService: - """复用实例启动装配的贡献服务,不在请求中构造网络客户端或缓存。""" +) -> CommunityMedalService: + """复用实例启动装配的社区勋章服务,不在请求中构造网络客户端或缓存。""" if runtime.system.github_community is None: - raise HTTPException(status_code=503, detail="社区贡献服务尚未初始化") + raise HTTPException(status_code=503, detail="社区勋章服务尚未初始化") return runtime.system.github_community diff --git a/app/api/endpoints/github.py b/app/api/endpoints/github.py index 4210b2877..ea7c94c59 100644 --- a/app/api/endpoints/github.py +++ b/app/api/endpoints/github.py @@ -5,10 +5,10 @@ from dataclasses import asdict from fastapi import Depends, HTTPException from app.api.dependencies.auth import get_current_active_superuser_async, get_user_service -from app.api.dependencies.github import get_community_contribution_service, get_github_auth_service +from app.api.dependencies.github import get_community_medal_service, get_github_auth_service from app.api.principal import ApiPrincipal from app.api.response import ResponseAPIRouter -from app.application.community.contributions import CommunityContributionService +from app.application.community.medals import CommunityMedalService from app.application.github_auth import ( GithubAuthError, GithubAuthService, @@ -31,15 +31,15 @@ initialization_router = ResponseAPIRouter() @router.get( # type: ignore[misc] "/community", - summary="核对当前 GitHub 用户的社区贡献", + summary="查询当前 GitHub 用户的社区勋章", response_model=_SchemaResponse[GithubCommunityProfile], ) async def get_community_profile( refresh: bool = False, _: ApiPrincipal = Depends(get_current_active_superuser_async), - community: CommunityContributionService = Depends(get_community_contribution_service), + community: CommunityMedalService = Depends(get_community_medal_service), ) -> _SchemaResponse[GithubCommunityProfile]: - """仅向管理此实例凭据的超级管理员展示对应荣誉,不继承给普通用户。""" + """仅向管理此实例凭据的超级管理员展示其 GitHub 身份的勋章,不继承给普通用户。""" profile = await community.profile(refresh=refresh) return _SchemaResponse(success=True, data=GithubCommunityProfile.model_validate(asdict(profile))) diff --git a/app/application/community/__init__.py b/app/application/community/__init__.py index 5a3e31cec..53bb863be 100644 --- a/app/application/community/__init__.py +++ b/app/application/community/__init__.py @@ -1 +1 @@ -"""社区贡献与荣誉用例。""" +"""社区勋章用例。""" diff --git a/app/application/community/contributions.py b/app/application/community/contributions.py deleted file mode 100644 index 27680c3c6..000000000 --- a/app/application/community/contributions.py +++ /dev/null @@ -1,98 +0,0 @@ -"""社区贡献认定、身份隔离缓存与贡献等级编排。""" - -from __future__ import annotations - -import asyncio -import hashlib -import time -from collections.abc import Awaitable, Callable -from dataclasses import replace - -from app.domain.github import ( - COMMUNITY_REPOSITORIES, - GithubAuthTransportError, - GithubCommunityPort, - GithubCommunityProfile, - GithubRepositoryContribution, - GithubUser, -) - - -class CommunityContributionService: - """按实例已验证 GitHub 身份核对社区贡献,不向普通本地用户继承荣誉。""" - - def __init__( - self, *, token_reader: Callable[[], Awaitable[str]], transport: GithubCommunityPort, - clock: Callable[[], float] = time.time, - ) -> None: - """注入服务端凭据与传输端口;缓存只保存摘要键和公开贡献事实。""" - self._token_reader = token_reader - self._transport = transport - self._clock = clock - self._lock = asyncio.Lock() - self._key = "" - self._profile = GithubCommunityProfile() - self._attempted_at = float("-inf") - - async def profile(self, *, refresh: bool = False) -> GithubCommunityProfile: - """合并并发查询,缓存六小时;主动刷新最短间隔一分钟,失败不冒充零贡献。""" - 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 < 60 or (not refresh and elapsed < 21600 and not self._profile.stale - and self._profile.state == "ready"): - return self._profile - self._attempted_at = now - try: - async with asyncio.timeout(45): - user = await self._transport.get_user(token) - if user.user_id <= 0: - raise GithubAuthTransportError("GitHub 身份无效") - repositories = await self._collect(token, user) - # Token 在查询期间切换时,旧账号的异步结果不能重新点亮入口。 - if await self._token_reader() != token: - self._key = "" - self._profile = GithubCommunityProfile() - return self._profile - self._profile = self._recognize(user.login, user.user_id, repositories, now) - 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 - - async def _collect(self, token: str, user: GithubUser) -> tuple[GithubRepositoryContribution, ...]: - """逐仓查询避免同时突发搜索请求,任一失败不发布不完整的等级快照。""" - results = [] - for repository, area, branch in COMMUNITY_REPOSITORIES: - item = await self._transport.get_repository_contribution(token, user, repository, area, branch) - if item.units: - results.append(item) - return tuple(results) - - @staticmethod - def _recognize( - login: str, user_id: int, repositories: tuple[GithubRepositoryContribution, ...], now: float, - ) -> GithubCommunityProfile: - """等级按同仓去重后的贡献量认定;核心是里程碑称号,不授予仓库权限。""" - total = sum(item.units for item in repositories) - areas = {item.area for item in repositories} - tier = next((name for minimum, name in ( - (200, "core"), (50, "gold"), (10, "silver"), (1, "bronze"), - ) if total >= minimum), None) - specialty = next(iter(areas)) if len(areas) == 1 else ("ecosystem" if areas else None) - return GithubCommunityProfile( - state="ready", login=login, user_id=user_id, tier=tier, specialty=specialty, - total=total, repositories=repositories, checked_at=int(now), - ) diff --git a/app/application/community/medals.py b/app/application/community/medals.py new file mode 100644 index 000000000..114e65b08 --- /dev/null +++ b/app/application/community/medals.py @@ -0,0 +1,96 @@ +"""社区勋章查询:核验实例 GitHub 身份、读取 Wiki 名单并按凭据隔离缓存。""" + +from __future__ import annotations + +import asyncio +import hashlib +import time +from collections.abc import Awaitable, Callable +from dataclasses import replace + +from app.domain.github import ( + GithubAuthTransportError, + GithubCommunityPort, + GithubCommunityProfile, +) + +# 名单由 Wiki 定时任务更新,实例侧缓存六小时;主动刷新也至少间隔一分钟,避免放大外部请求。 +_CACHE_SECONDS = 21600 +_REFRESH_COOLDOWN_SECONDS = 60 +_QUERY_TIMEOUT_SECONDS = 45 + + +class CommunityMedalService: + """按实例 Token 对应的 GitHub 数字 ID 查询社区勋章,不向普通本地用户继承荣誉。 + + 计算与人工颁发都在 Wiki 名单完成,实例只核验身份并查表;身份只认 ``GET /user`` + 返回的数字 ID,不接受前端或参数声明的用户名。功能是否开放由本机设置与 MoviePilot + Server 远程开关共同决定,关闭时不核验身份也不拉取名单。 + """ + + def __init__( + self, *, token_reader: Callable[[], Awaitable[str]], transport: GithubCommunityPort, + enabled_reader: Callable[[], Awaitable[bool]], clock: Callable[[], float] = time.time, + ) -> None: + """注入服务端凭据、传输端口与远程开关;缓存只保存 Token 摘要键和公开名单记录。 + + ``enabled_reader`` 读取本机与服务端合成的社区勋章有效开关,取不到时必须返回 False; + 未配置 Token 时它应按服务端全局开关作答,使已开放的功能返回 ``unconfigured``。 + """ + self._token_reader = token_reader + self._enabled_reader = enabled_reader + self._transport = transport + self._clock = clock + self._lock = asyncio.Lock() + self._key = "" + self._profile = GithubCommunityProfile() + self._attempted_at = float("-inf") + + async def profile(self, *, refresh: bool = False) -> GithubCommunityProfile: + """合并并发查询并按凭据缓存;名单拉取失败保留同一 Token 的上次结果并标记过期。 + + 开关关闭时直接返回 ``disabled``,不触发任何 GitHub 请求,也不改动已有缓存, + 以便开关重新打开后沿用冷却与缓存策略。 + """ + if not await self._enabled_reader(): + return GithubCommunityProfile(state="disabled") + 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 + try: + async with asyncio.timeout(_QUERY_TIMEOUT_SECONDS): + 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), + ) + 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 diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index ae40ea3d1..c799a6d2c 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -17,67 +17,67 @@ "description": "AI智能体开关", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 821 + "source_line": 823 }, "AI_AGENT_GLOBAL": { "description": "合局AI智能体", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 823 + "source_line": 825 }, "AI_AGENT_HIDE_ENTRY": { "description": "是否隐藏前端全局智能体入口", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 825 + "source_line": 827 }, "AI_AGENT_JOB_INTERVAL": { "description": "AI智能体定时任务检查间隔(小时),0为不启用,默认24小时", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 878 + "source_line": 880 }, "AI_AGENT_OUTPUT_LANGUAGE": { "description": "内置智能助手输出语言,默认使用简体中文", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 829 + "source_line": 831 }, "AI_AGENT_PET": { "description": "默认助手形象,格式为 插件ID:形象key,留空使用内置机器人;用户个人选择优先", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 827 + "source_line": 829 }, "AI_AGENT_RETRY_TRANSFER": { "description": "AI智能体自动重试整理失败记录开关", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 882 + "source_line": 884 }, "AI_AGENT_VERBOSE": { "description": "AI智能体啰嗦模式,开启后会回复工具调用过程", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 880 + "source_line": 882 }, "AI_RECOMMEND_ENABLED": { "description": "是否启用AI推荐", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 869 + "source_line": 871 }, "AI_RECOMMEND_MAX_ITEMS": { "description": "AI推荐条目数量限制", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 874 + "source_line": 876 }, "AI_RECOMMEND_USER_PREFERENCE": { "description": "AI推荐用户偏好", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 871 + "source_line": 873 }, "ALIPAN_APP_ID": { "description": "Alipan AppId", @@ -89,7 +89,7 @@ "description": "对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新)", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 814 + "source_line": 816 }, "ALLOWED_HOSTS": { "description": "允许的跨域来源;为 \"*\" 时浏览器跨域请求不携带凭据,需要跨域带 Cookie 请列出显式来源", @@ -113,7 +113,7 @@ "description": "是否开放 API 文档;开放 /docs、/redoc 与 /api/v1/openapi.json,文档首次生成后常驻约 20–30MB 内存,修改后立即生效", "group": "security", "source_file": "app/runtime/config.py", - "source_line": 802 + "source_line": 804 }, "API_TOKEN": { "description": "API密钥,需要更换", @@ -155,67 +155,67 @@ "description": "音频输入 API 密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 891 + "source_line": 893 }, "AUDIO_INPUT_BASE_URL": { "description": "音频输入基础URL", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 893 + "source_line": 895 }, "AUDIO_INPUT_LANGUAGE": { "description": "音频输入识别语言", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 897 + "source_line": 899 }, "AUDIO_INPUT_MODEL": { "description": "音频输入模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 895 + "source_line": 897 }, "AUDIO_INPUT_PROVIDER": { "description": "音频输入提供商:openai/openai_chat_audio/mimo/minimax", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 889 + "source_line": 891 }, "AUDIO_OUTPUT_API_KEY": { "description": "音频输出 API 密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 901 + "source_line": 903 }, "AUDIO_OUTPUT_BASE_URL": { "description": "音频输出基础URL", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 903 + "source_line": 905 }, "AUDIO_OUTPUT_INCLUDE_TEXT": { "description": "回复语音时是否同时附带文字说明", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 909 + "source_line": 911 }, "AUDIO_OUTPUT_MODEL": { "description": "音频输出模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 905 + "source_line": 907 }, "AUDIO_OUTPUT_PROVIDER": { "description": "音频输出提供商:openai/openai_chat_audio/mimo/minimax", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 899 + "source_line": 901 }, "AUDIO_OUTPUT_VOICE": { "description": "音频输出音色/发音人", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 907 + "source_line": 909 }, "AUTH_SITE": { "description": "用户认证站点", @@ -269,7 +269,7 @@ "description": "大内存模式", "group": "performance", "source_file": "app/runtime/config.py", - "source_line": 753 + "source_line": 755 }, "BROWSER_EMULATION": { "description": "仿真类型:cloakbrowser 或 flaresolverr,其他值按 cloakbrowser 处理", @@ -325,6 +325,12 @@ "source_file": "app/runtime/config.py", "source_line": 573 }, + "COMMUNITY_MEDALS_ENABLE": { + "description": "社区勋章:关闭后不查询服务端开关、不拉取勋章名单;开启时仍需 MoviePilot 服务端开放该功能", + "group": "dependencies", + "source_file": "app/runtime/config.py", + "source_line": 735 + }, "CONFIG_DIR": { "description": "配置文件目录", "group": "application", @@ -677,13 +683,13 @@ "description": "编码探测的最低置信度阈值", "group": "performance", "source_file": "app/runtime/config.py", - "source_line": 759 + "source_line": 761 }, "ENCODING_DETECTION_PERFORMANCE_MODE": { "description": "是否启用编码探测的性能模式", "group": "performance", "source_file": "app/runtime/config.py", - "source_line": 757 + "source_line": 759 }, "FANART_API_KEY": { "description": "Fanart API Key", @@ -707,43 +713,43 @@ "description": "飞书管理员 Open ID 列表,多个使用 , 分隔", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 745 + "source_line": 747 }, "FEISHU_APP_ID": { "description": "飞书应用 App ID", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 737 + "source_line": 739 }, "FEISHU_APP_SECRET": { "description": "飞书应用 App Secret", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 739 + "source_line": 741 }, "FEISHU_CHAT_ID": { "description": "飞书默认接收群聊 Chat ID", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 743 + "source_line": 745 }, "FEISHU_ENCRYPT_KEY": { "description": "飞书事件加密 Key", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 749 + "source_line": 751 }, "FEISHU_OPEN_ID": { "description": "飞书默认接收用户 Open ID", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 741 + "source_line": 743 }, "FEISHU_VERIFICATION_TOKEN": { "description": "飞书事件校验 Token", "group": "feishu", "source_file": "app/runtime/config.py", - "source_line": 747 + "source_line": 749 }, "FLARESOLVERR_URL": { "description": "FlareSolverr 服务地址,例如 http://127.0.0.1:8191", @@ -809,127 +815,127 @@ "description": "图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果", "group": "security", "source_file": "app/runtime/config.py", - "source_line": 794 + "source_line": 796 }, "LLM_API_KEY": { "description": "LLM API密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 847 + "source_line": 849 }, "LLM_API_PROTOCOL": { "description": "OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 837 + "source_line": 839 }, "LLM_BASE_URL": { "description": "LLM基础URL(用于自定义API端点)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 849 + "source_line": 851 }, "LLM_BASE_URL_PRESET": { "description": "LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 853 + "source_line": 855 }, "LLM_MAX_CONTEXT_TOKENS": { "description": "LLM最大上下文Token数量(K),用于目录缺失回退和未匹配兼容端点的用户上限", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 855 + "source_line": 857 }, "LLM_MAX_ITERATIONS": { "description": "LLM最大迭代次数", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 861 + "source_line": 863 }, "LLM_MAX_TOOLS": { "description": "LLM工具选择中间件最大工具数量,0为不启用工具选择中间件", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 876 + "source_line": 878 }, "LLM_MEMORY_RETENTION_DAYS": { "description": "内存记忆保留天数", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 867 + "source_line": 869 }, "LLM_MODEL": { "description": "LLM模型名称", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 833 + "source_line": 835 }, "LLM_PROVIDER": { "description": "LLM提供商(支持内置 provider,以及从 models.dev 动态补充的平台)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 831 + "source_line": 833 }, "LLM_SUPPORT_AUDIO_INPUT": { "description": "是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 843 + "source_line": 845 }, "LLM_SUPPORT_AUDIO_OUTPUT": { "description": "是否启用音频输出,开启后 Agent 可在支持渠道发送语音回复", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 845 + "source_line": 847 }, "LLM_SUPPORT_IMAGE_INPUT": { "description": "LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 841 + "source_line": 843 }, "LLM_TEMPERATURE": { "description": "LLM温度参数,留空时不覆盖提供商默认值", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 859 + "source_line": 861 }, "LLM_THINKING_LEVEL": { "description": "思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh;OpenAI兼容接口保留max/xhigh,仅按已知模型能力降级", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 835 + "source_line": 837 }, "LLM_TOOL_TIMEOUT": { "description": "LLM工具调用超时时间(秒)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 863 + "source_line": 865 }, "LLM_USER_AGENT": { "description": "LLM OpenAI兼容接口请求User-Agent", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 857 + "source_line": 859 }, "LLM_USE_PROXY": { "description": "LLM调用是否使用系统代理", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 851 + "source_line": 853 }, "LLM_VERBOSE": { "description": "是否启用详细日志", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 865 + "source_line": 867 }, "LLM_WEB_SEARCH_MODE": { "description": "联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 839 + "source_line": 841 }, "LOCAL_EXISTS_SEARCH": { "description": "检查本地媒体库是否存在资源开关", @@ -1013,7 +1019,7 @@ "description": "主动内存回收时间间隔(分钟),0为不启用", "group": "performance", "source_file": "app/runtime/config.py", - "source_line": 761 + "source_line": 763 }, "META_CACHE_EXPIRE": { "description": "单条元数据识别缓存有效期(小时),0为自动", @@ -1151,7 +1157,7 @@ "description": "对OpenList进行快照对比时,是否检查文件夹的修改时间", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 812 + "source_line": 814 }, "PACKAGE_CACHE_DAYS": { "description": "pip/uv 包下载缓存保留天数", @@ -1169,7 +1175,7 @@ "description": "PassKey 是否强制用户验证(生物识别等)", "group": "security", "source_file": "app/runtime/config.py", - "source_line": 800 + "source_line": 802 }, "PIP_PROXY": { "description": "pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple", @@ -1181,7 +1187,7 @@ "description": "Playwright浏览器类型,供智能体浏览器工具和插件直接使用 Playwright 时读取", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 817 + "source_line": 819 }, "PLUGIN_AUTO_RELOAD": { "description": "是否开启插件热加载", @@ -1229,7 +1235,7 @@ "description": "对rclone进行快照对比时,是否检查文件夹的修改时间", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 810 + "source_line": 812 }, "RECOGNIZE_PLUGIN_FIRST": { "description": "优先使用辅助识别", @@ -1289,7 +1295,7 @@ "description": "Rust 加速总开关,free-threaded 运行时固定启用", "group": "performance", "source_file": "app/runtime/config.py", - "source_line": 755 + "source_line": 757 }, "SCRAP_FOLLOW_TMDB": { "description": "新增已入库媒体是否跟随TMDB信息变化", @@ -1331,13 +1337,13 @@ "description": "允许的图片缓存域名", "group": "security", "source_file": "app/runtime/config.py", - "source_line": 765 + "source_line": 767 }, "SECURITY_IMAGE_SUFFIXES": { "description": "允许的图片文件后缀格式", "group": "security", "source_file": "app/runtime/config.py", - "source_line": 796 + "source_line": 798 }, "SITEDATA_REFRESH_INTERVAL": { "description": "站点数据刷新间隔(小时)", @@ -1451,7 +1457,7 @@ "description": "是否按媒体聚合整理失败通知,关闭时保持逐条发送", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 884 + "source_line": 886 }, "TRANSFER_MAX_FAILED_RETRIES": { "description": "自动整理(目录监控、下载器轮询)遇到失败整理记录时,同一源路径允许自动重试的最大次数。 一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)不该让文件永久漏整理,因此必须重试; 但永远识别不出的文件重试再多也不会成功,只会重复推送失败通知,批量导入时更会刷屏, 因此必须有界。取值区间 1-10,越界或非法值会被钳制到边界并记录 warn: 既不支持关闭重试,也不支持无限重试。整理成功或删除整理记录时计数清零。 与本项无关的是同路径新版本:已成功整理的文件源大小变化时一律放行, 由整理链的 overwrite_mode 决断是否覆盖", @@ -1463,7 +1469,7 @@ "description": "是否按批次聚合电影、电视剧和音乐的整理成功通知", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 886 + "source_line": 888 }, "TRANSFER_TASK_TIMEOUT": { "description": "外部接管的运行中整理任务无状态心跳超时(分钟),0 表示禁用", @@ -1541,7 +1547,7 @@ "description": "工作流数据共享", "group": "workflow", "source_file": "app/runtime/config.py", - "source_line": 806 + "source_line": 808 }, "WRITE_TIMEOUT": { "description": "日志写入等待超时时间,单位秒", diff --git a/app/application/system.py b/app/application/system.py index 9330743ba..6d445b4a2 100644 --- a/app/application/system.py +++ b/app/application/system.py @@ -18,7 +18,7 @@ from app.schemas.system import SystemUpdateStatus, SystemUpdateTarget from app.schemas.types import SystemConfigKey if TYPE_CHECKING: - from app.application.community.contributions import CommunityContributionService + from app.application.community.medals import CommunityMedalService from app.application.github_auth import GithubAuthService from app.application.rules import AsyncRuleGroupMutationService @@ -195,7 +195,7 @@ class SystemService: plugin_mutation: Callable[[str], AbstractContextManager[None]], rule_group_mutation: Callable[[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]], github_auth: GithubAuthService | None = None, - github_community: CommunityContributionService | None = None, + github_community: CommunityMedalService | None = None, ) -> None: """保存由启动组合根注入的全部外部端口和 GitHub 授权服务。""" self.settings = settings diff --git a/app/domain/github.py b/app/domain/github.py index 0d6f120a3..5ad5d715b 100644 --- a/app/domain/github.py +++ b/app/domain/github.py @@ -49,61 +49,47 @@ class GithubUser: user_id: int = 0 -# 仓库与统计分支由社区维护,调用方不能把任意外部仓库注入授权请求。 -COMMUNITY_REPOSITORIES = ( - ("MoviePilot", "backend", "v3"), - ("MoviePilot-Frontend", "frontend", "v3"), - ("MoviePilot-Plugins", "plugins", "main"), - ("MoviePilot-Rust", "performance", "v3"), - ("MoviePilot-Resources", "resources", "main"), - ("MoviePilot-Server", "services", "main"), - ("MoviePilot-OCR", "services", "main"), - ("MoviePilot-Web", "website", "main"), - ("MoviePilot-Wiki", "docs", "main"), -) +# Wiki 名单的协议版本;结构不兼容时由名单方提升版本,旧实例按拉取失败处理而不是误读字段。 +COMMUNITY_MEDAL_SCHEMA = 1 @dataclass(frozen=True, slots=True) -class GithubRepositoryContribution: - """保留已合并 PR 与指定分支作者提交数,避免把两者相加重复奖励。""" +class GithubMedalSnapshot: + """Wiki 勋章名单中某个 GitHub 数字 ID 的记录与名单生成时间。 - repository: str - area: str - commits: int - merged_prs: int + ``medals`` 保留名单原始记录(已在协议边界校验),未认识的勋章键与字段原样透传, + 名单中没有该 ID 时为空元组,表示已核对但暂无勋章。 + """ - @property - def units(self) -> int: - """同仓取两类证据较大值,兼顾直接提交与被合并的贡献。""" - return max(self.commits, self.merged_prs) + generated_at: str + medals: tuple[dict[str, Any], ...] = () @dataclass(frozen=True, slots=True) class GithubCommunityProfile: - """社区荣誉的脱敏快照;不可用与确证零贡献是不同状态。""" + """社区勋章的脱敏快照;不可用与确证暂无勋章是不同状态。 + + state 取值:unconfigured、ready、unavailable,以及本机或服务端开关未生效时的 disabled。 + """ state: str = "unconfigured" login: str | None = None user_id: int | None = None - tier: str | None = None - specialty: str | None = None - total: int = 0 - repositories: tuple[GithubRepositoryContribution, ...] = () + medals: tuple[dict[str, Any], ...] = () + generated_at: str | None = None checked_at: int | None = None stale: bool = False class GithubCommunityPort(Protocol): - """查询已认证 GitHub 身份及社区贡献证据的外部端口。""" + """核验 GitHub 身份并读取 Wiki 社区勋章名单的外部端口。""" async def get_user(self, access_token: str) -> GithubUser: """使用 Token 校验身份,不接受前端声明的 GitHub 用户名。""" ... - async def get_repository_contribution( - self, access_token: str, user: GithubUser, repository: str, area: str, branch: str, - ) -> GithubRepositoryContribution: - """取得固定社区仓库的已合并 PR 数与目标分支作者提交数。""" + async def get_medal_snapshot(self, user_id: int) -> GithubMedalSnapshot: + """拉取公开勋章名单并取出指定数字 ID 的记录;名单不可用或格式不符时抛出传输错误。""" ... diff --git a/app/runtime/config.py b/app/runtime/config.py index 915950069..5c921842e 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -731,6 +731,8 @@ class ConfigModel(BaseModel): PIP_PROXY: Optional[str] = "" # 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** REPO_GITHUB_TOKEN: Optional[str] = None + # 社区勋章:关闭后不查询服务端开关、不拉取勋章名单;开启时仍需 MoviePilot 服务端开放该功能 + COMMUNITY_MEDALS_ENABLE: bool = True # ==================== 飞书通知配置 ==================== # 飞书应用 App ID diff --git a/app/schemas/github.py b/app/schemas/github.py index 1d21e98a9..dd71b66e4 100644 --- a/app/schemas/github.py +++ b/app/schemas/github.py @@ -1,33 +1,41 @@ """GitHub Token 授权接口的数据模型。""" -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field -class GithubCommunityRepository(BaseModel): # type: ignore[misc] - """单个社区仓库的公开贡献事实;提交与 PR 不直接相加。""" - - repository: str - area: str - commits: int = Field(ge=0) - merged_prs: int = Field(ge=0) - - class GithubCommunityProfile(BaseModel): # type: ignore[misc] - """管理员 Token 对应 GitHub 用户的荣誉快照,不包含凭据。""" + """实例 Token 对应 GitHub 用户的社区勋章快照,不包含凭据。 - state: Literal["unconfigured", "ready", "unavailable"] - login: str | None = None - user_id: int | None = None - tier: Literal["bronze", "silver", "gold", "core"] | None = None - specialty: Literal[ - "backend", "frontend", "plugins", "performance", "resources", "services", "website", "docs", "ecosystem", - ] | None = None - total: int = Field(default=0, ge=0) - repositories: list[GithubCommunityRepository] = Field(default_factory=list) - checked_at: int | None = None - stale: bool = False + state 为 disabled 表示社区勋章对当前实例未生效:本机 COMMUNITY_MEDALS_ENABLE 关闭,或 + MoviePilot Server 未开放(全局开关关闭且当前 GitHub 用户不在预览名单、未登录 GitHub 或服务端 + 不可达);此时不核验 + 身份、不拉取 Wiki 名单,其余字段均为默认值,前端应隐藏全部勋章入口。功能已开放但实例未配置 + GitHub Token 时为 unconfigured,前端可提示配置 Token。 + """ + + state: Literal["disabled", "unconfigured", "ready", "unavailable"] = Field( + description=( + "disabled 本机关闭或服务端未开放社区勋章,前端应隐藏;unconfigured 功能已开放但未配置 Token;" + "ready 已核对名单;unavailable 身份失效或名单从未取得" + ), + ) + login: str | None = Field(default=None, description="Token 对应的 GitHub 登录名,仅用于展示") + user_id: int | None = Field(default=None, description="Token 对应的 GitHub 数字 ID,勋章按此查表") + medals: list[dict[str, Any]] = Field( + default_factory=list, + description=( + "该 ID 在 Wiki 名单中的勋章记录,原样透传且省略的字段不补 null,未登记时为空。" + "已知字段:key 勋章键;tier 阶梯等级 1-4(初光、共建、卓越、核心);specialty 编剧(code)领域称号;" + "at 刻印日期 YYYY-MM-DD 或年份 YYYY;basis 阶梯时间口径 reached 或 recognized;" + "years 保留的整数年份列表(人工勋章当前只用 at);note 人工勋章贡献说明;" + "serial 编号(当前为 null);facts 阶梯勋章展示依据。未认识的键与字段供新版前端使用" + ), + ) + 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 的上次结果") class GithubTokenStatus(BaseModel): # type: ignore[misc] diff --git a/app/startup/composition/system.py b/app/startup/composition/system.py index d182740c5..25fbbce9c 100644 --- a/app/startup/composition/system.py +++ b/app/startup/composition/system.py @@ -18,7 +18,7 @@ from app.adapters.external.server import MoviePilotServerHelper from app.adapters.network.http import AsyncRequestUtils from app.adapters.system import rust as rust_accel from app.adapters.system.update import system_update_manager -from app.application.community.contributions import CommunityContributionService +from app.application.community.medals import CommunityMedalService from app.application.configuration import RuntimeSettingsService, SystemConfigService from app.application.github_auth import GithubAuthService from app.application.plugin.runtime import plugin_system_config_mutation @@ -220,15 +220,37 @@ class _ConfigurationEventAdapter(ConfigurationEventPort): class _ServerInfoAdapter(ServerInfoPort): """适配 MoviePilot Server 的用户权限与统计接口。""" + def __init__(self, settings: RuntimeSettingsService) -> None: + """保存运行设置提供器,用于读取本机社区勋章开关。""" + self._settings = settings + async def user_global(self) -> dict[str, Any]: - """合并用户 UUID 与两项共享管理权限。""" + """合并用户 UUID、两项共享管理权限与社区勋章有效开关。""" is_admin = await MoviePilotServerHelper.async_is_admin_user() return { "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(), } + async def community_medals_enabled(self) -> bool: + """社区勋章有效开关。 + + 有效 = 本机 COMMUNITY_MEDALS_ENABLE 且(服务端 ``/user/features`` 的 community_medals + 或已登录 GitHub 用户在服务端预览名单中,即权限的 community_medals_preview)。 + 本机关闭时不发任何勋章相关的服务端请求。未登录 GitHub 时只看全局开关,便于前端在 + 功能已开放时提示配置 Token。字段缺失、非 200 或服务端不可达均视为关闭。全局开关 + 单独短时缓存;预览判定与共享管理权限命中同一份按用户名缓存的权限结果。 + """ + if not self._settings.get("COMMUNITY_MEDALS_ENABLE"): + return False + features = await MoviePilotServerHelper.async_get_features() + if features.get("community_medals") is True: + return True + permissions = await MoviePilotServerHelper.async_get_user_permissions() + return permissions.get("community_medals_preview") is True + async def usage(self) -> dict[str, Any]: """读取安装版本统计。""" return await MoviePilotServerHelper.async_get_usage_statistic() @@ -345,13 +367,14 @@ def compose_system_service( transport=github_client, client_id=GITHUB_DEVICE_CLIENT_ID, ) + server_info = _ServerInfoAdapter(settings) return SystemService( settings=settings, system_config=system_config, logs=_FileLogAdapter(settings), market=_PluginMarketAdapter(settings), events=_ConfigurationEventAdapter(), - server=_ServerInfoAdapter(), + server=server_info, releases=_ReleaseCatalogAdapter(settings), features=_RuntimeFeatureAdapter(), control=_SystemControlAdapter(), @@ -360,5 +383,8 @@ def compose_system_service( plugin_mutation=plugin_system_config_mutation, rule_group_mutation=rule_group_mutation, github_auth=github_auth, - github_community=CommunityContributionService(token_reader=github_auth.access_token, transport=github_client), + github_community=CommunityMedalService( + token_reader=github_auth.access_token, transport=github_client, + enabled_reader=server_info.community_medals_enabled, + ), ) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 8b6cf7431..a4946d3a6 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -763,7 +763,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1121 | -| 内部导入边 | 9,553 | +| 内部导入边 | 9,555 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 49(债务已清零,49 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 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,553;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 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`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +成功通知聚合复用既有 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,555;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 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` 再执行快照测试及一次 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index f1c85c071..5f4947b59 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -359,24 +359,63 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器 ## 6. RESTful API -### 社区贡献勋章(前端专用) +### 社区勋章(前端专用) `GET /api/v1/github/community?refresh=false` 仅接受超级管理员登录态,沿用实例 GitHub -Token 的权限边界;普通用户不能继承该账号的荣誉。此接口不加入 Agent/MCP 工具。 -`data` 为脱敏身份、`state`(unconfigured/ready/unavailable)、`tier`、 -`specialty`、`total`、逐仓 `commits/merged_prs`、`checked_at` 及 `stale`。 -只有 ready 且 total 大于零时显示头像菜单入口。Token 原文不进入响应。 +Token 的权限边界;普通用户不能继承该账号的勋章。此接口不加入 Agent/MCP 工具,Token +原文不进入响应。 -统计九个公开社区仓库:MoviePilot、Frontend、Plugins、Rust、Resources、Server、OCR、 -Web、Wiki。私有 Build 不参与公开贡献认定。PR 按所有分支的已合并作者统计,提交按 -MoviePilot/Frontend/Rust 的 v3、其余仓库 main 的作者统计。每仓取两者较大值后汇总, -不是 PR 数与提交数相加。1/10/50/200 分别对应 bronze/silver/gold/core; -单领域保留该领域,多个领域为 ecosystem。core 是累计里程碑,不授予维护权限。 -仅有 Issue、评论、Star、未被 GitHub 关联的提交暂不计入。 +勋章的计算与人工颁发都在 MoviePilot-Wiki 完成,定时任务发布公开名单 +`https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/community/medals.json`。 +实例不再逐仓调用 GitHub 统计接口,只用 Token 调 `GET /user` 取得 GitHub 数字 ID 并查表; +身份不接受前端或参数声明的用户名。名单请求不携带 Token;配置 `GITHUB_PROXY` 时先经 +镜像站读取,失败再经 `PROXY`(未配置时直连)访问原地址。 + +社区勋章的有效开关为:本机设置开启,且服务端全局开关开启或当前 GitHub 用户在服务端预览名单中。 + +- 本机设置 `COMMUNITY_MEDALS_ENABLE`(默认 `true`)与其他系统设置一样经 + `GET/POST /api/v1/system/env` 读写。关闭后实例不做任何勋章相关请求,既不查询服务端开关, + 也不拉取名单。 +- 全局开关来自服务端 `GET /user/features` 的 `community_medals`,该接口不带任何身份参数, + 对所有实例返回相同结果。 +- 预览来自服务端 `GET /user/permissions?github_user=<登录名>` 的 `community_medals_preview`, + 只在实例已登录 GitHub 时查询;服务端预览名单为空时无人可预览。未登录 GitHub 时只看全局开关。 +- 字段缺失、非 200 或服务端不可达都视为关闭。 + +`GET /api/v1/system/global/user` 以 `COMMUNITY_MEDALS_ENABLED` 布尔值返回有效开关。开关 +关闭时本接口照常成功返回,`state` 为 `disabled`、`medals` 为 `[]`,不核验身份也不拉取 +Wiki 名单;开关开启但未配置 Token 时为 `unconfigured`,同样不拉取名单,前端可提示配置 +GitHub Token。全局开关与按用户名区分的权限结果各自在进程内缓存 60 秒,失败或空结果缓存 +15 秒。 + +`data` 包含以下字段。 + +| 字段 | 说明 | +| :--- | :--- | +| `state` | `disabled` 本机关闭或服务端未开放社区勋章;`unconfigured` 功能已开放但未配置 Token;`ready` 已核对名单;`unavailable` Token 失效或名单从未取得 | +| `login` / `user_id` | Token 对应的 GitHub 登录名与数字 ID,勋章只按数字 ID 查表 | +| `medals` | 名单 `holders[].medals` 的记录,名单中没有该 ID 时为 `[]` | +| `generated_at` | 名单生成时间(ISO 8601 UTC) | +| `checked_at` | 实例最近一次成功核对名单的 Unix 时间戳 | +| `stale` | 最近一次刷新失败,当前为同一 Token 的上次结果 | + +`medals` 每条记录与名单一致,名单中省略的字段在响应中同样省略。阶梯勋章键为 `code` +(编剧)与 `plugin`(布景),人工勋章键为 `maintainer`(主创)、`helper`(掌灯)、`security`(守夜)、 +`tester`(试映)、`pioneer`(启幕);勋章键以名单为准, +实例不维护键清单。已知字段为 `key`、`tier`(1–4)、`specialty`(编剧领域称号)、`at` +(`YYYY-MM-DD` 或 `YYYY`,人工勋章只用单个 `at`)、`basis`、`years`(保留的整数年份列表, +仍按类型校验)、`note`、`serial`、`facts`。名单协议的边界处理如下。 + +- 顶层 `schema` 必须为整数 1,且 `generated_at` 为非空字符串、`holders` 为对象,否则按拉取失败处理。 +- `holders` 以 GitHub 数字 ID 字符串为键,值为 `{"login": ..., "medals": [...]}`;目标 ID 的值不是对象或 `medals` 不是数组时按拉取失败处理。持有人对象的其他字段与顶层 `unlinked` 不参与查表。 +- 单条记录须为带非空字符串 `key` 的对象;已知字段类型不符(如 `tier` 超出 1–4、`at` 不是日期或年份)时只丢弃该条。 +- 未认识的勋章键与字段原样透传,消费方应忽略不认识的内容。 + +服务端合并并发查询、缓存六小时,`refresh=true` 仍受一分钟冷却保护。名单拉取失败时保留 +同一 Token 上次结果并标记 `stale`,没有旧结果为 `unavailable`。换 Token、删除 Token、 +Token 明确失效或查询期间切换账号都不能发布旧身份的勋章。前端在 `ready` 且 `medals` +非空时显示头像菜单入口,`disabled` 时隐藏全部勋章入口。 -服务端合并并发查询、缓存六小时,refresh=true 仍受一分钟冷却保护。网络失败保留同 -Token 的上次已验证结果并标记 stale;无旧快照时为 unavailable。换 Token、删除 Token、 -明确失效或查询期间切换账号都不能重新发布旧身份荣誉。 HTTP 工具管理端点统一位于 `/api/v1/mcp`,并与标准 MCP JSON-RPC 端点共享同一最终工具目录。 ### 相关 REST 端点 diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index 7ea56b093..715a5a4a2 100644 --- a/docs/refactor/agent-api-surface-audit.json +++ b/docs/refactor/agent-api-surface-audit.json @@ -3131,8 +3131,8 @@ "operation_ids": [], "owner": "host-ui", "path": "/api/v1/github/community", - "reason": "Community recognition is a presentation of the administrator-configured GitHub identity, not an Agent business operation.", - "summary": "核对当前 GitHub 用户的社区贡献", + "reason": "Community medals are a presentation of the administrator-configured GitHub identity, not an Agent business operation.", + "summary": "查询当前 GitHub 用户的社区勋章", "tags": [ "github" ] diff --git a/docs/refactor/agent-api-surface-audit.md b/docs/refactor/agent-api-surface-audit.md index fc84f5ed7..bc57c2661 100644 --- a/docs/refactor/agent-api-surface-audit.md +++ b/docs/refactor/agent-api-surface-audit.md @@ -341,7 +341,7 @@ Plugin ratings include this installation's own rating and require the same admin | `POST` | `/api/v1/github/auth/start` | github | `transport_or_identity` | host-runtime | 启动 GitHub 设备授权 | | `GET` | `/api/v1/github/auth/status` | github | `transport_or_identity` | host-runtime | 查询 GitHub Token 状态 | | `DELETE` | `/api/v1/github/auth/token` | github | `transport_or_identity` | host-runtime | 清除 GitHub Token | -| `GET` | `/api/v1/github/community` | github | `ui_presentation` | host-ui | 核对当前 GitHub 用户的社区贡献 | +| `GET` | `/api/v1/github/community` | github | `ui_presentation` | host-ui | 查询当前 GitHub 用户的社区勋章 | | `DELETE` | `/api/v1/history/download` | history | `gateway` | download.history.delete | 删除下载历史记录 | | `GET` | `/api/v1/history/download` | history | `gateway` | download.history.list | 查询下载历史记录 | | `DELETE` | `/api/v1/history/transfer` | history | `gateway` | transfer.history.delete | 删除整理记录 | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 7ef3db1d4..4248f53bf 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1121 / 9,553 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 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 身份 | +| 宿主 Python 模块 / 内部依赖边 | 1121 / 9,555 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 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 身份 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 99afa77b6..255b832d9 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -72,7 +72,7 @@ to make the directory tree look symmetrical. | `app/application/agent.py` | Agent orchestration facade and typed `AgentDataContext`; startup injects one explicit data context into the manager, memory, tool and scheduler owners without a process-wide persistence locator | | `app/application/invocation.py` | Frozen Agent write-call identity, claim and receipt contracts; the injected repository provides atomic claim, fenced settlement and unresolved-state reads, while `db/adapters/invocation.py` owns short transactions and cold-start recovery is invoked by startup | | `app/application/network.py` | System network-test target catalog, immutable public/private projections, URL and redirect admission, response validation and the injected transport Port; startup owns concrete HTTP Adapter assembly | -| `app/application/community/` | `contributions.py` 核对实例管理员 GitHub 身份、聚合固定公开仓库贡献与等级、管理同凭据缓存;通过 Domain GitHub Port 注入异步传输,具体 HTTP 仍归 `adapters/external/github.py`,启动由 system 组合根装配,不作为插件 SDK | +| `app/application/community/` | `medals.py` 按实例管理员 GitHub 数字 ID 查询 MoviePilot-Wiki 社区勋章名单、管理同凭据缓存与过期回退;通过 Domain GitHub Port 注入异步传输,名单拉取与协议校验归 `adapters/external/github.py`,启动由 system 组合根装配,不作为插件 SDK | | `app/application/outbox.py` | Durable intent, transaction-only stager, short-transaction dispatch store, claim fencing and structured post-commit result contracts | | `app/application/transfer/` | Durable transfer use cases: `workflow.py` owns queue service orchestration; `models.py` owns admission/planning/task contracts; `jobs.py` owns in-process task views; `notifications.py` owns success/failure notification snapshots, batch waiting and quiet-window aggregation; `projection.py` owns domain projections; `execution.py` owns stable operation identity, step/checkpoint state, retry/manual-review commands and terminal-settlement DTOs; `recovery.py` owns failed/corrupt task cleanup and history detachment through the execution repository; `history.py` projects history write fields and file fingerprints; `feedback.py` owns failure stages, notification snapshots and message text, while Chain owns notification delivery and cleanup side effects | | `app/chain/transfer/music.py` | 音乐整理的纯标签准入、目录共识、发行分组与曲目上下文编排;`filter.py` 保留通用文件筛选和既有 mixin 调用契约。可信标签在本地完成分类与命名,分类的外部事实补充可由本次用例明确禁用,仍遵守用户策略与人工覆盖 | diff --git a/scripts/generate_agent_api_surface_audit.py b/scripts/generate_agent_api_surface_audit.py index fc84b34bc..bb6dfdbf0 100644 --- a/scripts/generate_agent_api_surface_audit.py +++ b/scripts/generate_agent_api_surface_audit.py @@ -222,7 +222,7 @@ def _classify( return ( "ui_presentation", "host-ui", - "Community recognition is a presentation of the administrator-configured GitHub identity, not an Agent business operation.", + "Community medals are a presentation of the administrator-configured GitHub identity, not an Agent business operation.", [], ) if path.startswith(GITHUB_AUTH_PATH_PREFIXES): diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 93edaddd9..8761fed93 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9553, - "edge_sha256": "5cabdf16599788b91c81063ea71b02ceddc8b51586960497ba567ac5d78518b9", + "edge_count": 9555, + "edge_sha256": "c196ccb5714ef31be34e6b64314f7f4544ae5a4ce734d99ef18604bd056eaf74", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -976,6 +976,8 @@ "app.adapters.external.github -> app.adapters.network.http", "app.adapters.external.github -> app.domain", "app.adapters.external.github -> app.domain.github", + "app.adapters.external.github -> app.foundation", + "app.adapters.external.github -> app.foundation.url", "app.adapters.external.github -> app.runtime", "app.adapters.external.github -> app.runtime.settings", "app.adapters.external.location -> app.adapters", @@ -2157,7 +2159,7 @@ "app.api.dependencies.github -> app.api.context", "app.api.dependencies.github -> app.application", "app.api.dependencies.github -> app.application.community", - "app.api.dependencies.github -> app.application.community.contributions", + "app.api.dependencies.github -> app.application.community.medals", "app.api.dependencies.github -> app.application.github_auth", "app.api.dependencies.github -> app.startup", "app.api.dependencies.github -> app.startup.composition", @@ -2479,7 +2481,7 @@ "app.api.endpoints.github -> app.api.response", "app.api.endpoints.github -> app.application", "app.api.endpoints.github -> app.application.community", - "app.api.endpoints.github -> app.application.community.contributions", + "app.api.endpoints.github -> app.application.community.medals", "app.api.endpoints.github -> app.application.github_auth", "app.api.endpoints.github -> app.application.security", "app.api.endpoints.github -> app.application.security.user", @@ -3526,8 +3528,8 @@ "app.application.classification.runtime -> app.schemas.category", "app.application.commands -> app.schemas", "app.application.commands -> app.schemas.types", - "app.application.community.contributions -> app.domain", - "app.application.community.contributions -> app.domain.github", + "app.application.community.medals -> app.domain", + "app.application.community.medals -> app.domain.github", "app.application.configuration -> app.application", "app.application.configuration -> app.application.database", "app.application.configuration -> app.schemas", @@ -9977,7 +9979,7 @@ "app.startup.composition.system -> app.agent.llm.tools", "app.startup.composition.system -> app.application", "app.startup.composition.system -> app.application.community", - "app.startup.composition.system -> app.application.community.contributions", + "app.startup.composition.system -> app.application.community.medals", "app.startup.composition.system -> app.application.configuration", "app.startup.composition.system -> app.application.github_auth", "app.startup.composition.system -> app.application.plugin", @@ -10783,7 +10785,7 @@ "app.application.classification.runtime", "app.application.commands", "app.application.community", - "app.application.community.contributions", + "app.application.community.medals", "app.application.configuration", "app.application.dashboard", "app.application.database", diff --git a/tests/fixtures/community/medals_excerpt.json b/tests/fixtures/community/medals_excerpt.json new file mode 100644 index 000000000..12395d41d --- /dev/null +++ b/tests/fixtures/community/medals_excerpt.json @@ -0,0 +1,23 @@ +{ + "generated_at": "2026-10-15T11:45:29Z", + "holders": { + "12345678": { + "login": "example-contributor", + "medals": [ + {"at": "2026-08-11", "basis": "reached", "facts": {"repositories": [{"area": "backend", "commits": 1, "merged_prs": 1, "repository": "MoviePilot"}, {"area": "frontend", "commits": 1, "merged_prs": 1, "repository": "MoviePilot-Frontend"}], "total": 2}, "key": "code", "serial": null, "specialty": "ecosystem", "tier": 1}, + {"at": "2026-10-15", "basis": "recognized", "facts": {"installs": 1301263, "plugins": [{"id": "ExamplePlugin", "installs": 1301263, "market": "example/MoviePilot-Plugins", "name": "示例插件"}]}, "key": "plugin", "serial": null, "tier": 4}, + {"at": "2023", "key": "pioneer", "note": "早期测试与反馈", "serial": null} + ] + }, + "87654321": { + "login": "another-contributor", + "medals": [ + {"at": "2026-01-02", "key": "security", "serial": null} + ] + } + }, + "schema": 1, + "unlinked": [ + {"medals": [{"at": "2023", "key": "pioneer", "note": "早期文档整理", "serial": null}], "name": "无账号共建者"} + ] +} diff --git a/tests/test_api_response.py b/tests/test_api_response.py index 6c4389461..5065d4fc7 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -1018,6 +1018,8 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas(): "Response_Union_List_Dict_str__Any____Dict_str__Any___", # 通知渠道配置由各渠道模块定义,保留动态键值以承载渠道特有选项。 "NotificationConf", + # 社区勋章记录原样透传 Wiki 名单,字段由名单契约定义,省略的字段保持缺省。 + "GithubCommunityProfile", } allowed_empty_components = {"McpJsonRpcEmptyResult"} violations = [] diff --git a/tests/test_community_contributions.py b/tests/test_community_contributions.py deleted file mode 100644 index 0af110f53..000000000 --- a/tests/test_community_contributions.py +++ /dev/null @@ -1,173 +0,0 @@ -"""社区贡献认定、Token 隔离与 GitHub 协议失败的回归覆盖。""" - -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from app.adapters.external.github import GithubAuthClient -from app.api.endpoints.github import get_community_profile -from app.application.community.contributions import CommunityContributionService -from app.domain.github import ( - COMMUNITY_REPOSITORIES, - GithubAuthTransportError, - GithubRepositoryContribution, - GithubUser, -) - - -@pytest.fixture -def community(): - """提供不访问真实凭据或网络的可控贡献服务。""" - settings = {"token": "first", "time": 1000, "counts": {}} - - async def token_reader(): - """返回测试凭据。""" - return settings["token"] - - async def contribution(_token, _user, repository, area, _branch): - """按仓库返回注入的两类证据。""" - commits, prs = settings["counts"].get(repository, (0, 0)) - return GithubRepositoryContribution(repository, area, commits, prs) - - transport = SimpleNamespace( - get_user=AsyncMock(return_value=GithubUser("artist", 42)), - get_repository_contribution=AsyncMock(side_effect=contribution), - ) - service = CommunityContributionService( - token_reader=token_reader, transport=transport, clock=lambda: settings["time"], - ) - return service, transport, settings - - -@pytest.mark.asyncio -@pytest.mark.parametrize("count,tier", [(0, None), (1, "bronze"), (9, "bronze"), (10, "silver"), - (49, "silver"), (50, "gold"), (199, "gold"), (200, "core")]) -async def test_levels_and_single_frontend_specialty(community, count, tier): - """等级覆盖所有阈值边界,同仓 PR 与提交不重复相加。""" - service, _, settings = community - settings["counts"] = {"MoviePilot-Frontend": (count, count)} - result = await service.profile() - assert result.tier == tier - assert result.total == count - assert result.specialty == ("frontend" if count else None) - assert result.state == "ready" - - -@pytest.mark.asyncio -async def test_cross_repository_specialty_and_plugin_only(community): - """插件独立称号与跨领域生态称号分别认定,切换 Token 不复用旧结果。""" - service, _, settings = community - settings["counts"] = {"MoviePilot-Plugins": (8, 12)} - assert (await service.profile()).specialty == "plugins" - settings.update(token="second", counts={"MoviePilot-Plugins": (8, 12), "MoviePilot": (4, 2)}) - result = await service.profile() - assert result.specialty == "ecosystem" - assert result.total == 16 - - -@pytest.mark.asyncio -async def test_cache_refresh_cooldown_and_concurrent_readers(community): - """同一 Token 的并发读取合并,刷新按钮不能绕过一分钟冷却。""" - service, transport, settings = community - first, second = await asyncio.gather(service.profile(), service.profile()) - assert first == second - assert transport.get_user.await_count == 1 - await service.profile(refresh=True) - assert transport.get_user.await_count == 1 - settings["time"] += 61 - await service.profile(refresh=True) - assert transport.get_user.await_count == 2 - - -@pytest.mark.asyncio -async def test_failure_preserves_only_same_token_verified_snapshot(community): - """限流保留旧快照并标记过期,换 Token 后不能借用上一人的徽章。""" - service, transport, settings = community - settings["counts"] = {"MoviePilot": (50, 1)} - original = await service.profile() - settings["time"] += 61 - transport.get_repository_contribution.side_effect = GithubAuthTransportError("rate limited") - stale = await service.profile(refresh=True) - assert stale.tier == original.tier - assert stale.checked_at == original.checked_at - assert stale.stale - settings["token"] = "second" - failed = await service.profile() - assert failed.state == "unavailable" - assert failed.tier is None - assert failed.login is None - - -@pytest.mark.asyncio -async def test_unauthorized_or_removed_token_removes_badge(community): - """失效和删除凭据都不能继续显示已撤销身份的徽章。""" - service, transport, settings = community - settings["counts"] = {"MoviePilot": (5, 0)} - assert (await service.profile()).tier - settings["time"] += 61 - transport.get_user.side_effect = GithubAuthTransportError("expired", unauthorized=True) - assert (await service.profile(refresh=True)).tier is None - settings["token"] = "" - assert (await service.profile()).state == "unconfigured" - - -@pytest.mark.asyncio -async def test_token_switch_during_query_discards_late_result(community): - """网络请求期间变更凭据,旧身份的迟到结果不能点亮菜单。""" - service, transport, settings = community - - async def user(_token): - """模拟用户在身份查询期间清除凭据。""" - settings["token"] = "" - return GithubUser("artist", 42) - - transport.get_user.side_effect = user - settings["counts"] = {"MoviePilot": (500, 1)} - result = await service.profile() - assert result.tier is None - assert result.login is None - - -@pytest.mark.asyncio -async def test_endpoint_returns_sanitized_profile(community): - """API 只投影贡献事实,不包含服务器 Token。""" - service, _, settings = community - settings["counts"] = {"MoviePilot-Wiki": (1, 1)} - reply = await get_community_profile(refresh=False, community=service) - assert reply.success - assert reply.data.specialty == "docs" - assert "token" not in reply.model_dump_json().lower() - assert len(COMMUNITY_REPOSITORIES) == 9 - - -@pytest.mark.asyncio -async def test_adapter_uses_complete_search_and_authored_commit_pagination(monkeypatch): - """作者提交统计解析末页数字,绝不请求 Link 中的任意地址。""" - client = GithubAuthClient() - responses = [ - SimpleNamespace(status_code=200, json=lambda: {"total_count": 2, "incomplete_results": False}), - SimpleNamespace(status_code=200, json=lambda: [{"sha": "one"}], - headers={"Link": '; rel="last"'}), - ] - request = SimpleNamespace(get_res=AsyncMock(side_effect=responses)) - monkeypatch.setattr(client, "_request", lambda **_: request) - item = await client.get_repository_contribution("secret", GithubUser("artist", 42), "MoviePilot", "backend", "v3") - assert item.commits == 12 - assert item.merged_prs == 2 - assert all(call.args[0].startswith("https://api.github.com/") for call in request.get_res.call_args_list) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("response", [ - SimpleNamespace(status_code=403), - SimpleNamespace(status_code=200, json=lambda: {"total_count": 1, "incomplete_results": True}), - SimpleNamespace(status_code=200, json=lambda: {"total_count": -1, "incomplete_results": False}), -]) -async def test_adapter_does_not_turn_remote_failure_into_zero(monkeypatch, response): - """限流、不完整搜索和无效统计必须显式失败。""" - client = GithubAuthClient() - monkeypatch.setattr(client, "_request", lambda **_: SimpleNamespace(get_res=AsyncMock(return_value=response))) - with pytest.raises(GithubAuthTransportError): - await client.get_repository_contribution("secret", GithubUser("artist", 42), "MoviePilot", "backend", "v3") diff --git a/tests/test_community_medals.py b/tests/test_community_medals.py new file mode 100644 index 000000000..2ace2afb1 --- /dev/null +++ b/tests/test_community_medals.py @@ -0,0 +1,507 @@ +"""社区勋章查询的身份隔离、名单缓存与 Wiki 名单协议边界回归覆盖。""" + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.adapters.external.github import COMMUNITY_MEDALS_URL, GithubAuthClient +from app.adapters.external.server import MoviePilotServerHelper +from app.api.endpoints.github import get_community_profile +from app.application.community.medals import CommunityMedalService +from app.domain.github import GithubAuthTransportError, GithubMedalSnapshot, GithubUser +from app.startup.composition.system import _ServerInfoAdapter + +_CODE = { + "key": "code", "tier": 2, "specialty": "frontend", "at": "2025-03-01", "basis": "reached", + "serial": None, "facts": {"total": 12, "repositories": []}, +} +_PIONEER = {"key": "pioneer", "at": "2024", "note": "Logo 设计"} + + +@pytest.fixture +def community(): + """提供不访问真实凭据或网络的可控勋章服务。""" + settings = {"token": "first", "time": 1000, "medals": {42: (_CODE,)}, "enabled": True} + + async def token_reader(): + """返回测试凭据。""" + return settings["token"] + + async def snapshot(user_id): + """按数字 ID 返回注入的名单记录。""" + return GithubMedalSnapshot("2026-10-15T00:00:00Z", settings["medals"].get(user_id, ())) + + transport = SimpleNamespace( + get_user=AsyncMock(return_value=GithubUser("artist", 42)), + get_medal_snapshot=AsyncMock(side_effect=snapshot), + ) + async def enabled_reader(): + """返回测试注入的服务端开关。""" + return settings["enabled"] + + service = CommunityMedalService( + token_reader=token_reader, transport=transport, enabled_reader=enabled_reader, + clock=lambda: settings["time"], + ) + return service, transport, settings + + +@pytest.mark.asyncio +async def test_medals_are_looked_up_by_verified_numeric_id(community): + """名单按 GET /user 返回的数字 ID 查表,而不是登录名。""" + service, transport, _ = community + result = await service.profile() + assert result.state == "ready" + assert (result.login, result.user_id) == ("artist", 42) + assert result.medals == (_CODE,) + assert result.generated_at == "2026-10-15T00:00:00Z" + assert result.checked_at == 1000 + assert not result.stale + transport.get_medal_snapshot.assert_awaited_once_with(42) + + +@pytest.mark.asyncio +async def test_user_absent_from_registry_is_ready_without_medals(community): + """名单中没有该 ID 是已核对的空结果,不是不可用。""" + service, _, settings = community + settings["medals"] = {} + result = await service.profile() + assert result.state == "ready" + assert result.medals == () + + +@pytest.mark.asyncio +async def test_cache_refresh_cooldown_and_concurrent_readers(community): + """同一 Token 的并发读取合并,刷新按钮不能绕过一分钟冷却,缓存六小时。""" + service, transport, settings = community + first, second = await asyncio.gather(service.profile(), service.profile()) + assert first == second + assert transport.get_medal_snapshot.await_count == 1 + await service.profile(refresh=True) + assert transport.get_medal_snapshot.await_count == 1 + settings["time"] += 61 + await service.profile() + assert transport.get_medal_snapshot.await_count == 1 + await service.profile(refresh=True) + assert transport.get_medal_snapshot.await_count == 2 + settings["time"] += 21600 + await service.profile() + assert transport.get_medal_snapshot.await_count == 3 + + +@pytest.mark.asyncio +async def test_registry_failure_preserves_only_same_token_snapshot(community): + """名单拉取失败保留同 Token 旧结果并标记过期,换 Token 后不能借用上一人的勋章。""" + service, transport, settings = community + original = await service.profile() + settings["time"] += 61 + transport.get_medal_snapshot.side_effect = GithubAuthTransportError("registry down") + stale = await service.profile(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() + assert failed.state == "unavailable" + assert failed.medals == () + assert failed.login is None + + +@pytest.mark.asyncio +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" + transport.get_medal_snapshot.side_effect = None + transport.get_medal_snapshot.return_value = GithubMedalSnapshot("t", (_PIONEER,)) + settings["time"] += 61 + recovered = await service.profile() + assert recovered.state == "ready" + assert recovered.medals == (_PIONEER,) + assert not recovered.stale + + +@pytest.mark.asyncio +async def test_unauthorized_or_removed_token_removes_medals(community): + """失效和删除凭据都不能继续显示已撤销身份的勋章。""" + service, transport, settings = community + assert (await service.profile()).medals + settings["time"] += 61 + transport.get_user.side_effect = GithubAuthTransportError("expired", unauthorized=True) + revoked = await service.profile(refresh=True) + assert revoked.state == "unavailable" + assert revoked.medals == () + settings["token"] = "" + assert (await service.profile()).state == "unconfigured" + + +@pytest.mark.asyncio +async def test_token_switch_during_query_discards_late_result(community): + """网络请求期间变更凭据,旧身份的迟到结果不能点亮菜单。""" + service, transport, settings = community + + async def user(_token): + """模拟用户在身份查询期间清除凭据。""" + settings["token"] = "" + return GithubUser("artist", 42) + + transport.get_user.side_effect = user + result = await service.profile() + assert result.medals == () + assert result.login is None + + +@pytest.mark.asyncio +async def test_endpoint_returns_registry_records_without_token(community): + """API 原样投影名单记录,省略的字段不补 null,响应不含服务器 Token。""" + service, _, settings = community + settings["medals"] = {42: (_PIONEER, {**_CODE, "glow": "gold"})} + 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", + } + assert "first" not in reply.model_dump_json() + + +@pytest.mark.asyncio +async def test_disabled_switch_skips_identity_and_registry(community): + """服务端开关关闭时返回 disabled,不核验身份也不拉取名单,重新打开后照常查询。""" + service, transport, settings = community + settings["enabled"] = False + result = await service.profile(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" + + +@pytest.mark.asyncio +async def test_endpoint_reports_disabled_without_medals(community): + """开关关闭时接口仍成功返回,state 为 disabled 且没有勋章。""" + service, transport, settings = community + settings["enabled"] = False + reply = await get_community_profile(refresh=False, community=service) + payload = json.loads(reply.model_dump_json()) + assert reply.success + assert payload["data"]["state"] == "disabled" + assert payload["data"]["medals"] == [] + transport.get_medal_snapshot.assert_not_awaited() + + +_FEATURES = { + "on": (200, {"community_medals": True}), + "off": (200, {"community_medals": False}), + "missing": (200, {}), + "error": (502, {}), + "unreachable": None, +} + + +def _reply(response): + """把 (状态码, 载荷) 转成响应替身;None 表示服务端不可达。""" + if response is None: + return None + status_code, payload = response + return SimpleNamespace(status_code=status_code, json=lambda: payload) + + +async def _clear_server_caches(): + """清空全局开关与权限短时缓存,避免用例间串值。""" + await MoviePilotServerHelper.async_get_features.cache_clear() + await MoviePilotServerHelper._async_fetch_user_permissions.cache_clear() + + +@pytest.fixture +def server(monkeypatch): + """在边界替换服务端全局开关、用户权限与 GitHub 登录,返回请求替身与可切换的场景。""" + asyncio.run(_clear_server_caches()) + scenario = {"login": "artist", "features": "off", "preview": False, "permissions_up": True} + monkeypatch.setattr(MoviePilotServerHelper, "_github_user", None) + monkeypatch.setattr(MoviePilotServerHelper, "get_user_uuid", lambda: "uid") + + async def github_user(): + """返回场景中的 GitHub 登录名,空串表示未登录。""" + return scenario["login"] + + async def permissions(_github_user): + """按场景返回带预览标记的用户权限。""" + if not scenario["permissions_up"]: + return None + return _reply((200, {"is_admin": False, "community_medals_preview": scenario["preview"]})) + + async def features(): + """按场景返回全局开关。""" + return _reply(_FEATURES[scenario["features"]]) + + monkeypatch.setattr(MoviePilotServerHelper, "async_get_github_user", github_user) + calls = SimpleNamespace( + permissions=AsyncMock(side_effect=permissions), features=AsyncMock(side_effect=features), + ) + monkeypatch.setattr(MoviePilotServerHelper, "async_user_permissions", calls.permissions) + monkeypatch.setattr(MoviePilotServerHelper, "async_user_features", calls.features) + yield calls, scenario + asyncio.run(_clear_server_caches()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("features", ["on", "off", "missing", "error", "unreachable"]) +@pytest.mark.parametrize("preview", [True, False], ids=["preview", "no-preview"]) +@pytest.mark.parametrize("login", ["artist", ""], ids=["github-login", "no-github-login"]) +async def test_effective_switch_with_local_setting_on(server, features, preview, login): + """有效开关 = 全局开关,或已登录且在预览名单;未登录时只看全局开关。 + + global/user 与勋章服务各读一次,全局开关只请求一次,权限每个身份最多请求一次。 + """ + calls, scenario = server + scenario.update(features=features, preview=preview, login=login) + expected = features == "on" or (bool(login) and preview) + adapter = _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": True}) + assert (await adapter.user_global())["COMMUNITY_MEDALS_ENABLED"] is expected + assert await adapter.community_medals_enabled() is expected + assert calls.features.await_count == 1 + assert calls.permissions.await_count == (1 if login else 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("login", ["artist", ""], ids=["github-login", "no-github-login"]) +async def test_local_setting_off_skips_medal_lookup(server, login): + """本机关闭时有效开关为 False,且不请求全局开关或为勋章查询权限。""" + calls, scenario = server + scenario.update(login=login, features="on", preview=True) + adapter = _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": False}) + assert await adapter.community_medals_enabled() is False + assert (await adapter.user_global())["COMMUNITY_MEDALS_ENABLED"] is False + calls.features.assert_not_awaited() + # 已登录时 global/user 仍有原本的共享管理权限查询,与勋章无关。 + assert calls.permissions.await_count == (1 if login else 0) + + +@pytest.mark.asyncio +async def test_preview_lookup_failure_counts_as_off(server): + """全局关闭且权限查询失败时,预览不成立。""" + _, scenario = server + scenario.update(features="off", permissions_up=False) + assert await _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": True}).community_medals_enabled() is False + + +@pytest.mark.asyncio +async def test_features_transport_error_is_off(server): + """全局开关请求抛错时按关闭处理,并回落到预览判定。""" + calls, scenario = server + calls.features.side_effect = RuntimeError("offline") + scenario["preview"] = False + assert await _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": True}).community_medals_enabled() is False + + +@pytest.mark.asyncio +async def test_features_request_carries_no_identity(monkeypatch): + """全局开关请求不携带用户名与实例 UID,响应可被反向代理统一缓存。""" + get = AsyncMock(return_value=None) + monkeypatch.setattr(MoviePilotServerHelper, "_async_get", get) + await MoviePilotServerHelper.async_user_features() + assert get.await_args.args[0].endswith("/user/features") + assert get.await_args.kwargs.get("params") is None + assert get.await_args.kwargs["include_user_uid"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("local", "login", "features", "preview", "state"), + [ + (False, "artist", "on", True, "disabled"), + (True, "artist", "off", False, "disabled"), + (True, "artist", "unreachable", False, "disabled"), + (True, "", "on", False, "unconfigured"), + (True, "", "off", True, "disabled"), + (True, "", "unreachable", False, "disabled"), + ], +) +async def test_community_state_follows_effective_switch(server, local, login, features, preview, state): + """接口状态由有效开关与 Token 共同决定;disabled 与 unconfigured 都不拉取名单。""" + _, scenario = server + scenario.update(login=login, features=features, preview=preview) + adapter = _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": local}) + + async def token_reader(): + """已登录场景返回 Token,未登录返回空串。""" + return "token" if login else "" + + transport = SimpleNamespace(get_user=AsyncMock(), get_medal_snapshot=AsyncMock()) + service = CommunityMedalService( + token_reader=token_reader, transport=transport, + enabled_reader=adapter.community_medals_enabled, + ) + reply = await get_community_profile(refresh=False, community=service) + assert reply.success + assert reply.data.state == state + assert reply.data.medals == [] + transport.get_medal_snapshot.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_preview_user_reaches_registry_while_global_off(server): + """全局关闭时预览名单内的已登录用户照常核验身份并查表。""" + _, scenario = server + scenario.update(features="off", preview=True) + adapter = _ServerInfoAdapter({"COMMUNITY_MEDALS_ENABLE": True}) + + async def token_reader(): + """返回测试凭据。""" + return "token" + + transport = SimpleNamespace( + get_user=AsyncMock(return_value=GithubUser("artist", 42)), + get_medal_snapshot=AsyncMock(return_value=GithubMedalSnapshot("t", (_PIONEER,))), + ) + service = CommunityMedalService( + token_reader=token_reader, transport=transport, enabled_reader=adapter.community_medals_enabled, + ) + result = await service.profile() + assert result.state == "ready" + assert result.medals == (_PIONEER,) + + +def _registry(**overrides): + """构造一份符合 schema 1 的名单。""" + payload = { + "schema": 1, "generated_at": "2026-10-15T00:00:00Z", "unlinked": [], + "holders": { + "42": {"login": "artist", "medals": [_CODE, _PIONEER]}, + "7": {"login": "keeper", "medals": [{"key": "security", "at": "2026-01-02"}]}, + }, + } + payload.update(overrides) + return payload + + +def _client(monkeypatch, responses, settings=None): + """返回只回放给定响应的适配器及其请求记录。""" + client = GithubAuthClient(settings or {}) + get_res = AsyncMock(side_effect=responses) + built = [] + + def request(**kwargs): + """记录每次构造请求时的代理选择。""" + built.append(kwargs) + return SimpleNamespace(get_res=get_res) + + monkeypatch.setattr(client, "_request", request) + return client, get_res, built + + +def _response(payload, status_code=200): + """构造 JSON 响应替身。""" + return SimpleNamespace(status_code=status_code, json=lambda: payload) + + +@pytest.mark.asyncio +async def test_adapter_returns_only_requested_holder(monkeypatch): + """适配器只取出目标 ID 的记录,未登记的 ID 得到空记录而不是错误。""" + client, *_ = _client(monkeypatch, [_response(_registry()), _response(_registry())]) + snapshot = await client.get_medal_snapshot(42) + assert snapshot == GithubMedalSnapshot("2026-10-15T00:00:00Z", (_CODE, _PIONEER)) + assert (await client.get_medal_snapshot(99)).medals == () + + +@pytest.mark.asyncio +async def test_adapter_passes_unknown_keys_and_drops_malformed_records(monkeypatch): + """未知勋章键与字段透传;非对象、缺键或已知字段类型错误的记录整条丢弃。""" + future = {"key": "future", "aura": {"level": 3}} + # years 是保留的可选字段,类型正确时与 at 并存仍可发布。 + reserved_years = {"key": "security", "at": "2026-01-02", "years": [2025, 2026]} + bad = [ + "code", {"tier": 1}, {"key": ""}, {"key": "code", "tier": 5}, {"key": "code", "tier": True}, + {"key": "pioneer", "at": "2024/01/01"}, {"key": "security", "years": [2024, "2025"]}, + {"key": "code", "facts": []}, {"key": "pioneer", "note": 1}, + ] + holders = {"42": {"login": "artist", "medals": [future, *bad, reserved_years, _PIONEER]}} + client, *_ = _client(monkeypatch, [_response(_registry(holders=holders))]) + assert (await client.get_medal_snapshot(42)).medals == (future, reserved_years, _PIONEER) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [ + _registry(schema=2), + _registry(schema=True), + _registry(schema="1"), + _registry(generated_at=""), + _registry(holders=[]), + _registry(holders={"42": [_PIONEER]}), + _registry(holders={"42": "pioneer"}), + _registry(holders={"42": {"login": "artist"}}), + _registry(holders={"42": {"login": "artist", "medals": {"key": "pioneer"}}}), + ["not", "an", "object"], +]) +async def test_adapter_rejects_unsupported_or_malformed_registry(monkeypatch, payload): + """不支持的版本或顶层结构损坏按拉取失败处理,交由服务保留旧结果。""" + client, *_ = _client(monkeypatch, [_response(payload)]) + with pytest.raises(GithubAuthTransportError): + await client.get_medal_snapshot(42) + + +@pytest.mark.asyncio +async def test_adapter_parses_producer_excerpt(monkeypatch): + """以 Wiki 生成器真实产物的节选固定名单契约:持有人对象、阶梯与人工记录、unlinked 均可解析。""" + excerpt = json.loads((Path(__file__).parent / "fixtures/community/medals_excerpt.json").read_text("utf-8")) + client, *_ = _client(monkeypatch, [_response(excerpt), _response(excerpt), _response(excerpt)]) + snapshot = await client.get_medal_snapshot(12345678) + assert snapshot.generated_at == "2026-10-15T11:45:29Z" + assert snapshot.medals == tuple(excerpt["holders"]["12345678"]["medals"]) + assert [medal["key"] for medal in snapshot.medals] == ["code", "plugin", "pioneer"] + assert snapshot.medals[0]["facts"]["total"] == 2 + assert (await client.get_medal_snapshot(87654321)).medals == ({"at": "2026-01-02", "key": "security", "serial": None},) + # unlinked 只供荣誉榜展示,实例按数字 ID 查表时既不报错也不匹配其中的署名。 + assert (await client.get_medal_snapshot(99)).medals == () + + +@pytest.mark.asyncio +async def test_adapter_ignores_unknown_holder_fields_and_unlinked(monkeypatch): + """持有人对象的未知字段与任意形态的 unlinked 都不影响查表。""" + holders = {"42": {"login": "artist", "avatar": "x", "medals": [_PIONEER]}} + payload = _registry(holders=holders, unlinked={"unexpected": True}) + client, *_ = _client(monkeypatch, [_response(payload)]) + assert (await client.get_medal_snapshot(42)).medals == (_PIONEER,) + + +@pytest.mark.asyncio +async def test_adapter_reports_http_failure(monkeypatch): + """名单请求的 HTTP 错误不能被当作空名单。""" + client, *_ = _client(monkeypatch, [_response({}, status_code=404)]) + with pytest.raises(GithubAuthTransportError): + await client.get_medal_snapshot(42) + + +@pytest.mark.asyncio +async def test_adapter_prefers_github_mirror_then_falls_back_to_proxy(monkeypatch): + """配置 GitHub 镜像时先走镜像且不叠加代理,镜像返回错误页后再经部署代理访问原地址。""" + html = SimpleNamespace(status_code=200, json=lambda: (_ for _ in ()).throw(ValueError("html"))) + client, get_res, built = _client( + monkeypatch, [html, _response(_registry())], + settings={"GITHUB_PROXY": "mirror.invalid", "PROXY": {"https": "http://proxy.invalid"}}, + ) + snapshot = await client.get_medal_snapshot(42) + assert snapshot.medals == (_CODE, _PIONEER) + assert built == [{"direct": True}, {"direct": False}] + assert [call.args[0] for call in get_res.call_args_list] == [ + f"http://mirror.invalid/{COMMUNITY_MEDALS_URL}", COMMUNITY_MEDALS_URL, + ] + + +@pytest.mark.asyncio +async def test_adapter_requests_registry_without_token(monkeypatch): + """公开名单请求不携带实例 Token,不经镜像时访问固定 Wiki 地址。""" + client, get_res, built = _client(monkeypatch, [_response(_registry())]) + await client.get_medal_snapshot(42) + assert get_res.call_args.args[0] == COMMUNITY_MEDALS_URL + assert built == [{"direct": False}] diff --git a/tests/test_server_adapter_parity.py b/tests/test_server_adapter_parity.py index d44e8bef5..b5dc4e7d4 100644 --- a/tests/test_server_adapter_parity.py +++ b/tests/test_server_adapter_parity.py @@ -1,5 +1,6 @@ """MoviePilot 中心服务适配器同步异步一致性测试。""" +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -11,10 +12,12 @@ from app.adapters.external.server import MoviePilotServerHelper @pytest.fixture(autouse=True) def _reset_server_identity_cache(): - """隔离中心服务用户名缓存,避免同步入口短路异步入口。""" + """隔离中心服务用户名与权限短时缓存,避免同步入口短路异步入口或用例间串值。""" previous_user = MoviePilotServerHelper._github_user MoviePilotServerHelper._github_user = None + asyncio.run(MoviePilotServerHelper._async_fetch_user_permissions.cache_clear()) yield + asyncio.run(MoviePilotServerHelper._async_fetch_user_permissions.cache_clear()) MoviePilotServerHelper._github_user = previous_user