feat(weibo): 支持管理员强制更新占用账号 CK
This commit is contained in:
@@ -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=""),
|
||||
|
||||
@@ -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)} 个微博账号"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,11 +49,17 @@ export const weiboApi = {
|
||||
),
|
||||
getAccount: (accountId: number) =>
|
||||
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
|
||||
importCookies: (text: string, tag = '', updateExisting = false) =>
|
||||
importCookies: (
|
||||
text: string,
|
||||
tag = '',
|
||||
updateExisting = false,
|
||||
forceUpdate = false,
|
||||
) =>
|
||||
api.post<WeiboImportResult, WeiboImportResult>('/weibo/accounts/import-cookies', {
|
||||
text,
|
||||
tag,
|
||||
update_existing: updateExisting,
|
||||
force_update: forceUpdate,
|
||||
}),
|
||||
updateAccount: (
|
||||
accountId: number,
|
||||
|
||||
@@ -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<WeiboAccountItem[]>([]);
|
||||
@@ -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<WeiboAccountItem | null>(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
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
onValuesChange={(changed) => {
|
||||
if (changed.update_existing === false) {
|
||||
form.setFieldValue('force_update', false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="text"
|
||||
label="Cookie"
|
||||
@@ -3159,6 +3173,26 @@ export default function WeiboAccountsPage({
|
||||
重复账号时更新 CK(仍只有一条全局账号,不新建)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{canForceUpdateCk && (
|
||||
<Form.Item
|
||||
name="force_update"
|
||||
valuePropName="checked"
|
||||
style={{ marginTop: -8 }}
|
||||
>
|
||||
<Checkbox disabled={!updateExistingCk}>
|
||||
强制更新已被分配或工作台占用的 CK(保留原归属)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{forceUpdateCk && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title="强制更新会跳过归属和工作台占用校验"
|
||||
description="只替换 Cookie 和验证状态,不会取消或改变原账号归属与工作台成员。"
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
{importErrors.length > 0 && (
|
||||
<Alert
|
||||
@@ -3168,9 +3202,12 @@ export default function WeiboAccountsPage({
|
||||
description={
|
||||
<Space orientation="vertical" size={2}>
|
||||
{importErrors.slice(0, 8).map((item) => (
|
||||
<Text key={`${item.line}-${item.message}`} style={{ fontSize: 12 }}>
|
||||
第 {item.line} 行:{item.message}
|
||||
</Text>
|
||||
<div key={`${item.line}-${item.message}`}>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
第 {item.line} 行{item.uid ? ` · UID ${item.uid}` : ''}:
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user