feat(douyu): 外部单协作支持复制 CK

This commit is contained in:
yml2213
2026-10-23 09:29:53 +08:00
parent 96c4f41674
commit bf2f120fdc
7 changed files with 530 additions and 8 deletions
+131 -2
View File
@@ -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}"]
+4 -1
View File
@@ -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",
+39 -1
View File
@@ -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,
+36
View File
@@ -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
+99 -1
View File
@@ -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,
}
+37
View File
@@ -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<string, number>;
@@ -264,6 +291,16 @@ export const douyuCollabApi = {
),
createOrder: (data: DouyuCollabOrderCreate) =>
api.post<DouyuCollabOrder, DouyuCollabOrder>('/douyu/external-orders', data),
exportOrderCk: (ids: number[]) =>
api.post<DouyuCollabCkExportResult, DouyuCollabCkExportResult>(
'/douyu/external-orders/ck/export',
{ ids },
),
/** 单行复制 CK(仅超级管理员;后端再次校验角色) */
copyOrderCk: (id: number) =>
api.post<DouyuCollabCkExportResult, DouyuCollabCkExportResult>(
`/douyu/external-orders/${id}/ck/copy`,
),
getOrder: (id: number) =>
api.get<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`),
updateOrder: (id: number, data: DouyuCollabOrderUpdate) =>
+184 -3
View File
@@ -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<DouyuCollabOrder[]>([]);
const [summary, setSummary] = useState<DouyuCollabSummary>({
@@ -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<number | null>(null);
const [ckExport, setCkExport] = useState<DouyuCollabCkExportResult | null>(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() {
补资料
</Button>
)}
{isAdmin && !row.is_archived && (
<Tooltip title={row.has_cookie ? '复制关联账号的 CK 明文(仅管理员)' : '关联账号还没有 CK'}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!row.has_cookie || ckCopyingId === row.id}
loading={ckCopyingId === row.id}
onClick={() => void copyOneCk(row)}
>
复制CK
</Button>
</Tooltip>
)}
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>
详情
</Button>
@@ -1322,6 +1421,18 @@ export default function DouyuCollabPage() {
</Button>
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
<Text type="secondary">已选 {selectedCount} 单</Text>
{canExportCk && archiveScope !== 'archived' && (
<Tooltip title="勾选几行就复制几份关联账号的 CK;明文只在弹窗里出现">
<Button
icon={<CopyOutlined />}
disabled={selectedCount === 0 || loading || ckExporting}
loading={ckExporting}
onClick={() => void openCkExport()}
>
批量复制 CK{selectedCount > 0 ? `(${selectedCount})` : ''}
</Button>
</Tooltip>
)}
{canEdit && archiveScope !== 'archived' && (
<>
<Button
@@ -1385,7 +1496,7 @@ export default function DouyuCollabPage() {
loading={loading}
columns={columns}
dataSource={orders}
scroll={{ x: 2040 }}
scroll={{ x: 2090 }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
@@ -1629,6 +1740,76 @@ export default function DouyuCollabPage() {
</Space>
</Modal>
<Modal
title={`导出 CK(勾选 ${ckExport?.total ?? 0} 条,可导出 ${ckExport?.exported ?? 0} 条)`}
open={ckExportOpen}
onCancel={closeCkExport}
width={760}
footer={[
<Button key="close" onClick={closeCkExport}>
关闭
</Button>,
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
disabled={!ckExport?.exported}
onClick={() => void copyCkExport()}
>
复制到剪贴板
</Button>,
]}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="CK 是账号凭据"
description="斗鱼协作单不存 CK,这里导出的是关联内部账号当前的 cookie。复制后请勿发群 / 截图外传;每次导出都会写审计(谁、什么时候、导出了哪几单),审计里不存 CK 本体。"
/>
{ckExport && ckExport.skipped.length > 0 && (
<Text type="warning" style={{ fontSize: 12 }}>
跳过 {ckExport.skipped.length} 条:
{ckExport.skipped
.map((item) =>
item.order_no ? `${item.order_no}(${item.reason})` : item.reason,
)
.join(';')}
</Text>
)}
{ckExportRisky.length > 0 && (
<Text type="danger" style={{ fontSize: 12 }}>
其中 {ckExportRisky.length} 条关联账号的 CK 已失效:
{ckExportRisky
.map((item) => `${item.order_no}(${item.ck_status_label || 'CK 异常'})`)
.join(';')}
</Text>
)}
<Radio.Group
value={ckExportFormat}
onChange={(event) => setCkExportFormat(event.target.value)}
>
<Radio value="plain">每行一份纯 CK(直接粘)</Radio>
<Radio value="with_order">订单号 + Tab + CK(多选时好对号)</Radio>
</Radio.Group>
<Input.TextArea
rows={10}
value={ckExportText}
readOnly
placeholder="没有可导出的 CK"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 12,
}}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符;
关掉这个弹窗就清掉,不会留在页面上。
</Text>
</Space>
</Modal>
<Drawer
title={detail ? `协作单 ${detail.order_no}` : '协作单详情'}
open={detailOpen}