From afcbff2207e7c86e2f62c3af5ae31fe1ef94db06 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 15:42:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E6=94=AF=E6=8C=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=8F=96=E6=B6=88=E4=BD=9C=E5=BA=9F=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_douyu_collab_orders.py | 116 +++++++++++++++++++ web/backend/services/douyu_collab_service.py | 65 ++++++++++- web/frontend/src/pages/DouyuCollabPage.tsx | 59 +++++++++- 3 files changed, 233 insertions(+), 7 deletions(-) diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py index a470517..9d6b166 100644 --- a/tests/test_douyu_collab_orders.py +++ b/tests/test_douyu_collab_orders.py @@ -317,6 +317,122 @@ class TestDouyuCollabOrders: assert result["order"].status == collab.STATUS_DONE assert result["order"].done_at is not None + def test_admin_can_restore_voided_order_to_another_status(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-RESTORE-1", + shop="店铺A", + account_username="douyu-account-a", + gift_name="礼包A", + required_points=100, + home_images=["collab/home-restore-1.png"], + client=FakeClient(), + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_VOID, + reason="误作废", + ) + + restored = collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_SCRIPTED, + reason="管理员确认恢复", + ) + + assert restored.status == collab.STATUS_SCRIPTED + assert restored.void_reason == "" + assert restored.recharged_at is not None + assert restored.scripted_at is not None + payload = collab.serialize_one(self.session, restored) + serialized = DouyuCollabOrderOut.model_validate(payload).model_dump() + assert serialized["status"] == collab.STATUS_SCRIPTED + assert serialized["void_reason"] == "" + + audit = ( + self.session.query(AuditLog) + .filter(AuditLog.action == "douyu:collab:void_restore") + .order_by(AuditLog.id.desc()) + .first() + ) + assert audit is not None + assert audit.username == self.admin.username + assert '"from":"void"' in audit.detail + assert '"to":"scripted"' in audit.detail + assert '"previous_void_reason":"误作废"' in audit.detail + + def test_non_admin_cannot_restore_voided_order(self): + operator = User( + username="douyu-collab-restore-operator", + password_hash="hash", + role="operation", + is_active=True, + ) + self.session.add(operator) + self.session.commit() + order = collab.create_order( + self.session, + self.admin, + order_no="DY-RESTORE-2", + account_username="douyu-account-a", + draft=True, + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_VOID, + reason="测试权限", + ) + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status( + self.session, + operator, + order, + status=collab.STATUS_INCOMPLETE, + reason="越权恢复", + ) + + assert excinfo.value.status_code == 403 + assert excinfo.value.code == "admin_required" + assert order.status == collab.STATUS_VOID + assert order.void_reason == "测试权限" + + def test_void_restore_still_enforces_recharge_fields(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-RESTORE-3", + account_username="douyu-account-a", + draft=True, + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_VOID, + reason="资料不全", + ) + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_RECHARGED, + reason="直接恢复", + ) + + assert excinfo.value.code == "recharge_precondition" + assert order.status == collab.STATUS_VOID + assert order.void_reason == "资料不全" + def test_points_shortage_enters_points_error(self): order = collab.create_order( self.session, diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py index 0fd282a..5346edc 100644 --- a/web/backend/services/douyu_collab_service.py +++ b/web/backend/services/douyu_collab_service.py @@ -908,6 +908,60 @@ def _assert_ready_for_recharge(order: DouyuExternalOrder) -> None: ) +def _restore_void_status( + db: Session, + current: User, + order: DouyuExternalOrder, + *, + target: str, + reason: str, + commit: bool, +) -> DouyuExternalOrder: + """超级管理员取消作废,并把订单转为指定状态。""" + if current.role != "super_admin": + raise CollabError( + "仅超级管理员可取消作废", + status_code=403, + code="admin_required", + ) + if order.archived_at is not None: + raise CollabError("已归档的订单不能改状态", code="order_archived") + + target_index = STAGE_INDEX.get(target) + if target_index is not None and target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]: + _assert_ready_for_recharge(order) + + old_void_reason = order.void_reason or "" + # 取消作废等于重新进入状态机:先清空旧流程时间,再按目标状态补当前时间。 + for field in STATUS_TIMESTAMP_FIELDS.values(): + setattr(order, field, None) + if target_index is not None: + for name, field in STATUS_TIMESTAMP_FIELDS.items(): + if STAGE_INDEX[name] <= target_index: + setattr(order, field, _now()) + + order.status = target + order.void_reason = "" + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:void_restore", + target=_status_audit_target(order), + detail={ + "from": STATUS_VOID, + "to": target, + "previous_void_reason": old_void_reason, + "reason": _text(reason, max_length=256), + }, + ) + if commit: + db.commit() + db.refresh(order) + return order + + def set_status( db: Session, current: User, @@ -926,7 +980,16 @@ def set_status( if order.archived_at is not None: raise CollabError("已归档的订单不能改状态", code="order_archived") if order.status == STATUS_VOID: - raise CollabError("已作废的订单不能改状态", code="order_void") + if target == STATUS_VOID: + raise CollabError("已作废的订单不能改状态", code="order_void") + return _restore_void_status( + db, + current, + order, + target=target, + reason=reason, + commit=commit, + ) old = order.status now = _now() diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx index 0a81922..c29545b 100644 --- a/web/frontend/src/pages/DouyuCollabPage.tsx +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -748,6 +748,14 @@ export default function DouyuCollabPage() { setBatchStatusRollback(false); }; + const openRestoreVoid = (order: DouyuCollabOrder) => { + setStatusTarget(order); + setBatchStatusOpen(false); + setStatusValue('incomplete'); + setStatusReason(''); + setBatchStatusRollback(false); + }; + const openBatchStatus = () => { if (selectedIds.length === 0) { message.warning('请先勾选协作单'); @@ -800,13 +808,14 @@ export default function DouyuCollabPage() { } else if (statusTarget) { const currentIndex = FLOW_ORDER.indexOf(statusTarget.status); const targetIndex = FLOW_ORDER.indexOf(statusValue); + const restoringVoid = statusTarget.status === 'void'; await douyuCollabApi.setStatus(statusTarget.id, { status: statusValue, reason: statusReason, rollback: targetIndex >= 0 && currentIndex >= 0 && targetIndex < currentIndex, expected_version: statusTarget.version, }); - message.success('状态已更新'); + message.success(restoringVoid ? '已取消作废并恢复状态' : '状态已更新'); setSelectedRowKeys((keys) => keys.filter((key) => Number(key) !== statusTarget.id)); setStatusTarget(null); } @@ -1350,6 +1359,17 @@ export default function DouyuCollabPage() { )} + {canEdit && !row.is_archived && row.status === 'void' && isAdmin && ( + + + + )} {row.is_archived && canEdit && (