From 45322c25fd321bfddb33fcc4ec92a16dc777021d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 29 Sep 2026 17:50:32 +0800 Subject: [PATCH] fix(transfer): make manual review notes optional --- app/application/transfer/execution.py | 6 +++--- app/schemas/transfer.py | 2 +- docs/mcp-api.md | 2 +- tests/test_transfer_manual_review_api.py | 16 ++++++++++++++-- tests/test_transfer_manual_review_discovery.py | 9 +++++++-- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/application/transfer/execution.py b/app/application/transfer/execution.py index 01305a0d4..abc65ecf1 100644 --- a/app/application/transfer/execution.py +++ b/app/application/transfer/execution.py @@ -867,9 +867,9 @@ class TransferExecutionCommand: reason: str, result: Optional[TransferStepResult] = None, ) -> TransferManualReviewResult: - """提交人工判定;FAILED 在无 lease durable 结算落地前明确拒绝。""" - if not all((task_id, operation_id, actor, reason)): - raise ValueError("人工判定缺少任务、步骤、操作者或原因") + """提交人工判定,理由可留空;FAILED 仍须经 lease durable 结算。""" + if not all((task_id, operation_id, actor)): + raise ValueError("人工判定缺少任务、步骤或操作者") if decision is TransferManualReviewDecision.APPLIED and result is None: raise ValueError("人工判定已发生时必须提供结果证据") if decision is TransferManualReviewDecision.FAILED: diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 38d49604c..2b328a142 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -172,7 +172,7 @@ class TransferManualReviewRequest(BaseModel): # type: ignore[misc] decision: Literal["not_applied", "applied"] = Field( description="人工判定;不公开 failed,失败终态只能由 durable 结算写入", ) - reason: str = Field(min_length=1, max_length=2000, description="人工判定理由") + reason: str = Field(default="", max_length=2000, description="人工判定理由,可选") result_payload: Optional[dict[str, JsonData]] = Field( default=None, description="判定为 applied 时必填的外部结果证据", diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6e76e5486..40364cd8c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -407,7 +407,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;`logids` 会先还原为同一个显式文件批次,多首音乐因而共享专辑识别上下文;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | | GET | `/api/v1/transfer/tasks/manual-reviews` | 管理员分页查询 durable 人工复核任务;`state` 仅允许 `manual_review`(默认)或已经人工判定、等待调度恢复的 `retry_wait`,支持 `page` 与 `page_size`。响应只公开任务、源文件、状态、步骤意图/证据/错误和复核修订号,不返回 lease 或 attempt 身份 | | GET | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员查询单个 durable 人工复核任务详情;仅可读取 `manual_review` 或已经人工判定的 `retry_wait` 任务,其余状态按不存在处理 | -| POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id`、`decision=not_applied|applied`、`reason`,`applied` 还必须提供 `result_payload`。`failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 | +| POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id`、`decision=not_applied|applied`,`reason` 选填,省略或空白时记为空字符串,最多 2000 字符;`applied` 还必须提供 `result_payload`。`failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 | #### 媒体自动分类 diff --git a/tests/test_transfer_manual_review_api.py b/tests/test_transfer_manual_review_api.py index 38ee3f9d8..bfd17eb7d 100644 --- a/tests/test_transfer_manual_review_api.py +++ b/tests/test_transfer_manual_review_api.py @@ -83,8 +83,8 @@ def test_manual_review_endpoints_require_manage_permission(endpoint) -> None: "必须提供 result_payload", ), ( - {"operation_id": "op-1", "decision": "not_applied", "reason": " "}, - "String should have at least 1 character", + {"operation_id": "op-1", "decision": "not_applied", "reason": "x" * 2001}, + "String should have at most 2000 characters", ), ], ) @@ -97,6 +97,18 @@ def test_manual_review_request_rejects_unsafe_decisions( TransferManualReviewRequest.model_validate(payload) +@pytest.mark.parametrize("notes", [{}, {"reason": ""}, {"reason": " "}]) +def test_manual_review_request_allows_optional_reason(notes: dict) -> None: + """省略、空白或空字符串说明均可提交,空白输入统一为空字符串。""" + request = TransferManualReviewRequest.model_validate({ + "operation_id": "op-1", + "decision": "not_applied", + **notes, + }) + + assert request.reason == "" + + def test_manual_review_applied_wraps_result_and_hides_internal_state( monkeypatch, ) -> None: diff --git a/tests/test_transfer_manual_review_discovery.py b/tests/test_transfer_manual_review_discovery.py index 63d359822..40b4e5533 100644 --- a/tests/test_transfer_manual_review_discovery.py +++ b/tests/test_transfer_manual_review_discovery.py @@ -187,14 +187,16 @@ def test_manual_review_list_is_database_paginated(review_store) -> None: ("not_applied", None, "failed"), ], ) +@pytest.mark.parametrize("reason", ["确认文件状态", ""]) def test_unknown_manual_review_is_discoverable_and_resumes_via_api( monkeypatch, review_store, decision: str, result_payload: dict[str, bool] | None, expected_step_state: str, + reason: str, ) -> None: - """UNKNOWN 任务应可发现,人工判定后进入唯一 retry_wait 恢复路径。""" + """复核理由选填;判定后持久化审计身份并进入唯一 retry_wait 恢复路径。""" repository, operation_id = _put_in_manual_review( review_store, task_id=f"task-{decision}", @@ -243,7 +245,7 @@ def test_unknown_manual_review_is_discoverable_and_resumes_via_api( review=TransferManualReviewRequest( operation_id=operation_id, decision=decision, - reason=f"reviewed-{decision}", + reason=reason, result_payload=result_payload, ), current_user=SimpleNamespace(name="admin"), @@ -282,3 +284,6 @@ def test_unknown_manual_review_is_discoverable_and_resumes_via_api( assert pending.lease_owner is None assert pending.lease_token is None assert pending.retry_generation == 1 + assert pending.reviewed_by == "admin" + assert pending.review_decision == decision + assert pending.review_reason == reason