feat(douyu): 支持批量导出用户名

This commit is contained in:
yml2213
2026-10-23 09:52:47 +08:00
parent bf2f120fdc
commit f074b75871
6 changed files with 322 additions and 1 deletions
+58
View File
@@ -9,6 +9,7 @@ from web.backend.schemas import (
DouyuCollabAccountLookupOut,
DouyuCollabCkExportOut,
DouyuCollabOrderOut,
DouyuCollabUsernameExportOut,
)
from web.backend.services import douyu_collab_service as collab
@@ -501,6 +502,62 @@ class TestDouyuCollabOrders:
collab_api.copy_external_order_ck(order.id, self.session, operator)
assert excinfo.value.status_code == 403
def test_export_usernames_dedupes_and_skips_empty(self):
first = collab.create_order(
self.session,
self.admin,
order_no="DY-USER-1",
account_username="douyu-account-a",
draft=True,
)
second = collab.create_order(
self.session,
self.admin,
order_no="DY-USER-2",
account_username="douyu-account-a",
draft=True,
)
blank = collab.create_order(
self.session,
self.admin,
order_no="DY-USER-3",
draft=True,
)
result = collab.export_usernames(
self.session,
[first.id, second.id, blank.id],
)
assert result["total"] == 3
assert result["exported"] == 2
assert result["unique"] == 1
assert result["usernames"] == ["douyu-account-a"]
assert [item["order_no"] for item in result["items"]] == [
"DY-USER-1",
"DY-USER-2",
]
assert result["skipped"] == [
{
"id": blank.id,
"order_no": "DY-USER-3",
"reason": "这单还没填斗鱼用户名",
}
]
DouyuCollabUsernameExportOut.model_validate(result)
def test_export_usernames_rejects_empty_and_too_many(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.export_usernames(self.session, [])
assert excinfo.value.code == "username_export_empty"
with pytest.raises(collab.CollabError) as excinfo:
collab.export_usernames(
self.session,
list(range(1, collab.USERNAME_EXPORT_BATCH_MAX + 2)),
)
assert excinfo.value.code == "username_export_too_large"
def test_ck_export_permission_defaults_to_operation(self):
from web.backend.permissions import ROLE_PERMISSIONS
@@ -519,6 +576,7 @@ class TestDouyuCollabOrders:
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/external-orders/usernames/export" 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}"]
+21
View File
@@ -44,6 +44,8 @@ from ..schemas import (
DouyuCollabStatusRequest,
DouyuCollabSummaryOut,
DouyuCollabTimelineOut,
DouyuCollabUsernameExportOut,
DouyuCollabUsernameExportRequest,
)
from ..services import douyu_collab_service as collab
from ..services.douyu_collab_events import collab_events
@@ -369,6 +371,25 @@ def copy_external_order_ck(
raise _http_error(exc) from exc
@router.post(
"/external-orders/usernames/export",
response_model=DouyuCollabUsernameExportOut,
)
def export_external_order_usernames(
req: DouyuCollabUsernameExportRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
"""勾选多行导出关联的斗鱼用户名(前端写剪贴板)。
用户名不是凭据;能看列表的人本来就能看到,因此只需要查看权限。
"""
try:
return collab.export_usernames(db, req.ids)
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,
+29
View File
@@ -1906,6 +1906,35 @@ class DouyuCollabCkExportOut(BaseModel):
items: list[DouyuCollabCkExportItemOut] = Field(default_factory=list)
class DouyuCollabUsernameExportRequest(BaseModel):
"""勾选多行,导出关联的斗鱼用户名(一次最多 500 条)。"""
ids: list[int] = Field(default_factory=list)
class DouyuCollabUsernameExportItemOut(BaseModel):
id: int
order_no: str = ""
account_username: str = ""
class DouyuCollabUsernameExportSkippedOut(BaseModel):
id: int
order_no: str = ""
reason: str = ""
class DouyuCollabUsernameExportOut(BaseModel):
"""``exported`` 是按单计数,``unique`` 是去重后的用户名个数。"""
total: int
exported: int
unique: int
skipped: list[DouyuCollabUsernameExportSkippedOut] = Field(default_factory=list)
items: list[DouyuCollabUsernameExportItemOut] = Field(default_factory=list)
usernames: list[str] = Field(default_factory=list)
class DouyuCollabOrderOut(BaseModel):
id: int
order_no: str
@@ -106,6 +106,7 @@ POINTS_THRESHOLD_MAX = 1_000_000
MAX_IMAGES = 20
CK_EXPORT_BATCH_MAX = 50
CK_EXPORT_ACTION = "douyu:collab:ck_export"
USERNAME_EXPORT_BATCH_MAX = 500
VERDICT_SUCCESS = "success"
VERDICT_FAILED = "failed"
@@ -2084,3 +2085,75 @@ def export_cks(
"skipped": skipped,
"items": items,
}
def export_usernames(
db: Session,
order_ids: Sequence[int],
) -> dict[str, Any]:
"""导出勾选订单关联的斗鱼用户名(去重、保持选择顺序)。
用户名不是凭据,能看列表的人本来就能看到,所以这里不额外要求 CK 导出权限,
也不写审计;没有用户名的行会显式跳过。
"""
requested = list(dict.fromkeys(int(item) for item in order_ids if item))
if not requested:
raise CollabError("请先勾选要导出的行", code="username_export_empty")
if len(requested) > USERNAME_EXPORT_BATCH_MAX:
raise CollabError(
f"一次最多导出 {USERNAME_EXPORT_BATCH_MAX} 行,请分批操作",
code="username_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]] = []
usernames: list[str] = []
seen: set[str] = set()
for order_id in requested:
order = by_id.get(order_id)
if order is None:
skipped.append(
{
"id": order_id,
"order_no": "",
"reason": "协作单不存在或已删除",
}
)
continue
username = str(order.account_username or "").strip()
if not username:
account = account_by_id(db, order.account_id) or account_by_username(
db, order.account_username
)
username = str(account.username if account else "").strip()
if not username:
skipped.append(
{
"id": int(order.id),
"order_no": order.order_no,
"reason": "这单还没填斗鱼用户名",
}
)
continue
items.append(
{
"id": int(order.id),
"order_no": order.order_no,
"account_username": username,
}
)
if username not in seen:
seen.add(username)
usernames.append(username)
return {
"total": len(requested),
"exported": len(items),
"unique": len(usernames),
"skipped": skipped,
"items": items,
"usernames": usernames,
}
+27
View File
@@ -130,6 +130,28 @@ export interface DouyuCollabCkExportResult {
items: DouyuCollabCkExportItem[];
}
export interface DouyuCollabUsernameExportItem {
id: number;
order_no: string;
account_username: string;
}
export interface DouyuCollabUsernameExportSkipped {
id: number;
order_no: string;
reason: string;
}
export interface DouyuCollabUsernameExportResult {
total: number;
exported: number;
unique: number;
skipped: DouyuCollabUsernameExportSkipped[];
items: DouyuCollabUsernameExportItem[];
usernames: string[];
}
export interface DouyuCollabSummary {
total: number;
@@ -301,6 +323,11 @@ export const douyuCollabApi = {
api.post<DouyuCollabCkExportResult, DouyuCollabCkExportResult>(
`/douyu/external-orders/${id}/ck/copy`,
),
exportUsernames: (ids: number[]) =>
api.post<DouyuCollabUsernameExportResult, DouyuCollabUsernameExportResult>(
'/douyu/external-orders/usernames/export',
{ ids },
),
getOrder: (id: number) =>
api.get<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`),
updateOrder: (id: number, data: DouyuCollabOrderUpdate) =>
+114 -1
View File
@@ -43,6 +43,7 @@ import {
SyncOutlined,
TagOutlined,
UndoOutlined,
UserOutlined,
} from '@ant-design/icons';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import DouyuCollabShopModal from '../components/DouyuCollabShopModal';
@@ -58,6 +59,7 @@ import type {
DouyuCollabStatus,
DouyuCollabSummary,
DouyuCollabTimeline,
DouyuCollabUsernameExportResult,
} from '../api/douyuCollab';
import { usePermissions } from '../hooks/usePermissions';
import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime';
@@ -258,6 +260,12 @@ export default function DouyuCollabPage() {
const [ckExport, setCkExport] = useState<DouyuCollabCkExportResult | null>(null);
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
// 批量导出斗鱼用户名:只读列表数据,不含 CK
const [usernameExportOpen, setUsernameExportOpen] = useState(false);
const [usernameExporting, setUsernameExporting] = useState(false);
const [usernameExport, setUsernameExport] =
useState<DouyuCollabUsernameExportResult | null>(null);
const loadOrders = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent === true;
if (!silent) setLoading(true);
@@ -337,7 +345,8 @@ export default function DouyuCollabPage() {
shopModalOpen ||
batchStatusOpen ||
statusTarget ||
ckExportOpen,
ckExportOpen ||
usernameExportOpen,
);
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = canView && !realtimePaused;
@@ -440,6 +449,11 @@ export default function DouyuCollabPage() {
[ckExport],
);
const usernameExportText = useMemo(
() => (usernameExport?.usernames ?? []).join('\n'),
[usernameExport],
);
const refreshAll = () => {
void loadOrders();
@@ -796,6 +810,38 @@ export default function DouyuCollabPage() {
}
};
const openUsernameExport = async () => {
if (selectedIds.length === 0) {
message.warning('请先勾选协作单');
return;
}
setUsernameExporting(true);
try {
const result = await douyuCollabApi.exportUsernames(selectedIds);
setUsernameExport(result);
setUsernameExportOpen(true);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setUsernameExporting(false);
}
};
const closeUsernameExport = () => {
setUsernameExportOpen(false);
setUsernameExport(null);
};
const copyUsernameExport = async () => {
if (!usernameExport?.unique) return;
try {
await copyTextToClipboard(usernameExportText);
message.success(`已复制 ${usernameExport.unique} 个斗鱼用户名到剪贴板`);
} catch (error) {
message.error(`复制失败:${getErrorMessage(error)}(可以在下面的框里手动选中复制)`);
}
};
const archiveOrders = async (order: DouyuCollabOrder) => {
try {
const result = await douyuCollabApi.archive([order.id], '');
@@ -1421,6 +1467,18 @@ export default function DouyuCollabPage() {
</Button>
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
<Text type="secondary">已选 {selectedCount} 单</Text>
{canView && (
<Tooltip title="导出勾选订单关联的斗鱼用户名,一行一个并自动去重">
<Button
icon={<UserOutlined />}
disabled={selectedCount === 0 || loading || usernameExporting}
loading={usernameExporting}
onClick={() => void openUsernameExport()}
>
批量导出用户名{selectedCount > 0 ? `(${selectedCount})` : ''}
</Button>
</Tooltip>
)}
{canExportCk && archiveScope !== 'archived' && (
<Tooltip title="勾选几行就复制几份关联账号的 CK;明文只在弹窗里出现">
<Button
@@ -1740,6 +1798,61 @@ export default function DouyuCollabPage() {
</Space>
</Modal>
<Modal
title={`导出斗鱼用户名(勾选 ${usernameExport?.total ?? 0} 条,共 ${usernameExport?.unique ?? 0} 个)`}
open={usernameExportOpen}
onCancel={closeUsernameExport}
width={680}
footer={[
<Button key="close" onClick={closeUsernameExport}>
关闭
</Button>,
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
disabled={!usernameExport?.unique}
onClick={() => void copyUsernameExport()}
>
复制到剪贴板
</Button>,
]}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="只导出斗鱼用户名,不包含 CK"
description="每行一个用户名,同名账号会自动去重;关闭弹窗后内容即清空。"
/>
{usernameExport && usernameExport.skipped.length > 0 && (
<Text type="warning" style={{ fontSize: 12 }}>
跳过 {usernameExport.skipped.length} 条:
{usernameExport.skipped
.map((item) =>
item.order_no ? `${item.order_no}(${item.reason})` : item.reason,
)
.join(';')}
</Text>
)}
<Input.TextArea
rows={10}
value={usernameExportText}
readOnly
placeholder="没有可导出的斗鱼用户名"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 12,
}}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
共 {usernameExport?.unique ?? 0} 个用户名 · {usernameExportText.length} 字符;
按勾选订单导出,重复用户名只保留一次。
</Text>
</Space>
</Modal>
<Modal
title={`导出 CK(勾选 ${ckExport?.total ?? 0} 条,可导出 ${ckExport?.exported ?? 0} 条)`}
open={ckExportOpen}