feat(huya): 协作台账勾选导出 CK 到剪贴板(单点权限 + 逐行审计)+ 工具区固定两行
This commit is contained in:
@@ -53,6 +53,8 @@ PERMISSIONS = {
|
||||
"huya:collab:view": "查看外部 CK 协作台账",
|
||||
"huya:collab:edit": "编辑外部 CK 协作台账",
|
||||
"huya:collab:manage": "删除外部 CK 协作台账",
|
||||
# CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点(同 cookie:export 的口径),默认只给运营
|
||||
"huya:collab:ck_export": "导出外部 CK 协作台账的 CK(写剪贴板)",
|
||||
# 代理 & 白名单
|
||||
"proxy:manage": "代理配置管理",
|
||||
"whitelist:manage": "白名单配置管理",
|
||||
@@ -105,6 +107,7 @@ ROLE_PERMISSIONS = {
|
||||
"huya:collab:view",
|
||||
"huya:collab:edit",
|
||||
"huya:collab:manage",
|
||||
"huya:collab:ck_export",
|
||||
"proxy:manage",
|
||||
"whitelist:manage",
|
||||
"whitelist:test",
|
||||
@@ -125,6 +128,8 @@ ROLE_PERMISSIONS = {
|
||||
# 客服能看能改(共享表,靠审计留痕),但删除只给运营。
|
||||
"huya:collab:view",
|
||||
"huya:collab:edit",
|
||||
# 注意:**不给** huya:collab:ck_export —— 贴 CK 是「录入」,导出是「把凭据带走」,
|
||||
# 需要时在权限页单独勾(口径同 huya:cookie:export,见设计文档 §11.8)。
|
||||
"file:view",
|
||||
"file:upload",
|
||||
],
|
||||
|
||||
@@ -34,6 +34,8 @@ from ...schemas import (
|
||||
HuyaExternalOrderActivityQueryOut,
|
||||
HuyaExternalOrderActivityQueryRequest,
|
||||
HuyaExternalOrderAuditOut,
|
||||
HuyaExternalOrderCkExportOut,
|
||||
HuyaExternalOrderCkExportRequest,
|
||||
HuyaExternalOrderCkRequest,
|
||||
HuyaExternalOrderCkStatusRequest,
|
||||
HuyaExternalOrderCreate,
|
||||
@@ -375,6 +377,25 @@ def verify_external_orders_ck(
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/export", response_model=HuyaExternalOrderCkExportOut)
|
||||
def export_external_orders_ck(
|
||||
req: HuyaExternalOrderCkExportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:collab:ck_export")),
|
||||
):
|
||||
"""勾选多行导出 **CK 明文**(前端写进剪贴板)。
|
||||
|
||||
单独一个权限点 ``huya:collab:ck_export``(默认只给运营):贴 CK 是「录入」,
|
||||
导出是「把凭据带走」。逐行写审计(``huya:collab:ck_export``),但**只记单号**,
|
||||
不把 CK 本体写进审计;没 CK 的行在 ``skipped`` 里显式回原因。
|
||||
"""
|
||||
try:
|
||||
orders = collab.list_orders_by_ids(db, req.ids)
|
||||
return collab.export_cks(db, current, orders)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 单行操作
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -1304,6 +1304,43 @@ class HuyaExternalOrderActivityQueryRequest(BaseModel):
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HuyaExternalOrderCkExportRequest(BaseModel):
|
||||
"""勾选多行,导出这些行的 CK 明文(前端写剪贴板;一次最多 50 条)。"""
|
||||
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HuyaExternalOrderCkExportItemOut(BaseModel):
|
||||
"""导出结果里的一行:``ck_text`` 是**明文凭据**,只走这个接口、不留日志。"""
|
||||
|
||||
id: int
|
||||
order_no: str = ""
|
||||
uid: str = ""
|
||||
nickname: str = ""
|
||||
status: str = ""
|
||||
status_label: str = ""
|
||||
ck_status: str = ""
|
||||
ck_status_label: str = ""
|
||||
ck_text: str = ""
|
||||
|
||||
|
||||
class HuyaExternalOrderCkExportSkippedOut(BaseModel):
|
||||
"""没导出的行(例如这行还没贴 CK)——把原因显式回给前端,别静默丢。"""
|
||||
|
||||
id: int
|
||||
order_no: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class HuyaExternalOrderCkExportOut(BaseModel):
|
||||
"""导出 CK 的结果:``exported + len(skipped) = total``。"""
|
||||
|
||||
total: int
|
||||
exported: int
|
||||
skipped: list[HuyaExternalOrderCkExportSkippedOut] = Field(default_factory=list)
|
||||
items: list[HuyaExternalOrderCkExportItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HuyaCollabMetricMatchedOut(BaseModel):
|
||||
"""匹配到的那条兑换记录(只留界面要用的几个字段)。"""
|
||||
|
||||
|
||||
@@ -1678,6 +1678,90 @@ def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrde
|
||||
return [by_id[value] for value in wanted if value in by_id]
|
||||
|
||||
|
||||
# 导出 CK 的审计动作名(导出=把凭据带出系统,必须留痕)
|
||||
CK_EXPORT_ACTION = "huya:collab:ck_export"
|
||||
|
||||
|
||||
def export_cks(
|
||||
db: Session,
|
||||
current: User | None,
|
||||
orders: Sequence[HuyaExternalOrder],
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict:
|
||||
"""把勾选行的 **CK 明文**取出来(前端写剪贴板用)。
|
||||
|
||||
这是本模块唯一一个把凭据**往外送**的读接口,所以三条约束写死在这里:
|
||||
|
||||
1. **权限单独一个点**(``huya:collab:ck_export``,默认只给运营)—— 贴 CK 是「录入」,
|
||||
导出是「把凭据带走」,两件事不该共用一个权限(口径同 ``huya:cookie:export``);
|
||||
2. **逐行写审计**(``huya:collab:ck_export``):只记「谁在什么时候导出了哪一单」,
|
||||
**绝不把 CK 本体写进审计/日志**(列表接口也照旧不返回 cookie);
|
||||
3. **没 CK 的行显式跳过并回原因**,不静默丢;也不替用户猜 ——
|
||||
作废/停用的单只要还有 CK 照样导出,界面把状态标出来让人自己判断。
|
||||
|
||||
Returns:
|
||||
``{total, exported, skipped: [{id, order_no, reason}], items: [{..., ck_text}]}``
|
||||
——只有这个接口会返回 ``ck_text``。
|
||||
"""
|
||||
rows = list(orders)
|
||||
if not rows:
|
||||
raise CollabError("请先勾选要导出的行", code="ck_export_empty")
|
||||
if len(rows) > PROBE_BATCH_MAX:
|
||||
raise CollabError(
|
||||
f"一次最多导出 {PROBE_BATCH_MAX} 行,请分批操作", code="ck_export_too_large"
|
||||
)
|
||||
|
||||
items: list[dict] = []
|
||||
skipped: list[dict] = []
|
||||
for order in rows:
|
||||
ck_text = str(order.cookie or "").strip()
|
||||
if not ck_text:
|
||||
skipped.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"reason": "这行还没贴 CK",
|
||||
}
|
||||
)
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"uid": str(order.uid or ""),
|
||||
"nickname": order.nickname or "",
|
||||
"status": order.status,
|
||||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||||
"ck_status": order.ck_status,
|
||||
"ck_status_label": CK_STATUS_LABELS.get(
|
||||
order.ck_status, order.ck_status
|
||||
),
|
||||
"ck_text": ck_text,
|
||||
}
|
||||
)
|
||||
record_audit(
|
||||
db,
|
||||
current,
|
||||
action=CK_EXPORT_ACTION,
|
||||
target=_audit_target(order),
|
||||
detail={
|
||||
"order_no": order.order_no,
|
||||
"uid": str(order.uid or ""),
|
||||
"ck_status": order.ck_status,
|
||||
"batch_size": len(rows),
|
||||
},
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
return {
|
||||
"total": len(rows),
|
||||
"exported": len(items),
|
||||
"skipped": skipped,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 店铺字典(新建协作单时点一下选;也支持自定义输入)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
HuyaCollabAuditItem,
|
||||
HuyaCollabActivityBatchResult,
|
||||
HuyaCollabActivityItem,
|
||||
HuyaCollabCkExportResult,
|
||||
HuyaCollabCkPreview,
|
||||
HuyaCollabCkRequest,
|
||||
HuyaCollabCkStatusRequest,
|
||||
@@ -277,6 +278,12 @@ export const huyaApi = {
|
||||
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
|
||||
collabOrderMetrics: (id: number) =>
|
||||
api.get<HuyaCollabMetric[], HuyaCollabMetric[]>(`/huya/external-orders/${id}/metrics`),
|
||||
/** 勾选导出 CK 明文(前端写剪贴板;单独权限点 huya:collab:ck_export,逐行写审计) */
|
||||
exportCollabOrderCk: (ids: number[]) =>
|
||||
api.post<HuyaCollabCkExportResult, HuyaCollabCkExportResult>(
|
||||
'/huya/external-orders/ck/export',
|
||||
{ ids },
|
||||
),
|
||||
/** 删除一行(只给运营:huya:collab:manage) */
|
||||
deleteCollabOrder: (id: number) =>
|
||||
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
|
||||
|
||||
@@ -1394,6 +1394,34 @@ export interface HuyaCollabStatusBatchResult {
|
||||
results: HuyaCollabStatusBatchItem[];
|
||||
}
|
||||
|
||||
/** 导出 CK 里的一行(ck_text 是**明文凭据**,只有导出接口会返回) */
|
||||
export interface HuyaCollabCkExportItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
uid: string;
|
||||
nickname: string;
|
||||
status: HuyaCollabStatus;
|
||||
status_label: string;
|
||||
ck_status: string;
|
||||
ck_status_label: string;
|
||||
ck_text: string;
|
||||
}
|
||||
|
||||
/** 没导出的行(例如这行还没贴 CK)——原因显式回给界面,不静默丢 */
|
||||
export interface HuyaCollabCkExportSkipped {
|
||||
id: number;
|
||||
order_no: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 勾选导出 CK 的结果:exported + skipped.length = total */
|
||||
export interface HuyaCollabCkExportResult {
|
||||
total: number;
|
||||
exported: number;
|
||||
skipped: HuyaCollabCkExportSkipped[];
|
||||
items: HuyaCollabCkExportItem[];
|
||||
}
|
||||
|
||||
export interface HuyaCollabOrderList {
|
||||
items: HuyaCollabOrder[];
|
||||
total: number;
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
|
||||
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic,
|
||||
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
|
||||
Switch, Table, Tag, Timeline, Tooltip, Typography, Upload,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
|
||||
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
|
||||
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
|
||||
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
fileApi,
|
||||
huyaApi,
|
||||
type HuyaCollabAuditItem,
|
||||
type HuyaCollabCkExportResult,
|
||||
type HuyaCollabCkPreview,
|
||||
type HuyaCollabGift,
|
||||
type HuyaCollabImageSlot,
|
||||
@@ -39,6 +40,7 @@ import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
|
||||
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
@@ -275,6 +277,8 @@ export default function HuyaCollabPage() {
|
||||
const { can, user } = usePermissions();
|
||||
const canEdit = can('huya:collab:edit');
|
||||
const canManage = can('huya:collab:manage');
|
||||
// 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py)
|
||||
const canExportCk = can('huya:collab:ck_export');
|
||||
|
||||
const [rows, setRows] = useState<HuyaCollabOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -336,6 +340,11 @@ export default function HuyaCollabPage() {
|
||||
const [querying, setQuerying] = useState(false);
|
||||
const [metrics, setMetrics] = useState<HuyaCollabMetric[]>([]);
|
||||
const [metricsLoading, setMetricsLoading] = useState(false);
|
||||
// 勾选导出 CK(明文只在这一个弹窗里出现;复制走剪贴板)
|
||||
const [ckExportOpen, setCkExportOpen] = useState(false);
|
||||
const [ckExporting, setCkExporting] = useState(false);
|
||||
const [ckExport, setCkExport] = useState<HuyaCollabCkExportResult | null>(null);
|
||||
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -549,6 +558,60 @@ export default function HuyaCollabPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 勾选导出 CK:先把明文拉回来(后端逐行写审计),再让用户复制。
|
||||
*
|
||||
* 明文只活在这次弹窗的 state 里:不进 localStorage、不写 URL、
|
||||
* 关掉弹窗就清掉(`closeCkExport()`)。
|
||||
*/
|
||||
const openCkExport = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setCkExporting(true);
|
||||
try {
|
||||
const result = await huyaApi.exportCollabOrderCk(selectedIds);
|
||||
setCkExport(result);
|
||||
setCkExportOpen(true);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setCkExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeCkExport = () => {
|
||||
setCkExportOpen(false);
|
||||
setCkExport(null);
|
||||
};
|
||||
|
||||
/** 导出文本:默认每行一份纯 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]);
|
||||
|
||||
// 已失效 / 已停用的 CK 照样导出(用户可能就是要看它),但得在界面上点名
|
||||
const ckExportRisky = useMemo(
|
||||
() => (ckExport?.items ?? []).filter(
|
||||
(item) => item.ck_status === 'invalid' || item.ck_status === 'disabled',
|
||||
),
|
||||
[ckExport],
|
||||
);
|
||||
|
||||
const copyCkExport = async () => {
|
||||
if (!ckExport?.exported) return;
|
||||
try {
|
||||
await copyTextToClipboard(ckExportText);
|
||||
message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`);
|
||||
} catch (e: unknown) {
|
||||
// 复制失败别吞:明文就在下面的框里,让人手动选中复制
|
||||
message.error(`复制失败:${getErrorMessage(e)}(可以在下面的框里手动选中复制)`);
|
||||
}
|
||||
};
|
||||
|
||||
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
|
||||
try {
|
||||
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
|
||||
@@ -1213,8 +1276,13 @@ export default function HuyaCollabPage() {
|
||||
</Row>
|
||||
|
||||
<Card size="small">
|
||||
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space wrap>
|
||||
{/*
|
||||
固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」
|
||||
那种排法:不勾选时两组挤成一行,勾选后按钮多了「(N)」就会被顶到第二行,
|
||||
列表跟着往下跳(2026-10-16 用户反馈「不要选中后两行」)。
|
||||
*/}
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">店铺</Text>
|
||||
<HuyaCollabShopPicker
|
||||
@@ -1247,7 +1315,8 @@ export default function HuyaCollabPage() {
|
||||
onChange={(checked) => { setPage(1); setFilters((prev) => ({ ...prev, include_void: checked })); }} />
|
||||
</Space>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
|
||||
{canEdit && (
|
||||
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
@@ -1284,6 +1353,14 @@ export default function HuyaCollabPage() {
|
||||
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
{canExportCk && (
|
||||
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
|
||||
<Button icon={<CopyOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
loading={ckExporting}
|
||||
onClick={() => void openCkExport()}>
|
||||
导出 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
|
||||
粘贴导入
|
||||
@@ -1934,6 +2011,58 @@ export default function HuyaCollabPage() {
|
||||
</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 本体。"
|
||||
/>
|
||||
{ckExport && ckExport.skipped.length > 0 && (
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
跳过 {ckExport.skipped.length} 条:
|
||||
{ckExport.skipped.map((item) => `${item.order_no}(${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})`).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>
|
||||
|
||||
<HuyaCollabShopModal
|
||||
open={shopModalOpen}
|
||||
shops={shops}
|
||||
|
||||
Reference in New Issue
Block a user