feat(douyu): 支持批量导出用户名
This commit is contained in:
@@ -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}"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user