Author SHA1 Message Date
yml 50bb829101 feat(weibo): 增加工作台批量充值与批量导入 2026-11-11 20:11:35 +08:00
9 changed files with 1353 additions and 60 deletions
@@ -118,6 +118,37 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
- 支持单个/批量移出工作台,不删除账号池数据。
- 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。
### 5.1 工作台批量充值(2026-11-11)
批量充值只放在「微博工作台」,不替换现有单账号充值:
1. 工作台勾选账号后点批量化栏的「批量充值」,填写本批统一金额(1~1000 元)。
2. 每个账号调用一次现有 `POST /api/weibo/accounts/{id}/recharge/orders`,
因此每个账号对应一张独立微博支付订单和一个支付宝二维码;
微博没有原生批量下单接口,不能用一个二维码给多个账号充值。
3. 前端受控并发为 3,单次最多 20 个账号;停用账号直接显示跳过原因,
单个账号下单失败不影响其它账号。
4. 支付码弹窗按账号分卡片展示二维码、金额、预计微豆、支付状态和到账状态;
支持放大、复制支付链接、单项/批量刷新状态。
5. 支付状态成功不等于微豆到账。刷新状态时会继续查询微豆明细,
只有 `source_id == 充值 order_id` 且 `point_num > 0` 才显示「已到账」。
6. 批量支付码弹窗打开时默认每 8 秒自动查询待支付/待确认到账项,最多 3 个并发;
页面不可见或弹窗关闭时暂停,用户可手动关闭「自动查询」。
微博没有独立扫码事件,自动查询从二维码可用后开始,支付成功后继续核对微豆到账。
7. 二维码和支付链接只保存在当前页面内存,不写 localStorage 或数据库;
刷新页面后需要重新生成。后续如果要求后台继续生成、刷新恢复或单次超过 20 个账号,
再增加持久化批次任务和 WebSocket 进度。
### 5.2 从外部单协作复制账号标识
「微博外部单协作」列表和详情里的微博用户名、UID 都提供复制按钮。
勾选多单后可一次性复制所选用户名或 UID,每行一个并自动去重。
工作台「导入账号」弹窗默认提供「批量粘贴」页签,支持换行、空格、逗号、
中文逗号、分号分隔;后端先按 UID 精确匹配,再按用户名、角色名兜底解析,
重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、
被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。
## 6. 数据隔离边界
- 工作台列表和增删接口都按当前登录用户过滤。
+88
View File
@@ -32,6 +32,7 @@ from web.backend.routers.weibo import (
enable_account,
get_account,
import_cookies,
import_workbench_accounts_by_text,
list_account_ids,
list_account_tags,
list_accounts,
@@ -52,6 +53,7 @@ from web.backend.schemas import (
WeiboPaymentStatusRequest,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
WeiboWorkbenchBatchImportRequest,
)
from web.backend.services import weibo_service
@@ -476,6 +478,92 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user):
assert [item.uid for item in pool_a["items"]] == [first.uid]
def test_batch_import_workbench_accounts_reports_each_identifier(db, current_user):
other_user = User(
username="weibo-batch-import-other",
password_hash="hash",
role="support",
is_active=True,
)
db.add(other_user)
db.commit()
added = WeiboAccount(
uid="4014000901",
nickname="批量导入新账号",
cookie="SCF=a; SUB=added",
)
existing = WeiboAccount(
uid="4014000902",
nickname="批量导入已存在",
cookie="SCF=b; SUB=existing",
)
occupied = WeiboAccount(
uid="4014000903",
nickname="批量导入被占用",
cookie="SCF=c; SUB=occupied",
)
disabled = WeiboAccount(
uid="4014000904",
nickname="批量导入停用",
cookie="SCF=d; SUB=disabled",
is_enabled=False,
)
duplicate_a = WeiboAccount(
uid="4014000905",
nickname="批量导入重名",
cookie="SCF=e; SUB=duplicate-a",
)
duplicate_b = WeiboAccount(
uid="4014000906",
nickname="批量导入重名",
cookie="SCF=f; SUB=duplicate-b",
)
db.add_all([added, existing, occupied, disabled, duplicate_a, duplicate_b])
db.commit()
db.add_all(
[
WeiboWorkbenchAccount(user_id=current_user.id, account_id=existing.id),
WeiboWorkbenchAccount(user_id=other_user.id, account_id=occupied.id),
]
)
db.commit()
result = import_workbench_accounts_by_text(
WeiboWorkbenchBatchImportRequest(
text=(
f"{added.nickname} {added.uid} {existing.uid},{occupied.uid}\n"
"不存在账号;批量导入重名 批量导入停用"
)
),
db=db,
current=current_user,
).model_dump()
assert result["requested"] == 7
assert result["added"] == 1
assert result["already_added"] == 1
assert result["failed"] == 5
assert result["account_ids"] == [added.id]
statuses = {item["identifier"]: item["status"] for item in result["items"]}
assert statuses == {
added.nickname: "added",
added.uid: "duplicate",
existing.uid: "already_added",
occupied.uid: "unavailable",
"不存在账号": "not_found",
"批量导入重名": "ambiguous",
"批量导入停用": "disabled",
}
workbench_ids = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.filter(WeiboWorkbenchAccount.user_id == current_user.id)
.all()
}
assert workbench_ids == {existing.id, added.id}
def test_batch_tag_updates_selected_accounts(db, current_user):
first = WeiboAccount(
uid="4014000201",
+174
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
@@ -41,6 +42,9 @@ from ..schemas import (
WeiboRechargeOrderOut,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
WeiboWorkbenchBatchImportItem,
WeiboWorkbenchBatchImportOut,
WeiboWorkbenchBatchImportRequest,
)
from ..services.audit_service import record_audit
from ..services.weibo_service import (
@@ -61,6 +65,7 @@ from ..services.weibo_service import (
router = APIRouter(prefix="/api/weibo", tags=["微博微豆"])
WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip()
WEIBO_WORKBENCH_BATCH_IMPORT_MAX = 200
def _require_weibo_perm(user: User, permission: str) -> None:
@@ -319,6 +324,175 @@ def list_workbench_pool(
return _paginated_accounts(query, page=page, page_size=page_size)
@router.post(
"/workbench/accounts/batch-import",
response_model=WeiboWorkbenchBatchImportOut,
)
def import_workbench_accounts_by_text(
req: WeiboWorkbenchBatchImportRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""按 UID/用户名/角色批量解析账号并加入当前用户工作台。"""
_require_weibo_perm(current, "weibo:workbench")
identifiers: list[str] = []
for token in re.split(r"[\s,,;;]+", req.text.strip()):
value = token.strip()
if value and value not in identifiers:
identifiers.append(value)
if not identifiers:
raise HTTPException(status_code=400, detail="请先粘贴微博用户名或 UID")
if len(identifiers) > WEIBO_WORKBENCH_BATCH_IMPORT_MAX:
raise HTTPException(
status_code=400,
detail=f"一次最多导入 {WEIBO_WORKBENCH_BATCH_IMPORT_MAX} 个账号",
)
visible_query = _visible_weibo_accounts_query(db.query(WeiboAccount), current)
accounts = visible_query.filter(
or_(
WeiboAccount.uid.in_(identifiers),
WeiboAccount.nickname.in_(identifiers),
WeiboAccount.game_role_name.in_(identifiers),
)
).all()
def grouped(field: str) -> dict[str, list[WeiboAccount]]:
result: dict[str, list[WeiboAccount]] = {}
for account in accounts:
value = str(getattr(account, field) or "").strip()
if value:
result.setdefault(value, []).append(account)
return result
by_uid = grouped("uid")
by_nickname = grouped("nickname")
by_role = grouped("game_role_name")
account_ids = [account.id for account in accounts]
occupancy = {
account_id: user_id
for account_id, user_id in db.query(
WeiboWorkbenchAccount.account_id,
WeiboWorkbenchAccount.user_id,
)
.filter(WeiboWorkbenchAccount.account_id.in_(account_ids))
.all()
}
items: list[WeiboWorkbenchBatchImportItem] = []
to_add: list[WeiboAccount] = []
planned_ids: set[int] = set()
for identifier in identifiers:
candidates = by_uid.get(identifier) or by_nickname.get(identifier) or []
if not candidates:
candidates = by_role.get(identifier) or []
if not candidates:
items.append(
WeiboWorkbenchBatchImportItem(
identifier=identifier,
status="not_found",
message="当前可见账号池中没有匹配项",
)
)
continue
if len(candidates) > 1 and identifier not in by_uid:
items.append(
WeiboWorkbenchBatchImportItem(
identifier=identifier,
status="ambiguous",
message=f"匹配到 {len(candidates)} 个账号,请改用 UID",
)
)
continue
account = candidates[0]
base = {
"identifier": identifier,
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
}
occupant = occupancy.get(account.id)
if not account.is_enabled:
items.append(
WeiboWorkbenchBatchImportItem(
status="disabled",
message="账号已停用",
**base,
)
)
elif occupant is not None and occupant != current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="unavailable",
message="账号已在其他人的工作台中使用",
**base,
)
)
elif occupant == current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="already_added",
message="账号已在本工作台",
**base,
)
)
elif account.id in planned_ids:
items.append(
WeiboWorkbenchBatchImportItem(
status="duplicate",
message="同一账号在本次名单中重复",
**base,
)
)
else:
planned_ids.add(account.id)
to_add.append(account)
items.append(
WeiboWorkbenchBatchImportItem(
status="added",
message="已加入工作台",
**base,
)
)
try:
db.add_all(
[
WeiboWorkbenchAccount(user_id=current.id, account_id=account.id)
for account in to_add
]
)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=409,
detail="部分账号刚被其他工作台占用,请刷新后重试",
) from exc
if to_add:
_record_audit_safely(
db,
current,
action="weibo:workbench:batch_import",
target=f"weibo_user:{current.id}",
detail={"account_ids": [account.id for account in to_add]},
)
added = sum(item.status == "added" for item in items)
already_added = sum(item.status == "already_added" for item in items)
failed = len(items) - added - already_added
return WeiboWorkbenchBatchImportOut(
requested=len(identifiers),
added=added,
already_added=already_added,
failed=failed,
account_ids=[account.id for account in to_add],
items=items,
)
@router.get("/workbench/tags")
def list_workbench_tags(
db: Session = Depends(get_db),
+22
View File
@@ -521,6 +521,28 @@ class WeiboWorkbenchAccountsRequest(BaseModel):
account_ids: list[int] = Field(default_factory=list)
class WeiboWorkbenchBatchImportRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=100_000)
class WeiboWorkbenchBatchImportItem(BaseModel):
identifier: str
status: str
message: str = ""
account_id: int | None = None
uid: str = ""
nickname: str = ""
class WeiboWorkbenchBatchImportOut(BaseModel):
requested: int
added: int
already_added: int
failed: int
account_ids: list[int] = Field(default_factory=list)
items: list[WeiboWorkbenchBatchImportItem] = Field(default_factory=list)
class WeiboBatchAssignRequest(BaseModel):
account_ids: list[int] = Field(min_length=1)
assigned_to: int | None = None
+25
View File
@@ -814,6 +814,31 @@ export interface WeiboWorkbenchMutationResult {
success: boolean;
}
export interface WeiboWorkbenchBatchImportItem {
identifier: string;
status:
| 'added'
| 'already_added'
| 'not_found'
| 'ambiguous'
| 'unavailable'
| 'disabled'
| 'duplicate';
message: string;
account_id: number | null;
uid: string;
nickname: string;
}
export interface WeiboWorkbenchBatchImportResult {
requested: number;
added: number;
already_added: number;
failed: number;
account_ids: number[];
items: WeiboWorkbenchBatchImportItem[];
}
export interface WeiboBatchTagResult {
updated: number;
tag: string;
+6
View File
@@ -15,6 +15,7 @@ import type {
WeiboRechargeOrderResult,
WeiboImportResult,
WeiboWorkbenchMutationResult,
WeiboWorkbenchBatchImportResult,
} from './types';
export const weiboApi = {
@@ -122,6 +123,11 @@ export const weiboApi = {
'/weibo/workbench/accounts',
{ account_ids: accountIds },
),
batchImportWorkbenchAccounts: (text: string) =>
api.post<WeiboWorkbenchBatchImportResult, WeiboWorkbenchBatchImportResult>(
'/weibo/workbench/accounts/batch-import',
{ text },
),
removeWorkbenchAccounts: (accountIds: number[]) =>
api.post<WeiboWorkbenchMutationResult, WeiboWorkbenchMutationResult>(
'/weibo/workbench/accounts/remove',
File diff suppressed because it is too large Load Diff
+110 -3
View File
@@ -24,6 +24,7 @@ import {
} from 'antd';
import type { TableProps } from 'antd';
import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
FileExcelOutlined,
@@ -58,6 +59,7 @@ import { useCollabRealtime } from '../hooks/useCollabRealtime';
import { usePermissions } from '../hooks/usePermissions';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { copyTextToClipboard } from '../utils/clipboard';
import { formatTime } from '../utils/time';
const { Text, Title } = Typography;
@@ -578,6 +580,38 @@ export default function WeiboCollabPage() {
[summary],
);
const copySelectedAccountValues = async (kind: 'username' | 'uid') => {
const selectedOrders = orders.filter((row) => selectedIds.includes(row.id));
const values = Array.from(
new Set(
selectedOrders
.map((row) =>
kind === 'uid'
? row.account_uid
: row.account_nickname || row.account_uid,
)
.map((value) => String(value || '').trim())
.filter(Boolean),
),
);
if (values.length === 0) {
message.warning(
kind === 'uid' ? '所选订单没有微博 UID' : '所选订单没有微博用户名',
);
return;
}
try {
await copyTextToClipboard(values.join('\n'));
message.success(
`已复制 ${values.length} 个${
kind === 'uid' ? ' UID' : '用户名'
},可直接粘贴到工作台导入搜索框`,
);
} catch (error) {
message.error(getErrorMessage(error));
}
};
const columns: TableProps<WeiboCollabOrder>['columns'] = [
{
title: '序号',
@@ -618,9 +652,33 @@ export default function WeiboCollabPage() {
width: 210,
render: (_, row) => (
<Space orientation="vertical" size={0}>
<Text>{row.account_nickname || row.account_uid || '未关联'}</Text>
<Text
copyable={
row.account_nickname || row.account_uid
? {
text: row.account_nickname || row.account_uid || '',
tooltips: ['复制用户名', '已复制用户名'],
}
: false
}
>
{row.account_nickname || row.account_uid || '未关联'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {row.account_uid || '-'}
{row.account_uid ? (
<Text
type="secondary"
style={{ fontSize: 12 }}
copyable={{
text: row.account_uid,
tooltips: ['复制 UID', '已复制 UID'],
}}
>
UID {row.account_uid}
</Text>
) : (
'UID -'
)}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.game_role_name || '未读到角色'}
@@ -900,6 +958,22 @@ export default function WeiboCollabPage() {
<Space wrap>
<Text type="secondary">已选 {selectedIds.length} 单</Text>
{selectedIds.length > 0 && (
<>
<Button
icon={<CopyOutlined />}
onClick={() => void copySelectedAccountValues('username')}
>
复制所选用户名
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => void copySelectedAccountValues('uid')}
>
复制所选 UID
</Button>
</>
)}
{canEdit && selectedIds.length > 0 && archiveScope !== 'archived' && (
<>
<Button
@@ -1168,7 +1242,40 @@ export default function WeiboCollabPage() {
column={2}
items={[
{ key: 'shop', label: '店铺 / 负责人', children: `${detail.shop || '-'} / ${detail.owner_name || '-'}` },
{ key: 'account', label: '微博账号', children: `${detail.account_nickname || '-'} · UID ${detail.account_uid || '-'}` },
{
key: 'account',
label: '微博账号',
children: (
<Space size={4} wrap>
<Text
copyable={
detail.account_nickname || detail.account_uid
? {
text:
detail.account_nickname ||
detail.account_uid ||
'',
tooltips: ['复制用户名', '已复制用户名'],
}
: false
}
>
{detail.account_nickname || '-'}
</Text>
{detail.account_uid && (
<Text
type="secondary"
copyable={{
text: detail.account_uid,
tooltips: ['复制 UID', '已复制 UID'],
}}
>
UID {detail.account_uid}
</Text>
)}
</Space>
),
},
{ key: 'role', label: '绑定角色', children: detail.game_role_name || '-' },
{ key: 'goods', label: '商品 / 所需微豆', children: `${detail.goods_name || '-'} / ${detail.required_weidou ?? '-'}` },
{ key: 'status', label: '业务状态', children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
+17
View File
@@ -0,0 +1,17 @@
export async function runWithConcurrency<T>(
items: T[],
limit: number,
worker: (item: T) => Promise<void>,
) {
let nextIndex = 0;
const workerCount = Math.min(Math.max(1, limit), items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
await worker(items[index]);
}
}),
);
}