feat(weibo): 支持工作台账号转入外部单协作
This commit is contained in:
@@ -149,6 +149,13 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
|
||||
重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、
|
||||
被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。
|
||||
|
||||
### 5.3 工作台转入外部单协作
|
||||
|
||||
所有能访问微博工作台的角色都可以把单个选中账号转入外部单协作,不再只限客服。
|
||||
服务端只要求账号确实在当前用户工作台、已启用且 CK 非空;转移后删除当前工作台映射,
|
||||
保留全局账号、归属和 CK,并打开外部单协作新建弹窗自动预选该账号。
|
||||
原工作台账号仍可通过正常导入流程重新加入,不会被删除或停用。
|
||||
|
||||
## 6. 数据隔离边界
|
||||
|
||||
- 工作台列表和增删接口都按当前登录用户过滤。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -564,6 +566,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",
|
||||
|
||||
@@ -45,6 +45,8 @@ from ..schemas import (
|
||||
WeiboWorkbenchBatchImportItem,
|
||||
WeiboWorkbenchBatchImportOut,
|
||||
WeiboWorkbenchBatchImportRequest,
|
||||
WeiboWorkbenchTransferToCollabOut,
|
||||
WeiboWorkbenchTransferToCollabRequest,
|
||||
)
|
||||
from ..services.audit_service import record_audit
|
||||
from ..services.weibo_service import (
|
||||
@@ -607,6 +609,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,
|
||||
|
||||
@@ -521,6 +521,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)
|
||||
|
||||
|
||||
@@ -839,6 +839,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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -159,6 +161,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 +171,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 +188,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);
|
||||
@@ -463,6 +468,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);
|
||||
@@ -1735,6 +1762,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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user