From 11b5a4d938eeee1f1960a03a348058ce33fca018 Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 11 Nov 2026 21:10:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=B1=95=E7=A4=BA=20CK=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=87=8D=E5=A4=8D=E8=B4=A6=E5=8F=B7=E6=98=8E?= =?UTF-8?q?=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../微博/微博账号池与个人工作台-2026-11-08.md | 3 ++ tests/test_weibo_accounts.py | 24 ++++++++++++ web/backend/routers/weibo.py | 21 +++++++++- web/backend/schemas.py | 8 ++++ web/backend/services/weibo_service.py | 38 +++++++++++++++++-- web/frontend/src/api/types.ts | 8 ++++ web/frontend/src/pages/WeiboAccountsPage.tsx | 26 +++++++++++++ 7 files changed, 123 insertions(+), 5 deletions(-) diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index 06e0d2f..121da15 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -170,6 +170,9 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 - 默认重复导入:不覆盖现有 CK,返回 `skipped=1`。 - 勾选「重复账号时更新 CK」:覆盖现有账号 CK 和最新资料,但**不新建账号**。 +- 重复结果不是只有数量:`duplicates[]` 逐条返回输入行号、UID、昵称和原因, + 并区分「账号池已存在」与「本次粘贴内容中 UID 重复」;导入弹窗直接列出, + 顶部提示只预览前三个,完整明细保留在弹窗。 - 客服不能更新已分配给其他客服的账号 CK,也不能通过重复导入改变账号归属。 - 客服导入新账号时,账号会自动分配给当前客服。 - 若账号已被其他人的工作台占用,其他用户不能更新 CK,必须由当前使用人操作。 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 6133550..74cb5df 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -164,6 +164,16 @@ def test_import_creates_once_and_updates_existing_only_when_requested(db, curren assert duplicate.count == 0 assert duplicate.skipped == 1 assert duplicate.accounts == [] + assert [item.model_dump() for item in duplicate.duplicates] == [ + { + "line": 1, + "uid": "4014000001", + "nickname": "测试微博", + "message": "账号池中已存在,未勾选更新 CK", + } + ] + assert "4014000001" in duplicate.message + assert "测试微博" in duplicate.message assert db.query(WeiboAccount).count() == 1 account = db.query(WeiboAccount).one() assert account.uid == "4014000001" @@ -198,6 +208,20 @@ def test_import_creates_once_and_updates_existing_only_when_requested(db, curren assert "cookie" not in dumped +def test_import_reports_duplicate_uid_inside_same_paste(db, current_user): + result = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=first\nSCF=b; SUB=second"), + db=db, + current=current_user, + ) + + assert result.count == 1 + assert result.skipped == 1 + assert result.duplicates[0].line == 2 + assert result.duplicates[0].uid == "4014000001" + assert result.duplicates[0].message == "本次粘贴内容中该 UID 重复" + + def test_import_reports_invalid_ck_per_line_and_keeps_valid_account(db, current_user): result = import_cookies( WeiboCookieImport(text="SCF=a; SUB=expired\nSCF=b; SUB=valid", tag=""), diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index e32c366..494b001 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -32,6 +32,7 @@ from ..schemas import ( WeiboExchangeRecordsOut, WeiboExchangeRequest, WeiboGoodsOut, + WeiboImportDuplicateOut, WeiboImportError, WeiboImportResult, WeiboMallListOut, @@ -980,7 +981,7 @@ def import_cookies( """导入微博 CK;每个非空行一条完整 Cookie。""" _require_weibo_perm(current, "weibo:import") - accounts, errors, skipped = import_weibo_cookies( + accounts, errors, skipped, duplicates = import_weibo_cookies( db, req.text, req.tag, @@ -992,6 +993,15 @@ def import_cookies( typed_errors = [ WeiboImportError(line=item.line, message=item.message) for item in errors ] + typed_duplicates = [ + WeiboImportDuplicateOut( + line=item.line, + uid=item.uid, + nickname=item.nickname, + message=item.message, + ) + for item in duplicates + ] if accounts and not _can_view_all_weibo_accounts(current): changed = False for account in accounts: @@ -1012,6 +1022,7 @@ def import_cookies( "uids": [account.uid for account in accounts], "count": len(accounts), "skipped": skipped, + "duplicate_uids": [item.uid for item in duplicates], "errors": len(errors), }, ) @@ -1019,13 +1030,21 @@ def import_cookies( if errors: message += f",失败 {len(errors)} 条" if skipped: + preview = "、".join( + f"{item.uid}({item.nickname or '无昵称'})" for item in duplicates[:3] + ) message += f",跳过 {skipped} 条重复" + if preview: + message += f":{preview}" + if len(duplicates) > 3: + message += f" 等 {len(duplicates)} 个" return WeiboImportResult( success=bool(accounts) or (skipped > 0 and not errors), message=message, count=len(accounts), skipped=skipped, errors=typed_errors, + duplicates=typed_duplicates, accounts=[_account_out(account) for account in accounts], ) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 1e6cf31..d2f86b2 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -480,6 +480,13 @@ class WeiboImportError(BaseModel): message: str +class WeiboImportDuplicateOut(BaseModel): + line: int + uid: str = "" + nickname: str = "" + message: str + + class WeiboAccountOut(BaseModel): id: int uid: str @@ -576,6 +583,7 @@ class WeiboImportResult(BaseModel): count: int skipped: int errors: list[WeiboImportError] = Field(default_factory=list) + duplicates: list[WeiboImportDuplicateOut] = Field(default_factory=list) accounts: list[WeiboAccountOut] = Field(default_factory=list) diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 26cf590..4a67bc1 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -27,6 +27,14 @@ class WeiboImportIssue: message: str +@dataclass(frozen=True, slots=True) +class WeiboImportDuplicate: + line: int + uid: str + nickname: str + message: str + + def _now() -> datetime: return datetime.now(UTC) @@ -64,15 +72,21 @@ def import_weibo_cookies( update_existing: bool = False, current_user_id: int | None = None, can_update_all: bool = False, -) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]: +) -> tuple[ + list[WeiboAccount], + list[WeiboImportIssue], + int, + list[WeiboImportDuplicate], +]: """逐行验证并导入微博 CK。 - 返回 ``(成功账号, 错误列表, 跳过数)``。单行 CK 失效或网络失败只记录该行, - 不阻断其余账号导入。 + 返回 ``(成功账号, 错误列表, 跳过数, 重复明细)``。单行 CK 失效或网络失败 + 只记录该行,不阻断其余账号导入。 """ accounts: list[WeiboAccount] = [] errors: list[WeiboImportIssue] = [] + duplicates: list[WeiboImportDuplicate] = [] skipped = 0 seen_uids: set[str] = set() clean_tag = (tag or "").strip() @@ -92,6 +106,14 @@ def import_weibo_cookies( if snapshot.uid in seen_uids: skipped += 1 + duplicates.append( + WeiboImportDuplicate( + line=line_no, + uid=snapshot.uid, + nickname=snapshot.nickname, + message="本次粘贴内容中该 UID 重复", + ) + ) continue seen_uids.add(snapshot.uid) @@ -126,6 +148,14 @@ def import_weibo_cookies( continue if account is not None and not update_existing: skipped += 1 + duplicates.append( + WeiboImportDuplicate( + line=line_no, + uid=snapshot.uid, + nickname=account.nickname or snapshot.nickname, + message="账号池中已存在,未勾选更新 CK", + ) + ) continue if account is None: account = WeiboAccount(uid=snapshot.uid, cookie=cookie) @@ -139,7 +169,7 @@ def import_weibo_cookies( db.commit() for account in accounts: db.refresh(account) - return accounts, errors, skipped + return accounts, errors, skipped, duplicates def refresh_weibo_account( diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index c00d0f8..5b86932 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -777,12 +777,20 @@ export interface WeiboImportError { message: string; } +export interface WeiboImportDuplicate { + line: number; + uid: string; + nickname: string; + message: string; +} + export interface WeiboImportResult { success: boolean; message: string; count: number; skipped: number; errors: WeiboImportError[]; + duplicates: WeiboImportDuplicate[]; accounts: WeiboAccountItem[]; } diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index be3d2de..366d13a 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -48,6 +48,7 @@ import { type WeiboBindQrResult, type WeiboExchangeRecordsResult, type WeiboImportError, + type WeiboImportDuplicate, type WeiboMallListResult, type WeiboPaymentStatusResult, type WeiboPointHistoryResult, @@ -212,6 +213,9 @@ export default function WeiboAccountsPage({ const [importOpen, setImportOpen] = useState(false); const [importing, setImporting] = useState(false); const [importErrors, setImportErrors] = useState([]); + const [importDuplicates, setImportDuplicates] = useState< + WeiboImportDuplicate[] + >([]); const [form] = Form.useForm<{ text: string; tag?: string; @@ -327,6 +331,7 @@ export default function WeiboAccountsPage({ Boolean(values.update_existing), ); setImportErrors(result.errors); + setImportDuplicates(result.duplicates); let workbenchError = ''; if (isWorkbench && result.accounts.length > 0) { try { @@ -1722,6 +1727,7 @@ export default function WeiboAccountsPage({ disabled={!canImport} onClick={() => { setImportErrors([]); + setImportDuplicates([]); form.resetFields(); form.setFieldsValue({ update_existing: false }); setImportOpen(true); @@ -2753,6 +2759,26 @@ export default function WeiboAccountsPage({ } /> )} + {importDuplicates.length > 0 && ( + 0 ? 12 : 0 }} + title={`${importDuplicates.length} 条重复账号已跳过`} + description={ +
+ {importDuplicates.map((item) => ( +
+ + 第 {item.line} 行 · UID {item.uid} ·{' '} + {item.nickname || '无昵称'}:{item.message} + +
+ ))} +
+ } + /> + )}