From 17a2b5c464f0de8eecb638f8008c02cbc24800ea Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 14 Nov 2026 14:46:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E6=94=AF=E6=8C=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=BC=BA=E5=88=B6=E6=9B=B4=E6=96=B0=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E8=B4=A6=E5=8F=B7=20CK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_weibo_accounts.py | 78 +++++++++++++++++- web/backend/routers/weibo.py | 16 +++- web/backend/schemas.py | 13 +++ web/backend/services/weibo_service.py | 86 ++++++++++++++++---- web/frontend/src/api/types.ts | 6 ++ web/frontend/src/api/weibo.ts | 8 +- web/frontend/src/pages/WeiboAccountsPage.tsx | 45 +++++++++- 7 files changed, 229 insertions(+), 23 deletions(-) diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index a3ac8eb..30f1b64 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -302,11 +302,87 @@ def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, curren assert result.count == 0 assert len(result.errors) == 1 - assert "其他人的工作台占用" in result.errors[0].message + assert "个人工作台排他槽位归 weibo-owner" in result.errors[0].message + assert result.errors[0].workbench_owner_id == owner.id + assert result.errors[0].workbench_owner_username == owner.username db.refresh(imported) assert imported.cookie == original_cookie +def test_super_admin_force_updates_occupied_ck_without_changing_owner(db, current_user): + imported_out = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=owner"), + db=db, + current=current_user, + ).accounts[0] + imported = db.query(WeiboAccount).filter(WeiboAccount.id == imported_out.id).one() + owner = User( + username="weibo-owner", + password_hash="hash", + role="support", + is_active=True, + ) + db.add(owner) + db.commit() + imported.assigned_to = owner.id + db.add( + WeiboWorkbenchAccount( + user_id=owner.id, + account_id=imported.id, + exclusive_slot=1, + ) + ) + db.commit() + + result = import_cookies( + WeiboCookieImport( + text="SCF=forced; SUB=fresh", + update_existing=True, + force_update=True, + ), + db=db, + current=current_user, + ) + + assert result.count == 1 + assert result.errors == [] + db.refresh(imported) + assert "SUB=fresh" in imported.cookie + assert imported.assigned_to == owner.id + membership = ( + db.query(WeiboWorkbenchAccount) + .filter(WeiboWorkbenchAccount.account_id == imported.id) + .one() + ) + assert membership.user_id == owner.id + assert membership.exclusive_slot == 1 + + +def test_force_update_requires_dedicated_permission(db): + user = User( + username="weibo-import-only", + password_hash="hash", + role="support", + is_active=True, + custom_permissions=["weibo:import"], + ) + db.add(user) + db.commit() + + with pytest.raises(HTTPException) as excinfo: + import_cookies( + WeiboCookieImport( + text="SCF=a; SUB=b", + update_existing=True, + force_update=True, + ), + db=db, + current=user, + ) + + assert excinfo.value.status_code == 403 + + def test_refresh_marks_payment_access_failure(db, current_user): account_out = import_cookies( WeiboCookieImport(text="SCF=a; SUB=b", tag=""), diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index f9c4a83..892e91e 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -1175,6 +1175,8 @@ def import_cookies( """导入微博 CK;每个非空行一条完整 Cookie。""" _require_weibo_perm(current, "weibo:import") + if req.force_update: + _require_weibo_perm(current, "weibo:assign:force") accounts, errors, skipped, duplicates = import_weibo_cookies( db, req.text, @@ -1183,9 +1185,20 @@ def import_cookies( req.update_existing, current.id, can_update_all=_can_view_all_weibo_accounts(current), + force_update=req.force_update, ) typed_errors = [ - WeiboImportError(line=item.line, message=item.message) for item in errors + WeiboImportError( + line=item.line, + message=item.message, + account_id=item.account_id, + uid=item.uid, + assigned_user_id=item.assigned_user_id, + assigned_username=item.assigned_username, + workbench_owner_id=item.workbench_owner_id, + workbench_owner_username=item.workbench_owner_username, + ) + for item in errors ] typed_duplicates = [ WeiboImportDuplicateOut( @@ -1218,6 +1231,7 @@ def import_cookies( "skipped": skipped, "duplicate_uids": [item.uid for item in duplicates], "errors": len(errors), + "force_update": req.force_update, }, ) message = f"成功导入 {len(accounts)} 个微博账号" diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 3cb7604..0d21b3f 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -474,11 +474,24 @@ class WeiboCookieImport(BaseModel): text: str = Field(..., min_length=1) tag: str = Field("", max_length=64) update_existing: bool = False + force_update: bool = False + + @model_validator(mode="after") + def _validate_force_update(self): + if self.force_update and not self.update_existing: + raise ValueError("强制更新必须同时勾选“重复账号时更新 CK”") + return self class WeiboImportError(BaseModel): line: int message: str + account_id: int | None = None + uid: str = "" + assigned_user_id: int | None = None + assigned_username: str = "" + workbench_owner_id: int | None = None + workbench_owner_username: str = "" class WeiboImportDuplicateOut(BaseModel): diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 0f35bfd..ba3c15b 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -19,7 +19,7 @@ from core.weibo import ( normalize_weibo_cookie, ) -from ..models import WeiboAccount, WeiboRechargeRecord, WeiboWorkbenchAccount +from ..models import User, WeiboAccount, WeiboRechargeRecord, WeiboWorkbenchAccount from .weibo_image_service import embed_weibo_goods_images @@ -27,6 +27,12 @@ from .weibo_image_service import embed_weibo_goods_images class WeiboImportIssue: line: int message: str + account_id: int | None = None + uid: str = "" + assigned_user_id: int | None = None + assigned_username: str = "" + workbench_owner_id: int | None = None + workbench_owner_username: str = "" @dataclass(frozen=True, slots=True) @@ -74,6 +80,7 @@ def import_weibo_cookies( update_existing: bool = False, current_user_id: int | None = None, can_update_all: bool = False, + force_update: bool = False, ) -> tuple[ list[WeiboAccount], list[WeiboImportIssue], @@ -123,31 +130,78 @@ def import_weibo_cookies( db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first() ) if account is not None and update_existing and current_user_id is not None: - if ( - not can_update_all - and account.assigned_to is not None - and account.assigned_to != current_user_id - ): - errors.append( - WeiboImportIssue( - line=line_no, - message="该账号已分配给其他客服,不能更新 CK", - ) + assigned_user = ( + db.query(User).filter(User.id == account.assigned_to).first() + if account.assigned_to is not None + else None + ) + workbench_owner = ( + db.query(User) + .join( + WeiboWorkbenchAccount, + WeiboWorkbenchAccount.user_id == User.id, ) - continue - owner_id = ( - db.query(WeiboWorkbenchAccount.user_id) .filter( WeiboWorkbenchAccount.account_id == account.id, WeiboWorkbenchAccount.exclusive_slot.is_not(None), ) .first() ) - if owner_id is not None and owner_id[0] != current_user_id: + assigned_conflict = ( + not can_update_all + and account.assigned_to is not None + and account.assigned_to != current_user_id + ) + workbench_conflict = ( + workbench_owner is not None and workbench_owner.id != current_user_id + ) + if not force_update and (assigned_conflict or workbench_conflict): + assigned_label = ( + assigned_user.username if assigned_user else account.assigned_to + ) + workbench_label = workbench_owner.username if workbench_owner else "" + if ( + assigned_conflict + and workbench_conflict + and workbench_owner is not None + ): + conflict_message = ( + "该账号已分配给其他客服:账号归属客服 " + f"{assigned_label}(ID {account.assigned_to})," + f"且个人工作台排他槽位归 {workbench_label}" + f"(ID {workbench_owner.id})" + ) + elif assigned_conflict: + conflict_message = ( + "该账号已分配给其他客服:账号归属客服 " + f"{assigned_label}(ID {account.assigned_to})" + ) + else: + assert workbench_owner is not None + conflict_message = ( + "该账号已被其他人的工作台占用:个人工作台排他槽位归 " + f"{workbench_label}" + f"(ID {workbench_owner.id})" + ) errors.append( WeiboImportIssue( line=line_no, - message="该账号已被其他人的工作台占用,不能更新 CK", + message=( + conflict_message + + ",不能更新 CK。超级管理员可勾选“强制更新”覆盖,原归属保持不变" + ), + account_id=account.id, + uid=account.uid, + assigned_user_id=account.assigned_to, + assigned_username=( + assigned_user.username if assigned_user else "" + ), + workbench_owner_id=( + workbench_owner.id if workbench_owner else None + ), + workbench_owner_username=( + workbench_owner.username if workbench_owner else "" + ), ) ) continue diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 246aed6..16f7afb 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -775,6 +775,12 @@ export interface WeiboAccountItem { export interface WeiboImportError { line: number; message: string; + account_id: number | null; + uid: string; + assigned_user_id: number | null; + assigned_username: string; + workbench_owner_id: number | null; + workbench_owner_username: string; } export interface WeiboImportDuplicate { diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index 3961cb5..c4804f7 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -49,11 +49,17 @@ export const weiboApi = { ), getAccount: (accountId: number) => api.get(`/weibo/accounts/${accountId}`), - importCookies: (text: string, tag = '', updateExisting = false) => + importCookies: ( + text: string, + tag = '', + updateExisting = false, + forceUpdate = false, + ) => api.post('/weibo/accounts/import-cookies', { text, tag, update_existing: updateExisting, + force_update: forceUpdate, }), updateAccount: ( accountId: number, diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 05cb971..d06142e 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -192,6 +192,7 @@ export default function WeiboAccountsPage({ const canExchange = can('weibo:exchange'); const canDelete = can('weibo:delete'); const canAssign = can('weibo:assign'); + const canForceUpdateCk = can('weibo:assign:force'); const canTransferCollab = can('weibo:workbench'); const [items, setItems] = useState([]); @@ -241,7 +242,10 @@ export default function WeiboAccountsPage({ text: string; tag?: string; update_existing?: boolean; + force_update?: boolean; }>(); + const updateExistingCk = Form.useWatch('update_existing', form); + const forceUpdateCk = Form.useWatch('force_update', form); const [editForm] = Form.useForm<{ tag?: string; remark?: string }>(); const [editOpen, setEditOpen] = useState(false); const [editAccount, setEditAccount] = useState(null); @@ -406,6 +410,7 @@ export default function WeiboAccountsPage({ values.text, values.tag || '', Boolean(values.update_existing), + Boolean(values.force_update), ); setImportErrors(result.errors); setImportDuplicates(result.duplicates); @@ -3140,7 +3145,16 @@ export default function WeiboAccountsPage({ onCancel={() => setImportOpen(false)} destroyOnHidden > -
+ { + if (changed.update_existing === false) { + form.setFieldValue('force_update', false); + } + }} + > + {canForceUpdateCk && ( + + + 强制更新已被分配或工作台占用的 CK(保留原归属) + + + )} + {forceUpdateCk && ( + + )} {importErrors.length > 0 && ( {importErrors.slice(0, 8).map((item) => ( - - 第 {item.line} 行:{item.message} - +
+ + 第 {item.line} 行{item.uid ? ` · UID ${item.uid}` : ''}: + {item.message} + +
))} }