diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py index 33ece04..ff0725c 100644 --- a/tests/test_douyu_collab_orders.py +++ b/tests/test_douyu_collab_orders.py @@ -3,8 +3,13 @@ import pytest from tests.support.db import get_test_engine, make_test_session -from web.backend.models import Account, User -from web.backend.schemas import DouyuCollabAccountLookupOut, DouyuCollabOrderOut +from web.backend.models import Account, AuditLog, User +from web.backend.routers import douyu_collab as collab_api +from web.backend.schemas import ( + DouyuCollabAccountLookupOut, + DouyuCollabCkExportOut, + DouyuCollabOrderOut, +) from web.backend.services import douyu_collab_service as collab @@ -381,6 +386,128 @@ class TestDouyuCollabOrders: assert collab.list_orders(self.session, ck_status=collab.CK_VALID)["total"] == 0 assert collab.summary(self.session)["ck_invalid"] == 1 + def test_export_cks_reads_linked_account_and_skips_missing_cookie(self): + first = collab.create_order( + self.session, + self.admin, + order_no="DY-EXP-1", + account_username="douyu-account-a", + draft=True, + ) + blank_account = Account( + username="douyu-account-blank", + password="password", + email="blank@example.com", + email_password="", + cookie="", + ) + self.session.add(blank_account) + self.session.commit() + blank = collab.create_order( + self.session, + self.admin, + order_no="DY-EXP-2", + account_username="douyu-account-blank", + draft=True, + ) + missing = collab.create_order( + self.session, + self.admin, + order_no="DY-EXP-3", + account_username="douyu-account-missing", + draft=True, + ) + self.account.ck_check_status = collab.CK_VALID + self.session.commit() + + result = collab.export_cks( + self.session, + self.admin, + [first.id, blank.id, missing.id], + ) + + assert result["total"] == 3 + assert result["exported"] == 1 + assert result["items"][0]["order_no"] == "DY-EXP-1" + assert result["items"][0]["ck_text"] == self.account.cookie + assert result["items"][0]["account_username"] == self.account.username + assert result["items"][0]["ck_status_label"] == "CK 正常" + assert [item["order_no"] for item in result["skipped"]] == [ + "DY-EXP-2", + "DY-EXP-3", + ] + assert "没有 CK" in result["skipped"][0]["reason"] + assert "还没绑定" in result["skipped"][1]["reason"] + DouyuCollabCkExportOut.model_validate(result) + + def test_export_ck_audit_never_contains_the_cookie_body(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-EXP-4", + account_username="douyu-account-a", + draft=True, + ) + + collab.export_cks(self.session, self.admin, [order.id]) + + audit = ( + self.session.query(AuditLog) + .filter(AuditLog.action == collab.CK_EXPORT_ACTION) + .order_by(AuditLog.id.desc()) + .first() + ) + assert audit is not None + assert audit.username == self.admin.username + assert order.order_no in audit.detail + assert "douyu-account-a" in audit.detail + assert self.account.cookie not in audit.detail + + def test_export_cks_rejects_empty_and_too_many(self): + with pytest.raises(collab.CollabError) as excinfo: + collab.export_cks(self.session, self.admin, []) + assert excinfo.value.code == "ck_export_empty" + + with pytest.raises(collab.CollabError) as excinfo: + collab.export_cks( + self.session, + self.admin, + list(range(1, collab.CK_EXPORT_BATCH_MAX + 2)), + ) + assert excinfo.value.code == "ck_export_too_large" + + def test_single_row_copy_is_super_admin_only(self): + operator = User( + username="douyu-collab-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-EXP-5", + account_username="douyu-account-a", + draft=True, + ) + + result = collab_api.copy_external_order_ck(order.id, self.session, self.admin) + assert result["exported"] == 1 + assert result["items"][0]["ck_text"] == self.account.cookie + + with pytest.raises(collab_api.HTTPException) as excinfo: + collab_api.copy_external_order_ck(order.id, self.session, operator) + assert excinfo.value.status_code == 403 + + def test_ck_export_permission_defaults_to_operation(self): + from web.backend.permissions import ROLE_PERMISSIONS + + assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["operation"] + assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["super_admin"] + assert "douyu:collab:ck_export" not in ROLE_PERMISSIONS["support"] + def test_douyu_collab_routes_registered(self): from web.backend.main import app @@ -390,6 +517,8 @@ class TestDouyuCollabOrders: assert "/api/douyu/external-orders/status/batch" in paths assert "/api/douyu/external-orders/verify/batch" in paths assert "/api/douyu/external-orders/accounts/lookup" in paths + assert "/api/douyu/external-orders/ck/export" in paths + assert "/api/douyu/external-orders/{order_id}/ck/copy" 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/permissions.py b/web/backend/permissions.py index 6905fdc..96af602 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -31,10 +31,12 @@ PERMISSIONS = { # 斗鱼活动 "douyu:task": "斗鱼任务管理", "douyu:config": "斗鱼配置管理", - # 斗鱼外部单协作(订单挂内部账号,不复制 CK) + # 斗鱼外部单协作(订单挂内部账号,导出时实时读取账号 CK) "douyu:collab:view": "查看斗鱼外部单协作", "douyu:collab:edit": "编辑斗鱼外部单协作", "douyu:collab:manage": "删除斗鱼外部单协作", + # CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点,默认只给运营 + "douyu:collab:ck_export": "导出斗鱼外部单协作账号的 CK(写剪贴板)", "yyb:session": "应用宝扫码登录与角色查询", "yyb:recharge": "应用宝充值与付款", "yyb:manage": "接管并操作他人的应用宝任务", @@ -94,6 +96,7 @@ ROLE_PERMISSIONS = { "douyu:collab:view", "douyu:collab:edit", "douyu:collab:manage", + "douyu:collab:ck_export", "yyb:session", "yyb:recharge", "yyb:manage", diff --git a/web/backend/routers/douyu_collab.py b/web/backend/routers/douyu_collab.py index 4c0647b..e58aaa1 100644 --- a/web/backend/routers/douyu_collab.py +++ b/web/backend/routers/douyu_collab.py @@ -19,7 +19,7 @@ from fastapi import ( from sqlalchemy.orm import Session from ..database import get_db -from ..deps import authenticate_websocket, require_permission +from ..deps import authenticate_websocket, get_current_user, require_permission from ..models import User from ..permissions import get_user_permissions from ..schemas import ( @@ -27,6 +27,8 @@ from ..schemas import ( DouyuCollabAccountLookupRequest, DouyuCollabAccountRequest, DouyuCollabArchiveRequest, + DouyuCollabCkExportOut, + DouyuCollabCkExportRequest, DouyuCollabGoodsOut, DouyuCollabImagesRequest, DouyuCollabMetricOut, @@ -331,6 +333,42 @@ def verify_external_orders_batch( return {"results": results, "success": all(item["success"] for item in results)} +@router.post("/external-orders/ck/export", response_model=DouyuCollabCkExportOut) +def export_external_orders_ck( + req: DouyuCollabCkExportRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:ck_export")), +): + """勾选多行导出关联内部账号的 CK 明文(前端写剪贴板)。 + + 斗鱼协作单不存 CK,导出的始终是关联账号的实时 ``cookie``。逐行写审计, + 但审计里只记单号/账号/CK 状态,不把 CK 本体写进审计。 + """ + try: + return collab.export_cks(db, current, req.ids) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post( + "/external-orders/{order_id}/ck/copy", + response_model=DouyuCollabCkExportOut, +) +def copy_external_order_ck( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """复制单行 CK 明文(仅超级管理员;与虎牙协作台账口径一致)。""" + if current.role != "super_admin": + raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK") + try: + order = collab.get_order(db, order_id) + return collab.export_cks(db, current, [order.id]) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + @router.post("/external-orders/{order_id}/activity/query") def query_external_order_activity( order_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index a3754ff..5edc5a0 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1870,6 +1870,42 @@ class DouyuCollabArchiveRequest(BaseModel): reason: str = Field("", max_length=256) +class DouyuCollabCkExportRequest(BaseModel): + """勾选多行,导出关联内部账号的 CK 明文(一次最多 50 条)。""" + + ids: list[int] = Field(default_factory=list) + + +class DouyuCollabCkExportItemOut(BaseModel): + """导出结果里的一行:``ck_text`` 是明文凭据,只走这个接口、不留日志。""" + + id: int + order_no: str = "" + account_id: int | None = None + account_username: str = "" + account_nickname: str = "" + status: str = "" + status_label: str = "" + ck_status: str = "" + ck_status_label: str = "" + ck_text: str = "" + + +class DouyuCollabCkExportSkippedOut(BaseModel): + id: int + order_no: str = "" + reason: str = "" + + +class DouyuCollabCkExportOut(BaseModel): + """导出 CK 的结果:``exported + len(skipped) = total``。""" + + total: int + exported: int + skipped: list[DouyuCollabCkExportSkippedOut] = Field(default_factory=list) + items: list[DouyuCollabCkExportItemOut] = Field(default_factory=list) + + class DouyuCollabOrderOut(BaseModel): id: int order_no: str diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py index ff01bda..aecddaf 100644 --- a/web/backend/services/douyu_collab_service.py +++ b/web/backend/services/douyu_collab_service.py @@ -13,7 +13,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import UTC, datetime from typing import Any @@ -104,6 +104,8 @@ POINTS_GROUP_STATUSES: tuple[str, ...] = ( POINTS_THRESHOLD_DEFAULT = 100 POINTS_THRESHOLD_MAX = 1_000_000 MAX_IMAGES = 20 +CK_EXPORT_BATCH_MAX = 50 +CK_EXPORT_ACTION = "douyu:collab:ck_export" VERDICT_SUCCESS = "success" VERDICT_FAILED = "failed" @@ -1986,3 +1988,99 @@ def delete_order( db.delete(order) db.commit() return {"success": True, "order_no": order_no} + + +def export_cks( + db: Session, + current: User, + order_ids: Sequence[int], + *, + commit: bool = True, +) -> dict[str, Any]: + """导出协作单关联内部账号的 **CK 明文**(前端写剪贴板用)。 + + 斗鱼协作单本身不存 CK,导出的始终是关联 ``accounts.cookie`` 的实时值。 + 和虎牙台账保持同一口径: + + 1. 需要独立权限点 ``douyu:collab:ck_export``; + 2. 每条成功导出都写审计,但审计里只记单号/账号/CK 状态,**不记 CK 本体**; + 3. 未绑定账号、账号没有 CK、订单已删除的行显式跳过并返回原因。 + """ + requested = list(dict.fromkeys(int(item) for item in order_ids if item)) + if not requested: + raise CollabError("请先勾选要导出的行", code="ck_export_empty") + if len(requested) > CK_EXPORT_BATCH_MAX: + raise CollabError( + f"一次最多导出 {CK_EXPORT_BATCH_MAX} 行,请分批操作", + code="ck_export_too_large", + ) + + rows = ( + db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(requested)).all() + ) + by_id = {int(order.id): order for order in rows} + items: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for order_id in requested: + order = by_id.get(order_id) + if order is None: + skipped.append( + { + "id": order_id, + "order_no": "", + "reason": "协作单不存在或已删除", + } + ) + continue + account = account_by_id(db, order.account_id) or account_by_username( + db, order.account_username + ) + ck_text = str(account.cookie or "").strip() if account else "" + if not ck_text: + reason = ( + "这单还没绑定斗鱼账号" if account is None else "关联的斗鱼账号还没有 CK" + ) + skipped.append( + { + "id": int(order.id), + "order_no": order.order_no, + "reason": reason, + } + ) + continue + + ck_status = str(account.ck_check_status or "") + items.append( + { + "id": int(order.id), + "order_no": order.order_no, + "account_id": int(account.id), + "account_username": account.username or order.account_username or "", + "account_nickname": account.nickname or order.account_nickname or "", + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "ck_status": ck_status, + "ck_status_label": CK_LABELS.get(ck_status, ""), + "ck_text": ck_text, + } + ) + record_audit( + db, + current, + action=CK_EXPORT_ACTION, + target=f"douyu_external_order:{order.id}", + detail={ + "order_no": order.order_no, + "account_username": account.username or order.account_username or "", + "ck_status": ck_status, + "batch_size": len(requested), + }, + ) + if commit: + db.commit() + return { + "total": len(requested), + "exported": len(items), + "skipped": skipped, + "items": items, + } diff --git a/web/frontend/src/api/douyuCollab.ts b/web/frontend/src/api/douyuCollab.ts index 9753aa9..025684e 100644 --- a/web/frontend/src/api/douyuCollab.ts +++ b/web/frontend/src/api/douyuCollab.ts @@ -104,6 +104,33 @@ export interface DouyuCollabOrderList { page_size: number; } +export interface DouyuCollabCkExportItem { + id: number; + order_no: string; + account_id: number | null; + account_username: string; + account_nickname: string; + status: DouyuCollabStatus; + status_label: string; + ck_status: string; + ck_status_label: string; + ck_text: string; +} + +export interface DouyuCollabCkExportSkipped { + id: number; + order_no: string; + reason: string; +} + +export interface DouyuCollabCkExportResult { + total: number; + exported: number; + skipped: DouyuCollabCkExportSkipped[]; + items: DouyuCollabCkExportItem[]; +} + + export interface DouyuCollabSummary { total: number; by_status: Record; @@ -264,6 +291,16 @@ export const douyuCollabApi = { ), createOrder: (data: DouyuCollabOrderCreate) => api.post('/douyu/external-orders', data), + exportOrderCk: (ids: number[]) => + api.post( + '/douyu/external-orders/ck/export', + { ids }, + ), + /** 单行复制 CK(仅超级管理员;后端再次校验角色) */ + copyOrderCk: (id: number) => + api.post( + `/douyu/external-orders/${id}/ck/copy`, + ), getOrder: (id: number) => api.get(`/douyu/external-orders/${id}`), updateOrder: (id: number, data: DouyuCollabOrderUpdate) => diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx index 0c8c2cd..0f386a1 100644 --- a/web/frontend/src/pages/DouyuCollabPage.tsx +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -15,6 +15,7 @@ import { Input, InputNumber, Modal, + Radio, Row, Select, Space, @@ -29,6 +30,7 @@ import { } from 'antd'; import { CheckCircleOutlined, + CopyOutlined, DeleteOutlined, EditOutlined, FormOutlined, @@ -48,6 +50,7 @@ import DouyuCollabShopPicker from '../components/DouyuCollabShopPicker'; import { douyuCollabApi } from '../api/modules'; import type { DouyuCollabAccountLookup, + DouyuCollabCkExportResult, DouyuCollabGoods, DouyuCollabMetric, DouyuCollabOrder, @@ -60,6 +63,7 @@ import { usePermissions } from '../hooks/usePermissions'; import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime'; import { message } from '../utils/antdMessage'; import { getErrorMessage } from '../utils/error'; +import { copyTextToClipboard } from '../utils/clipboard'; const { Text } = Typography; @@ -192,6 +196,9 @@ export default function DouyuCollabPage() { const canView = can('douyu:collab:view'); const canEdit = can('douyu:collab:edit'); const canManage = can('douyu:collab:manage'); + const canExportCk = can('douyu:collab:ck_export'); + // 单行复制是一键带走凭据,和虎牙台账保持一致:只给超级管理员(后端再校验) + const isAdmin = user?.role === 'super_admin'; const [orders, setOrders] = useState([]); const [summary, setSummary] = useState({ @@ -244,6 +251,13 @@ export default function DouyuCollabPage() { const [statusReason, setStatusReason] = useState(''); const [statusBusy, setStatusBusy] = useState(false); + // 勾选导出 CK:明文只在弹窗 state 里,关闭即清空 + const [ckExportOpen, setCkExportOpen] = useState(false); + const [ckExporting, setCkExporting] = useState(false); + const [ckCopyingId, setCkCopyingId] = useState(null); + const [ckExport, setCkExport] = useState(null); + const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain'); + const loadOrders = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent === true; if (!silent) setLoading(true); @@ -318,7 +332,12 @@ export default function DouyuCollabPage() { }, [loadMeta]); const collabModalOpen = Boolean( - createOpen || detailOpen || shopModalOpen || batchStatusOpen || statusTarget, + createOpen || + detailOpen || + shopModalOpen || + batchStatusOpen || + statusTarget || + ckExportOpen, ); // 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。 const realtimeSyncEnabled = canView && !realtimePaused; @@ -406,6 +425,22 @@ export default function DouyuCollabPage() { selectedOrders.length === selectedIds.length && selectedOrders.every((row) => row.status === 'done' && !row.is_archived); + /** 导出文本:默认每行一份纯 CK,也可以带订单号方便多选对号。 */ + const ckExportText = useMemo(() => { + if (!ckExport) return ''; + return ckExport.items + .map((item) => + ckExportFormat === 'with_order' ? `${item.order_no}\t${item.ck_text}` : item.ck_text, + ) + .join('\n'); + }, [ckExport, ckExportFormat]); + + const ckExportRisky = useMemo( + () => (ckExport?.items ?? []).filter((item) => item.ck_status === 'invalid'), + [ckExport], + ); + + const refreshAll = () => { void loadOrders(); void loadSummary(); @@ -710,6 +745,57 @@ export default function DouyuCollabPage() { } }; + const openCkExport = async () => { + if (selectedIds.length === 0) { + message.warning('请先勾选协作单'); + return; + } + setCkExporting(true); + try { + const result = await douyuCollabApi.exportOrderCk(selectedIds); + setCkExport(result); + setCkExportOpen(true); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setCkExporting(false); + } + }; + + const closeCkExport = () => { + setCkExportOpen(false); + setCkExport(null); + }; + + const copyCkExport = async () => { + if (!ckExport?.exported) return; + try { + await copyTextToClipboard(ckExportText); + message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`); + } catch (error) { + message.error(`复制失败:${getErrorMessage(error)}(可以在下面的框里手动选中复制)`); + } + }; + + /** 单行复制:斗鱼协作单不存 CK,这里实时读取关联内部账号的 cookie。 */ + const copyOneCk = async (row: DouyuCollabOrder) => { + setCkCopyingId(row.id); + try { + const result = await douyuCollabApi.copyOrderCk(row.id); + const item = result.items[0]; + if (!item) { + message.warning(result.skipped[0]?.reason || '这行还没有可复制的 CK'); + return; + } + await copyTextToClipboard(item.ck_text); + message.success(`已复制 ${row.order_no} 的 CK`); + } catch (error) { + message.error(`复制失败:${getErrorMessage(error)}`); + } finally { + setCkCopyingId(null); + } + }; + const archiveOrders = async (order: DouyuCollabOrder) => { try { const result = await douyuCollabApi.archive([order.id], ''); @@ -1028,7 +1114,7 @@ export default function DouyuCollabPage() { }, { title: '操作', - width: 350, + width: 400, fixed: 'right', align: 'center', render: (_, row) => ( @@ -1043,6 +1129,19 @@ export default function DouyuCollabPage() { 补资料 )} + {isAdmin && !row.is_archived && ( + + + + )} @@ -1322,6 +1421,18 @@ export default function DouyuCollabPage() { {realtimePending && 有更新待同步} 已选 {selectedCount} 单 + {canExportCk && archiveScope !== 'archived' && ( + + + + )} {canEdit && archiveScope !== 'archived' && ( <> , + , + ]} + destroyOnHidden + > + + + {ckExport && ckExport.skipped.length > 0 && ( + + 跳过 {ckExport.skipped.length} 条: + {ckExport.skipped + .map((item) => + item.order_no ? `${item.order_no}(${item.reason})` : item.reason, + ) + .join(';')} + + )} + {ckExportRisky.length > 0 && ( + + 其中 {ckExportRisky.length} 条关联账号的 CK 已失效: + {ckExportRisky + .map((item) => `${item.order_no}(${item.ck_status_label || 'CK 异常'})`) + .join(';')} + + )} + setCkExportFormat(event.target.value)} + > + 每行一份纯 CK(直接粘) + 订单号 + Tab + CK(多选时好对号) + + + + 共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符; + 关掉这个弹窗就清掉,不会留在页面上。 + + + +