fix(api): restore full plugin catalog by default

This commit is contained in:
jxxghp
2026-09-04 10:47:02 +08:00
parent 7fff20cf72
commit adae5bd878
9 changed files with 97 additions and 37 deletions
+15 -3
View File
@@ -327,7 +327,7 @@ FIELD_DESCRIPTIONS = {
"match_field": "Object field used to match one list item during an upsert or removal.", "match_field": "Object field used to match one list item during an upsert or removal.",
"match_value": "Exact value compared against match_field during a list-item update.", "match_value": "Exact value compared against match_field during a list-item update.",
"max_chars": "Maximum number of serialized plugin-data characters to return.", "max_chars": "Maximum number of serialized plugin-data characters to return.",
"max_results": "Maximum number of plugin catalog results to return, from 1 to 200.", "max_results": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"max_rating": "Maximum rating used to filter shared or popular subscriptions.", "max_rating": "Maximum rating used to filter shared or popular subscriptions.",
"media_category": "MoviePilot library category assigned to the media.", "media_category": "MoviePilot library category assigned to the media.",
"media_category_id": "Stable classification category ID; preserve it separately from the current category path snapshot.", "media_category_id": "Stable classification category ID; preserve it separately from the current category path snapshot.",
@@ -963,7 +963,19 @@ def _collection_response_contract(
query_parameters.get("page"), query_parameters.get("page"),
query_parameters.get("count"), query_parameters.get("count"),
] ]
has_existing_window = bool({"limit", "offset", "page_size", "max_results"} & query_parameters.keys()) native_window_names = {"limit", "offset", "page_size", "max_results"}
has_native_window_default = False
for name in native_window_names:
parameter = query_parameters.get(name)
if not isinstance(parameter, Mapping):
continue
if parameter.get("required", False):
has_native_window_default = True
break
schema = parameter.get("schema")
if isinstance(schema, Mapping) and schema.get("default") is not None:
has_native_window_default = True
break
defaults_to_unpaginated = ( defaults_to_unpaginated = (
all( all(
isinstance(parameter, Mapping) isinstance(parameter, Mapping)
@@ -972,7 +984,7 @@ def _collection_response_contract(
and "default" not in parameter["schema"] and "default" not in parameter["schema"]
for parameter in compatibility_parameters for parameter in compatibility_parameters
) )
and not has_existing_window and not has_native_window_default
) )
return { return {
"body_shape": "list", "body_shape": "list",
+28 -16
View File
@@ -8345,7 +8345,7 @@
}, },
{ {
"additionalProperties": false, "additionalProperties": false,
"description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list and the endpoint's documented pagination or limit defaults remain in effect. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.", "description": "List installed plugins and their runtime status. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
"properties": { "properties": {
"operation_id": { "operation_id": {
"const": "plugin.installed", "const": "plugin.installed",
@@ -8377,12 +8377,18 @@
"type": "boolean" "type": "boolean"
}, },
"max_results": { "max_results": {
"default": 50, "anyOf": [
"description": "Maximum number of plugin catalog results to return, from 1 to 200.", {
"maximum": 200, "maximum": 200,
"minimum": 1, "minimum": 1,
"title": "Max Results", "type": "integer"
"type": "integer" },
{
"type": "null"
}
],
"description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"title": "Max Results"
}, },
"page": { "page": {
"anyOf": [ "anyOf": [
@@ -8429,14 +8435,14 @@
"type": "object", "type": "object",
"x-moviepilot-collection": { "x-moviepilot-collection": {
"body_shape": "list", "body_shape": "list",
"default_pagination": "endpoint-defined", "default_pagination": "unpaginated",
"result_count_field": "collection.result_count", "result_count_field": "collection.result_count",
"total_count_field": "collection.total_count" "total_count_field": "collection.total_count"
} }
}, },
{ {
"additionalProperties": false, "additionalProperties": false,
"description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list and the endpoint's documented pagination or limit defaults remain in effect. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.", "description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
"properties": { "properties": {
"operation_id": { "operation_id": {
"const": "plugin.market", "const": "plugin.market",
@@ -8468,12 +8474,18 @@
"type": "boolean" "type": "boolean"
}, },
"max_results": { "max_results": {
"default": 50, "anyOf": [
"description": "Maximum number of plugin catalog results to return, from 1 to 200.", {
"maximum": 200, "maximum": 200,
"minimum": 1, "minimum": 1,
"title": "Max Results", "type": "integer"
"type": "integer" },
{
"type": "null"
}
],
"description": "Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.",
"title": "Max Results"
}, },
"page": { "page": {
"anyOf": [ "anyOf": [
@@ -8520,7 +8532,7 @@
"type": "object", "type": "object",
"x-moviepilot-collection": { "x-moviepilot-collection": {
"body_shape": "list", "body_shape": "list",
"default_pagination": "endpoint-defined", "default_pagination": "unpaginated",
"result_count_field": "collection.result_count", "result_count_field": "collection.result_count",
"total_count_field": "collection.total_count" "total_count_field": "collection.total_count"
} }
+4 -2
View File
@@ -183,11 +183,11 @@ async def all_plugins(
state: Optional[str] = "all", state: Optional[str] = "all",
force: bool = False, force: bool = False,
query: Optional[str] = None, query: Optional[str] = None,
max_results: Annotated[int, Query(ge=1, le=200)] = 50, max_results: Annotated[Optional[int], Query(ge=1, le=200)] = None,
page: CompatiblePageParam = None, count: CompatibleCountParam = None, page: CompatiblePageParam = None, count: CompatibleCountParam = None,
response: Response = None, response: Response = None,
) -> List[_SchemaPlugin]: ) -> List[_SchemaPlugin]:
"""查询插件清单,显式分页优先于兼容的 ``max_results`` 限量""" """查询插件清单;未指定分页或限量时返回完整清单"""
plugins = await get_plugin_catalog_query().query(state=state or "all", force=force) plugins = await get_plugin_catalog_query().query(state=state or "all", force=force)
if query: if query:
plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)] plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)]
@@ -197,6 +197,8 @@ async def all_plugins(
page, count = resolve_compatible_pagination(page, count) page, count = resolve_compatible_pagination(page, count)
assert page is not None and count is not None assert page is not None and count is not None
return plugins[(page - 1) * count : page * count] return plugins[(page - 1) * count : page * count]
if max_results is None:
return plugins
return plugins[:max_results] return plugins[:max_results]
@@ -339,7 +339,7 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
- REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端 - REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端
- 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数 - 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数
- 第一版由统一响应路由隐式注入分页参数并在序列化后切片;复核后确认这种实现虽然能生成查询参数,但端点签名不自描述,而且数据库列表仍会全量读取,因此不作为最终方案 - 第一版由统一响应路由隐式注入分页参数并在序列化后切片;复核后确认这种实现虽然能生成查询参数,但端点签名不自描述,而且数据库列表仍会全量读取,因此不作为最终方案
- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page``count``limit``max_results` 的接口保留其既有参数与默认语义 - 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page``count``limit``max_results` 的接口保留其显式参数语义;当原生限量参数默认值为 `None` 时,省略所有窗口参数继续返回完整列表
- 用户、PassKey、活动订阅、Workflow、Site、站点用户数据和站点统计的筛选、稳定排序、`LIMIT/OFFSET` 与精确 `COUNT` 已下推到异步 SQLAlchemy 查询;状态、用户名、名称、触发类型、站点启用状态、站点 ID/域名和日期筛选均在分页前执行,避免空页和错误总数 - 用户、PassKey、活动订阅、Workflow、Site、站点用户数据和站点统计的筛选、稳定排序、`LIMIT/OFFSET` 与精确 `COUNT` 已下推到异步 SQLAlchemy 查询;状态、用户名、名称、触发类型、站点启用状态、站点 ID/域名和日期筛选均在分页前执行,避免空页和错误总数
- 纯内存、配置、缓存、文件系统或运行时目录仍可在响应边界按显式 `page/count` 切片;第三方原生分页且不返回总数的接口只报告当前页数量,不伪造总数 - 纯内存、配置、缓存、文件系统或运行时目录仍可在响应边界按显式 `page/count` 切片;第三方原生分页且不返回总数的接口只报告当前页数量,不伪造总数
- REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count` - REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count`
+1 -1
View File
@@ -96,7 +96,7 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确
查询结果的兼容分页合同如下: 查询结果的兼容分页合同如下:
- 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count``page` 必须不小于 1`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。 - 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count``page` 必须不小于 1`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。
- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续保留其旧默认值,显式 `page/count` 的优先级由端点合同说明。 - 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续支持显式限量;原生限量参数默认值为 `None` 时,省略所有分页和限量参数仍返回完整列表,显式 `page/count` 的优先级由端点合同说明。
- REST 响应的 `data` 保持原列表结构,不改成 `{items,total}``X-Result-Count` 报告本次实际返回数量;仅当 MoviePilot 已经取得完整筛选结果时,才增加精确的 `X-Total-Count`。原有结构化分页接口继续在既有 `data.total``data.items` / `data.list` 中返回总数。 - REST 响应的 `data` 保持原列表结构,不改成 `{items,total}``X-Result-Count` 报告本次实际返回数量;仅当 MoviePilot 已经取得完整筛选结果时,才增加精确的 `X-Total-Count`。原有结构化分页接口继续在既有 `data.total``data.items` / `data.list` 中返回总数。
- `moviepilot_api` 把这些响应头投影为响应中的附加 `collection` 对象:`result_count` 为本次返回数量,`total_count` 仅在精确可知时出现,`page` / `count` 在可用时出现。`collection` 是附加元数据,不替换或改写 `data` - `moviepilot_api` 把这些响应头投影为响应中的附加 `collection` 对象:`result_count` 为本次返回数量,`total_count` 仅在精确可知时出现,`page` / `count` 在可用时出现。`collection` 是附加元数据,不替换或改写 `data`
- Agent 仅查询数量或摘要时,应对支持精确总数的列表发送最小窗口;兼容分页接口使用 `page=1,count=1`,然后直接读取 `collection.total_count`。即使列表内容触发 64KB 工具预览截断,网关也会把 `collection` 放在 `data` 前面,确保总数仍可见;不得因为条目被截断就回退到数据库统计。 - Agent 仅查询数量或摘要时,应对支持精确总数的列表发送最小窗口;兼容分页接口使用 `page=1,count=1`,然后直接读取 `collection.total_count`。即使列表内容触发 64KB 工具预览截断,网关也会把 `collection` 放在 `data` 前面,确保总数仍可见;不得因为条目被截断就回退到数据库统计。
+5 -5
View File
@@ -1,6 +1,6 @@
--- ---
name: moviepilot-api name: moviepilot-api
version: 24 version: 25
description: >- description: >-
Use this skill for MoviePilot product operations such as media search, torrent Use this skill for MoviePilot product operations such as media search, torrent
search, downloads, subscriptions, library checks, sites, storage, workflows, search, downloads, subscriptions, library checks, sites, storage, workflows,
@@ -713,17 +713,17 @@ Purpose: Install or update one plugin from an approved source.
### `plugin.installed` ### `plugin.installed`
`GET /api/v1/plugin/`; policy effect: `safe_read`. `GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List installed plugins and their runtime status. Purpose: List installed plugins and their runtime status.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
- `path_params`: none - `path_params`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries.
- `body`: none - `body`: none
### `plugin.market` ### `plugin.market`
`GET /api/v1/plugin/`; policy effect: `safe_read`. `GET /api/v1/plugin/`; policy effect: `safe_read`.
Purpose: List plugins available from configured marketplaces. Purpose: List plugins available from configured marketplaces.
- `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. For a count-only request, use the smallest valid page and read that metadata instead of querying the database after item truncation. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
- `path_params`: none - `path_params`: none
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries. - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries.
- `body`: none - `body`: none
### `plugin.market.sync_wiki` ### `plugin.market.sync_wiki`
+11 -7
View File
@@ -172,17 +172,19 @@ def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() ->
"collection.total_count" "collection.total_count"
) )
for operation_id in ( for operation_id in ("subscription.history", "download.history.list"):
"subscription.history",
"download.history.list",
"plugin.installed",
"plugin.market",
):
local_page = branches[operation_id]["x-moviepilot-collection"] local_page = branches[operation_id]["x-moviepilot-collection"]
assert local_page["total_count_field"] == "collection.total_count" assert local_page["total_count_field"] == "collection.total_count"
assert local_page["default_pagination"] == "endpoint-defined" assert local_page["default_pagination"] == "endpoint-defined"
assert "defaults remain in effect" in branches[operation_id]["description"] assert "defaults remain in effect" in branches[operation_id]["description"]
for operation_id in ("plugin.installed", "plugin.market"):
local_page = branches[operation_id]["x-moviepilot-collection"]
assert local_page["total_count_field"] == "collection.total_count"
assert local_page["default_pagination"] == "unpaginated"
assert "omit both page and count" in branches[operation_id]["description"]
assert "default" not in branches[operation_id]["properties"]["query"]["properties"]["max_results"]
media_search = branches["media.search"]["x-moviepilot-collection"] media_search = branches["media.search"]["x-moviepilot-collection"]
assert media_search["result_count_field"] == "collection.result_count" assert media_search["result_count_field"] == "collection.result_count"
assert media_search["total_count_field"] is None assert media_search["total_count_field"] is None
@@ -221,7 +223,9 @@ def test_plugin_operations_expose_discovery_before_precise_writes() -> None:
installed_query = branches["plugin.installed"]["properties"]["query"] installed_query = branches["plugin.installed"]["properties"]["query"]
assert installed_query["properties"]["state"]["const"] == "installed" assert installed_query["properties"]["state"]["const"] == "installed"
assert "query" in installed_query["properties"] assert "query" in installed_query["properties"]
assert installed_query["properties"]["max_results"]["maximum"] == 200 max_results = installed_query["properties"]["max_results"]
integer_variant = next(item for item in max_results["anyOf"] if item.get("type") == "integer")
assert integer_variant["maximum"] == 200
config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path
assert config_get_path == "/api/v1/plugin/form/{plugin_id}" assert config_get_path == "/api/v1/plugin/form/{plugin_id}"
+1 -1
View File
@@ -110,7 +110,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"command-dispatch": "2", "command-dispatch": "2",
"database-operation": "6", "database-operation": "6",
"feedback-issue": "9", "feedback-issue": "9",
"moviepilot-api": "24", "moviepilot-api": "25",
"moviepilot-update": "5", "moviepilot-update": "5",
"organize-files": "5", "organize-files": "5",
"transfer-failed-retry": "5", "transfer-failed-retry": "5",
+31 -1
View File
@@ -261,7 +261,7 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None: def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None:
"""插件列表显式 page/count 应分页,省略时仍保留旧 max_results 行为""" """插件列表显式 page/count 应分页,并优先于显式 max_results 限量"""
catalog = MagicMock() catalog = MagicMock()
catalog.query = AsyncMock( catalog.query = AsyncMock(
return_value=[ return_value=[
@@ -291,6 +291,36 @@ def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None:
assert response.headers["X-Total-Count"] == "3" assert response.headers["X-Total-Count"] == "3"
def test_all_plugins_without_pagination_or_limit_returns_complete_catalog() -> None:
"""插件列表省略分页和限量参数时应返回完整目录。"""
catalog = MagicMock()
catalog.query = AsyncMock(
return_value=[
schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0")
for index in range(1, 52)
]
)
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=catalog,
):
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"all",
False,
response=response,
)
)
assert len(result) == 51
assert result[0].id == "Plugin1"
assert result[-1].id == "Plugin51"
assert response.headers["X-Total-Count"] == "51"
def _persistence(identity: PluginIdentity) -> MagicMock: def _persistence(identity: PluginIdentity) -> MagicMock:
"""构造只暴露身份读取合同的异步持久化替身。""" """构造只暴露身份读取合同的异步持久化替身。"""
persistence = MagicMock() persistence = MagicMock()