diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py index ff0725c..df477c3 100644 --- a/tests/test_douyu_collab_orders.py +++ b/tests/test_douyu_collab_orders.py @@ -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}"] diff --git a/web/backend/routers/douyu_collab.py b/web/backend/routers/douyu_collab.py index e58aaa1..3aa3485 100644 --- a/web/backend/routers/douyu_collab.py +++ b/web/backend/routers/douyu_collab.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 5edc5a0..d72df79 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py index aecddaf..65c619a 100644 --- a/web/backend/services/douyu_collab_service.py +++ b/web/backend/services/douyu_collab_service.py @@ -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, + } diff --git a/web/frontend/src/api/douyuCollab.ts b/web/frontend/src/api/douyuCollab.ts index 025684e..dbeefdb 100644 --- a/web/frontend/src/api/douyuCollab.ts +++ b/web/frontend/src/api/douyuCollab.ts @@ -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( `/douyu/external-orders/${id}/ck/copy`, ), + exportUsernames: (ids: number[]) => + api.post( + '/douyu/external-orders/usernames/export', + { ids }, + ), 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 0f386a1..0948c45 100644 --- a/web/frontend/src/pages/DouyuCollabPage.tsx +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -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(null); const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain'); + // 批量导出斗鱼用户名:只读列表数据,不含 CK + const [usernameExportOpen, setUsernameExportOpen] = useState(false); + const [usernameExporting, setUsernameExporting] = useState(false); + const [usernameExport, setUsernameExport] = + useState(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() { {realtimePending && 有更新待同步} 已选 {selectedCount} 单 + {canView && ( + + + + )} {canExportCk && archiveScope !== 'archived' && ( , + , + ]} + destroyOnHidden + > + + + {usernameExport && usernameExport.skipped.length > 0 && ( + + 跳过 {usernameExport.skipped.length} 条: + {usernameExport.skipped + .map((item) => + item.order_no ? `${item.order_no}(${item.reason})` : item.reason, + ) + .join(';')} + + )} + + + 共 {usernameExport?.unique ?? 0} 个用户名 · {usernameExportText.length} 字符; + 按勾选订单导出,重复用户名只保留一次。 + + + +