diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py index 86ee45f..ef85dbe 100644 --- a/tests/test_douyu_collab_orders.py +++ b/tests/test_douyu_collab_orders.py @@ -19,6 +19,8 @@ class FakeClient: self.points = points self.fish = fish self.records = records if records is not None else [] + self.records_calls = 0 + self.orders_calls = 0 def elite_user_info(self, **_): return { @@ -55,9 +57,11 @@ class FakeClient: } def exchange_records(self, **_): + self.records_calls += 1 return {"records": self.records} def exchange_orders(self, **_): + self.orders_calls += 1 return {"orders": []} @@ -215,6 +219,96 @@ class TestDouyuCollabOrders: assert result["points_reason"] == "shortage" assert result["order"].status == collab.STATUS_POINTS_ERROR + def test_balance_query_skips_records_and_never_changes_status(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-106", + shop="店铺A", + account_username="douyu-account-a", + gift_name="礼包A", + required_points=100, + home_images=["collab/home-5.png"], + client=FakeClient(), + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_RECHARGED, + reason="鱼翅已充值", + ) + order.points_threshold = 10 + self.session.commit() + client = FakeClient(points=50, records=[{"commodityName": "礼包A"}]) + + result = collab.query_activity( + self.session, + self.admin, + order, + client=client, + source="balance_batch", + include_records=False, + ) + + assert client.records_calls == 0 + assert client.orders_calls == 0 + assert result["scope"] == "balances" + assert result["verdict"] == collab.VERDICT_UNKNOWN + assert result["points_reason"] == "shortage" + assert result["order"].status == collab.STATUS_RECHARGED + assert result["metric"].source == "balance_batch" + assert result["metric"].records_total == 0 + + def test_balance_query_keeps_last_exchange_verdict_visible(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-107", + shop="店铺A", + account_username="douyu-account-a", + gift_name="礼包A", + required_points=100, + home_images=["collab/home-6.png"], + client=FakeClient(), + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_RECHARGED, + reason="鱼翅已充值", + ) + collab.set_status( + self.session, + self.admin, + order, + status=collab.STATUS_SCRIPTED, + reason="脚本已上", + ) + record_result = collab.query_activity( + self.session, + self.admin, + order, + client=FakeClient(records=[{"commodityName": "礼包A"}]), + ) + assert record_result["order"].status == collab.STATUS_DONE + + collab.query_activity( + self.session, + self.admin, + order, + client=FakeClient(), + source="balance_batch", + include_records=False, + ) + payload = collab.serialize_one(self.session, order) + + assert payload["status"] == collab.STATUS_DONE + assert payload["last_verdict"] == collab.VERDICT_SUCCESS + assert payload["last_records_matched"] == 1 + assert payload["last_metric_message"] == "仅查询鱼翅与积分,未查询兑换记录" + def test_draft_can_be_created_before_account_resolution(self): order = collab.create_order( self.session, @@ -579,6 +673,10 @@ class TestDouyuCollabOrders: assert "/api/douyu/external-orders/ck/export" in paths assert "/api/douyu/external-orders/{order_id}/ck/copy" in paths assert "/api/douyu/external-orders/usernames/export" in paths + assert "/api/douyu/external-orders/balances/query" in paths + assert "/api/douyu/external-orders/{order_id}/balances/query" in paths + assert "/api/douyu/external-orders/activity/query" in paths + assert "/api/douyu/external-orders/{order_id}/activity/query" in paths assert "/api/douyu/collab-shops/{shop_id}" in paths assert "put" in paths["/api/douyu/collab-shops/{shop_id}"] assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"] diff --git a/web/backend/routers/douyu_collab.py b/web/backend/routers/douyu_collab.py index 3aa3485..d66d9c5 100644 --- a/web/backend/routers/douyu_collab.py +++ b/web/backend/routers/douyu_collab.py @@ -79,6 +79,22 @@ def _publish_change(current: User, *, scope: str, action: str) -> None: collab_events.publish(scope=scope, action=action, actor_id=int(current.id)) +def _query_result_out(db: Session, result: dict) -> dict: + return { + "order": _one(db, result["order"]), + "metric": collab.serialize_metric(result["metric"]), + "scope": result["scope"], + "verdict": result["verdict"], + "message": result["message"], + "points_reason": result["points_reason"], + "fish_fin_balance": result["fish_fin_balance"], + "points_balance": result["points_balance"], + "required_total": result["required_total"], + "surplus": result["surplus"], + "errors": result["errors"], + } + + @router.websocket("/external-orders/ws") async def ws_external_orders(websocket: WebSocket): """协作台账实时失效通知:前端收到后按自己的筛选静默重拉。""" @@ -400,18 +416,29 @@ def query_external_order_activity( order = collab.get_order(db, order_id) result = collab.query_activity(db, current, order, source="manual") _publish_change(current, scope="orders", action="order.activity") - return { - "order": _one(db, result["order"]), - "metric": collab.serialize_metric(result["metric"]), - "verdict": result["verdict"], - "message": result["message"], - "points_reason": result["points_reason"], - "fish_fin_balance": result["fish_fin_balance"], - "points_balance": result["points_balance"], - "required_total": result["required_total"], - "surplus": result["surplus"], - "errors": result["errors"], - } + return _query_result_out(db, result) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/{order_id}/balances/query") +def query_external_order_balances( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + """只刷新鱼翅和手册积分,不查兑换记录、不判定、不改状态。""" + try: + order = collab.get_order(db, order_id) + result = collab.query_activity( + db, + current, + order, + source="balance_manual", + include_records=False, + ) + _publish_change(current, scope="orders", action="order.balances") + return _query_result_out(db, result) except collab.CollabError as exc: raise _http_error(exc) from exc @@ -467,6 +494,42 @@ def set_external_order_status_batch( return {"results": results, "success": all(item["success"] for item in results)} +@router.post("/external-orders/balances/query") +def query_external_orders_balances( + ids: list[int], + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + """批量刷新鱼翅和手册积分,不查兑换记录、不判定、不改状态。""" + results: list[dict] = [] + for order_id in ids: + try: + order = collab.get_order(db, order_id) + result = collab.query_activity( + db, + current, + order, + source="balance_batch", + include_records=False, + ) + results.append( + { + "id": order_id, + "success": True, + "message": result["message"], + "fish_fin_balance": result["fish_fin_balance"], + "points_balance": result["points_balance"], + "points_reason": result["points_reason"], + } + ) + except Exception as exc: # noqa: BLE001 + db.rollback() + results.append({"id": order_id, "success": False, "message": str(exc)}) + if any(item["success"] for item in results): + _publish_change(current, scope="orders", action="order.balances_batch") + return {"results": results, "success": all(item["success"] for item in results)} + + @router.post("/external-orders/activity/query") def query_external_orders_activity( ids: list[int], diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py index 3a07d9e..facec7d 100644 --- a/web/backend/services/douyu_collab_service.py +++ b/web/backend/services/douyu_collab_service.py @@ -1042,9 +1042,10 @@ def query_activity( *, client: Any | None = None, source: str = "manual", + include_records: bool = True, commit: bool = True, ) -> dict[str, Any]: - """查询鱼翅、积分和当前账号兑换记录,落一条快照。""" + """查询鱼翅、积分;``include_records`` 时同时查兑换记录并判定。""" account = account_by_id(db, order.account_id) or account_by_username( db, order.account_username ) @@ -1067,24 +1068,25 @@ def query_activity( gold = active_client.gold_account() except Exception as exc: # noqa: BLE001 errors.append(f"鱼翅: {exc}") - try: - records = ( - active_client.exchange_records( - manual_id=str(config.manual_id or ""), limit=100, offset=0 - ).get("records") - or [] - ) - except Exception as exc: # noqa: BLE001 - errors.append(f"兑换记录: {exc}") - try: - orders = ( - active_client.exchange_orders( - manual_id=str(config.manual_id or ""), page=1, page_size=10 - ).get("orders") - or [] - ) - except Exception as exc: # noqa: BLE001 - errors.append(f"锁单: {exc}") + if include_records: + try: + records = ( + active_client.exchange_records( + manual_id=str(config.manual_id or ""), limit=100, offset=0 + ).get("records") + or [] + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"兑换记录: {exc}") + try: + orders = ( + active_client.exchange_orders( + manual_id=str(config.manual_id or ""), page=1, page_size=10 + ).get("orders") + or [] + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"锁单: {exc}") manual_score = _to_int(elite.get("manual_score")) fish = _to_int(gold.get("gold")) @@ -1107,7 +1109,11 @@ def query_activity( threshold = normalize_threshold(order.points_threshold) order.points_threshold = threshold - if errors: + matched = 0 + if not include_records: + verdict = VERDICT_UNKNOWN + message = ";".join(errors) if errors else "仅查询鱼翅与积分,未查询兑换记录" + elif errors: verdict = VERDICT_UNKNOWN message = ";".join(errors) elif not order.gift_name: @@ -1160,7 +1166,7 @@ def query_activity( points_required_total=required_total, points_surplus=surplus, records_total=len(records), - records_matched=_match_gift(records, order.gift_name), + records_matched=matched, verdict=verdict, message=message, source=_text(source, max_length=16) or "manual", @@ -1183,6 +1189,7 @@ def query_activity( action="douyu:collab:activity", target=_status_audit_target(order), detail={ + "scope": "activity" if include_records else "balances", "verdict": verdict, "message": message, "fish_fin_balance": fish, @@ -1193,40 +1200,41 @@ def query_activity( }, ) - # 已上脚本的单,兑换记录命中/未命中是业务事实;其它状态只记快照。 - if order.status == STATUS_SCRIPTED and verdict == VERDICT_SUCCESS: - set_status( - db, - current, - order, - status=STATUS_DONE, - reason=message, - expected_version=None, - commit=False, - ) - elif order.status == STATUS_SCRIPTED and verdict == VERDICT_FAILED: - set_status( - db, - current, - order, - status=STATUS_FAILED, - reason=message, - expected_version=None, - commit=False, - ) - elif order.status in (STATUS_RECHARGED, STATUS_SCRIPTED) and reason in ( - "shortage", - "excess", - ): - set_status( - db, - current, - order, - status=STATUS_POINTS_ERROR, - reason=points_message, - expected_version=None, - commit=False, - ) + # 只有查兑换记录时才做业务判定;余额查询保持只读,不动状态。 + if include_records: + if order.status == STATUS_SCRIPTED and verdict == VERDICT_SUCCESS: + set_status( + db, + current, + order, + status=STATUS_DONE, + reason=message, + expected_version=None, + commit=False, + ) + elif order.status == STATUS_SCRIPTED and verdict == VERDICT_FAILED: + set_status( + db, + current, + order, + status=STATUS_FAILED, + reason=message, + expected_version=None, + commit=False, + ) + elif order.status in (STATUS_RECHARGED, STATUS_SCRIPTED) and reason in ( + "shortage", + "excess", + ): + set_status( + db, + current, + order, + status=STATUS_POINTS_ERROR, + reason=points_message, + expected_version=None, + commit=False, + ) if commit: db.commit() @@ -1235,6 +1243,7 @@ def query_activity( return { "order": order, "metric": metric, + "scope": "activity" if include_records else "balances", "verdict": verdict, "message": message, "points_reason": reason, @@ -1487,9 +1496,14 @@ def serialized_goods(db: Session) -> list[dict[str, Any]]: def _metric_views( db: Session, order_ids: list[int] -) -> dict[int, DouyuExternalOrderMetric]: +) -> tuple[dict[int, DouyuExternalOrderMetric], dict[int, DouyuExternalOrderMetric]]: + """返回最近一条快照,以及最近一条带兑换判定的快照。 + + 余额查询也会落快照,但它的 verdict 是 unknown;列表上的兑换结论应沿用 + 最近一次「查兑换记录」的结果,不能被余额刷新覆盖。 + """ if not order_ids: - return {} + return {}, {} rows = ( db.query(DouyuExternalOrderMetric) .filter(DouyuExternalOrderMetric.order_id.in_(order_ids)) @@ -1499,10 +1513,14 @@ def _metric_views( ) .all() ) - result: dict[int, DouyuExternalOrderMetric] = {} + latest: dict[int, DouyuExternalOrderMetric] = {} + verdict_latest: dict[int, DouyuExternalOrderMetric] = {} for row in rows: - result.setdefault(int(row.order_id), row) - return result + order_id = int(row.order_id) + latest.setdefault(order_id, row) + if not str(row.source or "").startswith("balance"): + verdict_latest.setdefault(order_id, row) + return latest, verdict_latest def account_clusters( @@ -1556,6 +1574,7 @@ def serialize_order( accounts: Mapping[int, Account] | None = None, cluster: Mapping[str, Any] | None = None, metric: DouyuExternalOrderMetric | None = None, + verdict_metric: DouyuExternalOrderMetric | None = None, ) -> dict[str, Any]: users = users or {} accounts = accounts or {} @@ -1630,16 +1649,16 @@ def serialize_order( "account_cluster": dict(cluster) if cluster else None, "last_metric_at": metric.created_at if metric else None, "last_metric_message": metric.message if metric else "", - "last_verdict": metric.verdict if metric else "", + "last_verdict": verdict_metric.verdict if verdict_metric else "", "last_verdict_label": { VERDICT_SUCCESS: "兑换成功", VERDICT_FAILED: "兑换失败", VERDICT_UNKNOWN: "无法判定", - }.get(metric.verdict, "") - if metric + }.get(verdict_metric.verdict, "") + if verdict_metric else "", - "last_records_total": metric.records_total if metric else 0, - "last_records_matched": metric.records_matched if metric else 0, + "last_records_total": verdict_metric.records_total if verdict_metric else 0, + "last_records_matched": verdict_metric.records_matched if verdict_metric else 0, } return payload @@ -1761,7 +1780,7 @@ def list_orders( db, {int(order.account_id) for order in orders if order.account_id} ) clusters = account_clusters(db, orders) - metrics = _metric_views(db, [int(order.id) for order in orders]) + metrics, verdict_metrics = _metric_views(db, [int(order.id) for order in orders]) items = [ serialize_order( order, @@ -1769,6 +1788,7 @@ def list_orders( accounts=accounts, cluster=clusters.get(int(order.account_id)) if order.account_id else None, metric=metrics.get(int(order.id)), + verdict_metric=verdict_metrics.get(int(order.id)), ) for order in orders ] @@ -1802,13 +1822,14 @@ def serialize_one(db: Session, order: DouyuExternalOrder) -> dict[str, Any]: db, {int(order.account_id)} if order.account_id else set() ) clusters = account_clusters(db, [order]) - metrics = _metric_views(db, [int(order.id)]) + metrics, verdict_metrics = _metric_views(db, [int(order.id)]) return serialize_order( order, users=users, accounts=accounts, cluster=clusters.get(int(order.account_id)) if order.account_id else None, metric=metrics.get(int(order.id)), + verdict_metric=verdict_metrics.get(int(order.id)), ) diff --git a/web/frontend/src/api/douyuCollab.ts b/web/frontend/src/api/douyuCollab.ts index 77ecc43..e94feae 100644 --- a/web/frontend/src/api/douyuCollab.ts +++ b/web/frontend/src/api/douyuCollab.ts @@ -260,6 +260,7 @@ export interface DouyuCollabTimeline { export interface DouyuCollabQueryResult { order: DouyuCollabOrder; metric: DouyuCollabMetric; + scope: 'activity' | 'balances'; verdict: string; message: string; points_reason: string; @@ -347,9 +348,13 @@ export const douyuCollabApi = { { results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean }, { results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean } >('/douyu/external-orders/verify/batch', ids), - queryActivity: (id: number) => + queryActivity: (id: number) => api.post( + `/douyu/external-orders/${id}/activity/query`, + ), + /** 只刷新鱼翅与手册积分,不查兑换记录、不判定、不改状态 */ + queryBalances: (id: number) => api.post( - `/douyu/external-orders/${id}/activity/query`, + `/douyu/external-orders/${id}/balances/query`, ), setStatus: (id: number, data: { status: DouyuCollabStatus; @@ -371,6 +376,12 @@ export const douyuCollabApi = { '/douyu/external-orders/activity/query', ids, ), + /** 批量只刷新鱼翅/积分,不查兑换记录、不判定、不改状态 */ + batchBalances: (ids: number[]) => + api.post<{ results: Array>; success: boolean }, { results: Array>; success: boolean }>( + '/douyu/external-orders/balances/query', + ids, + ), updateImages: (id: number, home_images: string[], expected_version?: number) => api.post(`/douyu/external-orders/${id}/images`, { home_images, diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx index 5fe4964..2b539f3 100644 --- a/web/frontend/src/pages/DouyuCollabPage.tsx +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -38,6 +38,7 @@ import { PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, + SearchOutlined, ShopOutlined, SyncOutlined, TagOutlined, @@ -214,6 +215,8 @@ export default function DouyuCollabPage() { const [gifts, setGifts] = useState([]); const [goods, setGoods] = useState([]); const [loading, setLoading] = useState(false); + const [balanceQuerying, setBalanceQuerying] = useState(false); + const [recordQuerying, setRecordQuerying] = useState(false); const [goodsLoading, setGoodsLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); @@ -631,18 +634,35 @@ export default function DouyuCollabPage() { } }; - const queryOrder = async (order: DouyuCollabOrder) => { + const queryOneBalances = async (order: DouyuCollabOrder) => { try { - message.loading({ content: '正在查询鱼翅、积分与兑换记录…', key: 'dy-collab-query' }); - const result = await douyuCollabApi.queryActivity(order.id); + message.loading({ content: '正在查询鱼翅、积分…', key: 'dy-collab-balance' }); + const result = await douyuCollabApi.queryBalances(order.id); message.success({ - content: `${result.message}${result.points_reason ? `;${pointsReasonMeta(result.points_reason).label}` : ''}`, - key: 'dy-collab-query', + content: `鱼翅 ${result.fish_fin_balance ?? '-'} / 积分 ${result.points_balance ?? '-'}${ + result.points_reason ? `;${pointsReasonMeta(result.points_reason).label}` : '' + }`, + key: 'dy-collab-balance', }); refreshAll(); if (detail?.id === order.id) void openDetail(order); } catch (error) { - message.error({ content: getErrorMessage(error), key: 'dy-collab-query' }); + message.error({ content: getErrorMessage(error), key: 'dy-collab-balance' }); + } + }; + + const queryOneRecords = async (order: DouyuCollabOrder) => { + try { + message.loading({ content: '正在查询兑换记录…', key: 'dy-collab-records' }); + const result = await douyuCollabApi.queryActivity(order.id); + message.success({ + content: `${result.message}${result.points_reason ? `;${pointsReasonMeta(result.points_reason).label}` : ''}`, + key: 'dy-collab-records', + }); + refreshAll(); + if (detail?.id === order.id) void openDetail(order); + } catch (error) { + message.error({ content: getErrorMessage(error), key: 'dy-collab-records' }); } }; @@ -716,21 +736,39 @@ export default function DouyuCollabPage() { } }; - const batchQuery = async () => { + const batchQueryBalances = async () => { if (selectedIds.length === 0) { message.warning('请先勾选协作单'); return; } - setLoading(true); + setBalanceQuerying(true); try { - const result = await douyuCollabApi.batchActivity(selectedIds); + const result = await douyuCollabApi.batchBalances(selectedIds); const ok = result.results.filter((item) => item.success).length; - message.success(`查询完成:${ok}/${result.results.length}`); + message.success(`鱼翅/积分查询完成:${ok}/${result.results.length}`); refreshAll(); } catch (error) { message.error(getErrorMessage(error)); } finally { - setLoading(false); + setBalanceQuerying(false); + } + }; + + const batchQueryRecords = async () => { + if (selectedIds.length === 0) { + message.warning('请先勾选协作单'); + return; + } + setRecordQuerying(true); + try { + const result = await douyuCollabApi.batchActivity(selectedIds); + const ok = result.results.filter((item) => item.success).length; + message.success(`兑换记录查询完成:${ok}/${result.results.length}`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setRecordQuerying(false); } }; @@ -1158,7 +1196,7 @@ export default function DouyuCollabPage() { }, { title: '操作', - width: 400, + width: 500, fixed: 'right', align: 'center', render: (_, row) => ( @@ -1196,9 +1234,16 @@ export default function DouyuCollabPage() { 校验 - + + + + + + } disabled={selectedCount === 0} onClick={openBatchStatus}> 批量改状态{selectedCount > 0 ? `(${selectedCount})` : ''} - + + + + + + - + ) : undefined }