From 6559386871d3704cda814f85888fa1f7d42fbd2b Mon Sep 17 00:00:00 2001 From: kx_guo Date: Fri, 18 Sep 2026 17:31:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A3=8E=E9=99=A9=E9=A1=B9?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E6=8E=A5=E5=8F=A3=E5=92=8C=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/risk_items.py | 10 +++++++++ app/services/risk_items.py | 22 ++++++++++++++++++ tests/test_risk_item_withdraw.py | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 tests/test_risk_item_withdraw.py diff --git a/app/api/v1/risk_items.py b/app/api/v1/risk_items.py index 54279cd..8153da7 100644 --- a/app/api/v1/risk_items.py +++ b/app/api/v1/risk_items.py @@ -49,6 +49,16 @@ async def update_risk_item( return ApiResponse(data=await RiskItemService(session).update(item_id, payload, current_user)) +@router.post("/{item_id}/withdraw", response_model=ApiResponse[None]) +async def withdraw_risk_item( + item_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))], +) -> ApiResponse[None]: + await RiskItemService(session).withdraw(item_id, current_user) + return ApiResponse(data=None, message="风险项已撤回") + + @router.post("/{item_id}/review", response_model=ApiResponse[ReviewRecordData]) async def review_risk_item( item_id: int, diff --git a/app/services/risk_items.py b/app/services/risk_items.py index 02bd07f..e6eeef8 100644 --- a/app/services/risk_items.py +++ b/app/services/risk_items.py @@ -68,6 +68,8 @@ class RiskItemService: filters = [] if status != "all": filters.append(RiskItem.status == status) + else: + filters.append(RiskItem.status != "已撤回") if source_type != "all": filters.append(RiskItem.source_type == source_type) statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit) @@ -105,6 +107,26 @@ class RiskItemService: await self.session.flush() return self.to_data(item) + async def withdraw(self, item_id: int, operator: UserAccount) -> None: + item = await self.session.get(RiskItem, item_id) + if item is None: + raise AppException("风险项不存在", code=404, status_code=404) + if item.status not in {"待审核", "待预审"}: + raise AppException("只有待处理风险项可以撤回", code=409, status_code=409) + previous_status = item.status + item.status = "已撤回" + self.session.add( + OperationAudit( + actor_user_id=operator.id, + actor_emp_no=operator.emp_no, + action="risk_item.withdraw", + resource_type="risk_item", + resource_id=str(item.id), + audit_metadata={"previous_status": previous_status}, + ) + ) + await self.session.flush() + async def review(self, item_id: int, action: str, note: str, reviewer: UserAccount) -> ReviewRecordData: item = await self.session.get(RiskItem, item_id) if item is None: diff --git a/tests/test_risk_item_withdraw.py b/tests/test_risk_item_withdraw.py new file mode 100644 index 0000000..62ed61b --- /dev/null +++ b/tests/test_risk_item_withdraw.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.exceptions import AppException +from app.models.entities import OperationAudit, RiskItem, UserAccount +from app.services.risk_items import RiskItemService + + +@pytest.mark.asyncio +async def test_pending_risk_item_can_be_withdrawn_with_audit() -> None: + item = RiskItem(id=12, source_type="表单", status="待审核", photos=[], ocr_payload={}) + operator = UserAccount(id=3, emp_no="admin-1", name="管理员", role="admin", status="启用") + session = MagicMock() + session.get = AsyncMock(return_value=item) + session.flush = AsyncMock() + + await RiskItemService(session).withdraw(item.id, operator) + + assert item.status == "已撤回" + audit = next(call.args[0] for call in session.add.call_args_list if isinstance(call.args[0], OperationAudit)) + assert audit.action == "risk_item.withdraw" + assert audit.audit_metadata == {"previous_status": "待审核"} + session.flush.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reviewed_risk_item_cannot_be_withdrawn() -> None: + item = RiskItem(id=13, source_type="表单", status="已通过", photos=[], ocr_payload={}) + operator = UserAccount(id=3, emp_no="admin-1", name="管理员", role="admin", status="启用") + session = MagicMock() + session.get = AsyncMock(return_value=item) + + with pytest.raises(AppException, match="只有待处理风险项可以撤回"): + await RiskItemService(session).withdraw(item.id, operator) + + session.add.assert_not_called() +