Author SHA1 Message Date
yml 11b5a4d938 feat(weibo): 展示 CK 导入重复账号明细 2026-11-11 21:10:23 +08:00
yml 3f860c3272 feat(weibo): 支持工作台账号转入外部单协作 2026-11-11 20:32:42 +08:00
9 changed files with 360 additions and 8 deletions
@@ -149,6 +149,13 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、
被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。
### 5.3 工作台转入外部单协作
所有能访问微博工作台的角色都可以把单个选中账号转入外部单协作,不再只限客服。
服务端只要求账号确实在当前用户工作台、已启用且 CK 非空;转移后删除当前工作台映射,
保留全局账号、归属和 CK,并打开外部单协作新建弹窗自动预选该账号。
原工作台账号仍可通过正常导入流程重新加入,不会被删除或停用。
## 6. 数据隔离边界
- 工作台列表和增删接口都按当前登录用户过滤。
@@ -163,6 +170,9 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
- 默认重复导入:不覆盖现有 CK,返回 `skipped=1`。
- 勾选「重复账号时更新 CK」:覆盖现有账号 CK 和最新资料,但**不新建账号**。
- 重复结果不是只有数量:`duplicates[]` 逐条返回输入行号、UID、昵称和原因,
并区分「账号池已存在」与「本次粘贴内容中 UID 重复」;导入弹窗直接列出,
顶部提示只预览前三个,完整明细保留在弹窗。
- 客服不能更新已分配给其他客服的账号 CK,也不能通过重复导入改变账号归属。
- 客服导入新账号时,账号会自动分配给当前客服。
- 若账号已被其他人的工作台占用,其他用户不能更新 CK,必须由当前使用人操作。
+98
View File
@@ -41,6 +41,7 @@ from web.backend.routers.weibo import (
list_workbench_tags,
refresh_account,
remove_workbench_accounts,
transfer_workbench_to_collab,
update_account,
)
from web.backend.schemas import (
@@ -54,6 +55,7 @@ from web.backend.schemas import (
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
WeiboWorkbenchBatchImportRequest,
WeiboWorkbenchTransferToCollabRequest,
)
from web.backend.services import weibo_service
@@ -162,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"
@@ -196,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=""),
@@ -564,6 +590,78 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
assert workbench_ids == {existing.id, added.id}
def test_operation_can_transfer_workbench_account_to_collab(db):
operator = User(
username="weibo-transfer-operator",
password_hash="hash",
role="operation",
is_active=True,
)
db.add(operator)
db.commit()
account = WeiboAccount(
uid="4014000951",
nickname="转入协作运营账号",
cookie="SCF=a; SUB=transfer",
game_role_name="测试角色",
assigned_to=operator.id,
)
db.add(account)
db.commit()
db.add(
WeiboWorkbenchAccount(
user_id=operator.id,
account_id=account.id,
)
)
db.commit()
result = transfer_workbench_to_collab(
WeiboWorkbenchTransferToCollabRequest(account_id=account.id),
db=db,
current=operator,
).model_dump()
assert result == {
"success": True,
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
"game_role_name": "测试角色",
}
assert (
db.query(WeiboWorkbenchAccount)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.count()
== 0
)
audit = (
db.query(AuditLog)
.filter(AuditLog.action == "weibo:workbench:transfer_collab")
.one()
)
assert audit.target == f"weibo_account:{account.id}"
def test_transfer_to_collab_requires_account_in_current_workbench(db, current_user):
account = WeiboAccount(
uid="4014000952",
nickname="不在工作台",
cookie="SCF=a; SUB=not-member",
)
db.add(account)
db.commit()
with pytest.raises(HTTPException) as excinfo:
transfer_workbench_to_collab(
WeiboWorkbenchTransferToCollabRequest(account_id=account.id),
db=db,
current=current_user,
)
assert excinfo.value.status_code == 404
def test_batch_tag_updates_selected_accounts(db, current_user):
first = WeiboAccount(
uid="4014000201",
+73 -1
View File
@@ -32,6 +32,7 @@ from ..schemas import (
WeiboExchangeRecordsOut,
WeiboExchangeRequest,
WeiboGoodsOut,
WeiboImportDuplicateOut,
WeiboImportError,
WeiboImportResult,
WeiboMallListOut,
@@ -45,6 +46,8 @@ from ..schemas import (
WeiboWorkbenchBatchImportItem,
WeiboWorkbenchBatchImportOut,
WeiboWorkbenchBatchImportRequest,
WeiboWorkbenchTransferToCollabOut,
WeiboWorkbenchTransferToCollabRequest,
)
from ..services.audit_service import record_audit
from ..services.weibo_service import (
@@ -607,6 +610,57 @@ def add_workbench_accounts(
return {"account_ids": account_ids, "success": True}
@router.post(
"/workbench/accounts/transfer-to-collab",
response_model=WeiboWorkbenchTransferToCollabOut,
)
def transfer_workbench_to_collab(
req: WeiboWorkbenchTransferToCollabRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""把当前工作台账号移出,并引导到外部单协作新建订单。"""
_require_weibo_perm(current, "weibo:workbench")
membership = (
db.query(WeiboWorkbenchAccount)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboWorkbenchAccount.account_id == req.account_id,
)
.first()
)
if membership is None:
raise HTTPException(status_code=404, detail="该账号不在当前微博工作台")
account = _get_account(db, req.account_id, current)
_ensure_enabled(account)
if not str(account.cookie or "").strip():
raise HTTPException(
status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作"
)
db.delete(membership)
db.commit()
_record_audit_safely(
db,
current,
action="weibo:workbench:transfer_collab",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
},
)
return WeiboWorkbenchTransferToCollabOut(
account_id=account.id,
uid=account.uid,
nickname=account.nickname,
game_role_name=account.game_role_name,
)
@router.post("/workbench/accounts/remove")
def remove_workbench_accounts(
req: WeiboWorkbenchAccountsRequest,
@@ -927,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,
@@ -939,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:
@@ -959,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),
},
)
@@ -966,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],
)
+20
View File
@@ -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
@@ -521,6 +528,18 @@ class WeiboWorkbenchAccountsRequest(BaseModel):
account_ids: list[int] = Field(default_factory=list)
class WeiboWorkbenchTransferToCollabRequest(BaseModel):
account_id: int = Field(..., gt=0)
class WeiboWorkbenchTransferToCollabOut(BaseModel):
success: bool = True
account_id: int
uid: str = ""
nickname: str = ""
game_role_name: str = ""
class WeiboWorkbenchBatchImportRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=100_000)
@@ -564,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)
+34 -4
View File
@@ -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(
+16
View File
@@ -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[];
}
@@ -839,6 +847,14 @@ export interface WeiboWorkbenchBatchImportResult {
items: WeiboWorkbenchBatchImportItem[];
}
export interface WeiboWorkbenchTransferToCollabResult {
success: boolean;
account_id: number;
uid: string;
nickname: string;
game_role_name: string;
}
export interface WeiboBatchTagResult {
updated: number;
tag: string;
+8
View File
@@ -16,6 +16,7 @@ import type {
WeiboImportResult,
WeiboWorkbenchMutationResult,
WeiboWorkbenchBatchImportResult,
WeiboWorkbenchTransferToCollabResult,
} from './types';
export const weiboApi = {
@@ -128,6 +129,13 @@ export const weiboApi = {
'/weibo/workbench/accounts/batch-import',
{ text },
),
transferWorkbenchToCollab: (accountId: number) =>
api.post<
WeiboWorkbenchTransferToCollabResult,
WeiboWorkbenchTransferToCollabResult
>('/weibo/workbench/accounts/transfer-to-collab', {
account_id: accountId,
}),
removeWorkbenchAccounts: (accountIds: number[]) =>
api.post<WeiboWorkbenchMutationResult, WeiboWorkbenchMutationResult>(
'/weibo/workbench/accounts/remove',
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
AutoComplete,
@@ -37,6 +38,7 @@ import {
ReloadOutlined,
SearchOutlined,
StopOutlined,
SwapOutlined,
ZoomInOutlined,
} from '@ant-design/icons';
import {
@@ -46,6 +48,7 @@ import {
type WeiboBindQrResult,
type WeiboExchangeRecordsResult,
type WeiboImportError,
type WeiboImportDuplicate,
type WeiboMallListResult,
type WeiboPaymentStatusResult,
type WeiboPointHistoryResult,
@@ -159,6 +162,7 @@ export default function WeiboAccountsPage({
}) {
const { token } = theme.useToken();
const { can } = usePermissions();
const navigate = useNavigate();
const isWorkbench = mode === 'workbench';
const canImport = can('weibo:import');
const canManage = can('weibo:manage');
@@ -168,6 +172,7 @@ export default function WeiboAccountsPage({
const canExchange = can('weibo:exchange');
const canDelete = can('weibo:delete');
const canAssign = can('weibo:assign');
const canTransferCollab = can('weibo:workbench');
const [items, setItems] = useState<WeiboAccountItem[]>([]);
const [total, setTotal] = useState(0);
@@ -184,6 +189,7 @@ export default function WeiboAccountsPage({
const [mutatingId, setMutatingId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [batchBusy, setBatchBusy] = useState(false);
const [transferringId, setTransferringId] = useState<number | null>(null);
const [batchTagOpen, setBatchTagOpen] = useState(false);
const [batchTagValue, setBatchTagValue] = useState('');
const [batchTagging, setBatchTagging] = useState(false);
@@ -207,6 +213,9 @@ export default function WeiboAccountsPage({
const [importOpen, setImportOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importErrors, setImportErrors] = useState<WeiboImportError[]>([]);
const [importDuplicates, setImportDuplicates] = useState<
WeiboImportDuplicate[]
>([]);
const [form] = Form.useForm<{
text: string;
tag?: string;
@@ -322,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 {
@@ -463,6 +473,28 @@ export default function WeiboAccountsPage({
}
};
const transferToCollab = async () => {
if (!isWorkbench || selectedIds.length !== 1) {
message.warning('请选择一个微博工作台账号');
return;
}
const accountId = selectedIds[0];
setTransferringId(accountId);
try {
const result = await weiboApi.transferWorkbenchToCollab(accountId);
message.success(
`${result.nickname || result.uid} 已移出工作台,正在打开外部单协作`,
);
navigate('/weibo/collab', {
state: { transferAccountId: result.account_id },
});
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setTransferringId(null);
}
};
const handleBatchTag = async () => {
if (selectedIds.length === 0) return;
setBatchTagging(true);
@@ -1695,6 +1727,7 @@ export default function WeiboAccountsPage({
disabled={!canImport}
onClick={() => {
setImportErrors([]);
setImportDuplicates([]);
form.resetFields();
form.setFieldsValue({ update_existing: false });
setImportOpen(true);
@@ -1735,6 +1768,16 @@ export default function WeiboAccountsPage({
批量充值
</Button>
)}
{isWorkbench && canTransferCollab && selectedIds.length === 1 && (
<Button
size="small"
icon={<SwapOutlined />}
loading={transferringId === selectedIds[0]}
onClick={() => void transferToCollab()}
>
转入外部单协作
</Button>
)}
{!isWorkbench && canManage && (
<Button
size="small"
@@ -2716,6 +2759,26 @@ export default function WeiboAccountsPage({
}
/>
)}
{importDuplicates.length > 0 && (
<Alert
type="info"
showIcon
style={{ marginTop: importErrors.length > 0 ? 12 : 0 }}
title={`${importDuplicates.length} 条重复账号已跳过`}
description={
<div style={{ maxHeight: 220, overflowY: 'auto' }}>
{importDuplicates.map((item) => (
<div key={`${item.line}-${item.uid}`}>
<Text style={{ fontSize: 12 }}>
第 {item.line} 行 · UID {item.uid} ·{' '}
{item.nickname || '无昵称'}:{item.message}
</Text>
</div>
))}
</div>
}
/>
)}
</Modal>
<Modal
+38 -3
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
AutoComplete,
@@ -128,6 +129,8 @@ interface OrderFormValues {
export default function WeiboCollabPage() {
const { token } = theme.useToken();
const { can } = usePermissions();
const location = useLocation();
const navigate = useNavigate();
const canEdit = can('weibo:collab:edit');
const canManage = can('weibo:collab:manage');
@@ -163,6 +166,7 @@ export default function WeiboCollabPage() {
const [detailMetrics, setDetailMetrics] = useState<WeiboCollabMetric[]>([]);
const [detailTimeline, setDetailTimeline] = useState<WeiboCollabTimeline[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const transferHandledRef = useRef<number | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [statusTarget, setStatusTarget] = useState<WeiboCollabStatus>('to_recharge');
@@ -297,7 +301,7 @@ export default function WeiboCollabPage() {
if (!goodsOptions.some((item) => item.value === option.value)) goodsOptions.push(option);
}
const lookupAccount = async (accountId?: number | null) => {
const lookupAccount = useCallback(async (accountId?: number | null) => {
setLookup(null);
if (!accountId) return;
setLookupLoading(true);
@@ -310,7 +314,7 @@ export default function WeiboCollabPage() {
} finally {
setLookupLoading(false);
}
};
}, []);
const openCreate = () => {
setEditing(null);
@@ -325,6 +329,37 @@ export default function WeiboCollabPage() {
setEditOpen(true);
};
const transferAccountId = (
location.state as { transferAccountId?: number } | null
)?.transferAccountId;
useEffect(() => {
if (!transferAccountId || transferHandledRef.current === transferAccountId) {
return;
}
transferHandledRef.current = transferAccountId;
setEditing(null);
setLookup(null);
form.resetFields();
form.setFieldsValue({
owner_id: owners[0]?.id,
account_id: transferAccountId,
required_weidou: 0,
home_images: [],
draft: false,
});
setEditOpen(true);
void lookupAccount(transferAccountId);
navigate(location.pathname, { replace: true, state: null });
}, [
form,
location.pathname,
lookupAccount,
navigate,
owners,
transferAccountId,
]);
const openEdit = (order: WeiboCollabOrder) => {
setEditing(order);
form.setFieldsValue({