feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)

在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
This commit is contained in:
yml2213
2026-10-07 12:29:08 +08:00
parent 281769b440
commit 18bc7be4b1
38 changed files with 5143 additions and 35 deletions
+2
View File
@@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const DouyuPoolPage = lazy(() => import('./pages/DouyuPoolPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
@@ -90,6 +91,7 @@ function AppContent() {
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="cookie-operations" element={lazyRoute(<CookieOperationsPage />)} />
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/pool" element={lazyRoute(<DouyuPoolPage />)} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
+43
View File
@@ -0,0 +1,43 @@
import api from './client';
import type {
DouyuPoolExchangeRequest,
DouyuPoolExchangeResult,
DouyuPoolLease,
DouyuPoolSupply,
MessageResponse,
} from './types';
/**
* 账号池(自动分配与回收)。
*
* 单独一个模块而不是塞进 `douyuApi`:这是**新增的一条路**,
* 旧工作台(精英宝典/电竞手册)的接口一个都没动。
*/
export const douyuPoolApi = {
/** 不锁号地预览「能分出几个、要花多少钱」 */
supply: (scope: 'elite' | 'esports', commodityId: string) =>
api.get<DouyuPoolSupply, DouyuPoolSupply>('/douyu/pool/supply', {
params: { scope, commodity_id: commodityId },
}),
/** 提交需求:分配账号 + 建任务 + 启动批次 */
exchange: (data: DouyuPoolExchangeRequest) =>
api.post<DouyuPoolExchangeResult, DouyuPoolExchangeResult>('/douyu/pool/exchange', data),
leases: (mine = true) =>
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', { params: { mine } }),
/** 某个账号的租赁记录(账号详情页用;`includeFinished` 带历史) */
leasesOfAccount: (accountId: number, includeFinished = true) =>
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', {
params: { account_id: accountId, include_finished: includeFinished },
}),
releaseLease: (leaseId: number) =>
api.post<{ success: boolean; released: boolean }, { success: boolean; released: boolean }>(
`/douyu/pool/leases/${leaseId}/release`,
),
releaseBatch: (batchId: string) =>
api.post<{ success: boolean; released: number }, { success: boolean; released: number }>(
`/douyu/pool/leases/release-batch/${batchId}`,
),
/** 复用旧接口拿商品列表(皮肤下拉的数据源) */
goods: () => api.get<{ commodity_id: string; name: string; score: number | null }[], { commodity_id: string; name: string; score: number | null }[]>('/douyu/goods'),
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+1
View File
@@ -8,6 +8,7 @@ export { automationApi } from './automation';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
export { douyuPoolApi } from './douyuPool';
export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
+72
View File
@@ -146,6 +146,8 @@ export interface AccountEventItem {
source: string;
actor_type?: string;
actor_id?: number | null;
/** 操作人用户名(后端联表补上;池化后「谁做的」是这一屏的关键信息) */
actor_name?: string;
}
export interface AccountEventPage {
@@ -1163,3 +1165,73 @@ export interface AccountExchangeRecords {
total: number;
queried_at: string | null;
}
/** 账号池:供给预览(不锁号) */
export interface DouyuPoolSupply {
scope: string;
commodity_id: string;
score: number;
/** 积分已够、可直接兑的账号数(0 充值) */
zero_topup: number;
/** 充值积分后可兑的账号数 */
after_topup: number;
/** 当前被别处租用中的数量 */
leased: number;
blocked: Record<string, number>;
cost: { est_gifts: number; est_yuan: number };
cooldown_days: number;
commodity: { commodity_id: string; name: string; score: number };
cooling: { role_change_in_cooldown: number; changeable_now: number };
/** 当前鱼翅充值渠道(只读回显) */
recharge_channel: string;
/** 是否需要在扫码渠道下人工支付 */
manual_payment_required: boolean;
}
export interface DouyuPoolAccount {
account_id: number;
username: string;
/** A = 积分已够;B = 需要充值 */
tier: string;
points: number;
topup_gifts: number;
topup_yuan: number;
/** 充值订单金额列表(每笔 ≤ 800 元) */
recharge_orders: number[];
role_change_available_at: string | null;
}
export interface DouyuPoolExchangeRequest {
scope: 'elite' | 'esports';
commodity_id: string;
quantity: number;
allow_topup: boolean;
allow_fallback: boolean;
}
export interface DouyuPoolExchangeResult {
batch_id: string;
requested: number;
allocated: number;
shortfall: number;
accounts: DouyuPoolAccount[];
reasons: string[];
recharge_channel: string;
manual_payment_required: boolean;
}
export interface DouyuPoolLease {
id: number;
account_id: number;
username: string;
holder_user_id: number;
holder_name: string;
scope: string;
commodity_id: string;
batch_id: string;
status: string;
leased_at: string | null;
expires_at: string | null;
role_change_available_at: string | null;
points: number | null;
}
+5
View File
@@ -8,6 +8,7 @@ import {
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -84,6 +85,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
}
// 账号分配台:新增的独立工作台(旧的三个工作台原样保留)
if (can('account:pool_use')) {
douyuItems.push({ key: '/douyu/pool', label: '账号分配台', icon: <ThunderboltOutlined /> });
}
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
if (can('huya:task')) {
+75 -1
View File
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
} from '@ant-design/icons';
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import { accountApi, douyuApi, douyuPoolApi, userApi, type AccountEventItem, type AccountOverview, type DouyuPoolLease, type UserInfo } from '../api/modules';
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
@@ -100,6 +100,7 @@ export default function AccountDetailPage() {
const [eventType, setEventType] = useState('');
const [category, setCategory] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
const [tagDraft, setTagDraft] = useState('');
// 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果)
const [ovLoading, setOvLoading] = useState(false);
@@ -209,6 +210,22 @@ export default function AccountDetailPage() {
void loadEvents();
}, [loadEvents]);
// 租赁记录:池化后「这个账号被谁租过、用完还回去没有」。
// 没有 `account:pool_use` 权限的账号会 403 —— 静默留空,不影响页面其它部分。
useEffect(() => {
if (!accountId) return;
let alive = true;
(async () => {
try {
const rows = await douyuPoolApi.leasesOfAccount(accountId);
if (alive) setLeases(rows);
} catch {
if (alive) setLeases([]);
}
})();
return () => { alive = false; };
}, [accountId]);
const columns: TableProps<AccountEventItem>['columns'] = [
{
title: '时间',
@@ -238,6 +255,28 @@ export default function AccountDetailPage() {
render: (val: number | null) =>
val === null || val === undefined ? <Text type="secondary">-</Text> : val,
},
{
// 池化后客服不再长期持有账号,「这件事是谁做的」必须一眼看得到。
// 历史事件(占 71%)没有操作人 —— 如实显示「-」,不要假装是系统做的。
title: '操作人 / 渠道',
width: 170,
render: (_val, record) => {
const channel = (record.detail?.channel as string | undefined) || '';
const channelLabel = channel === 'supplier_api'
? 'API 直充'
: channel === 'wechat_qr'
? '微信扫码'
: channel;
return (
<Space size={4} wrap>
{record.actor_name
? <Tag color="blue">{record.actor_name}</Tag>
: <Text type="secondary">-</Text>}
{channelLabel && <Tag>{channelLabel}</Tag>}
</Space>
);
},
},
];
if (!accountId) {
@@ -678,6 +717,41 @@ export default function AccountDetailPage() {
</Spin>
<Card
title="租赁记录"
extra={<Text type="secondary" style={{ fontSize: 12 }}>客服按需租用,用完自动收回</Text>}
>
<Table
rowKey="id"
size="small"
dataSource={leases}
pagination={false}
locale={{ emptyText: <Empty description="暂无租赁记录" /> }}
columns={[
{
title: '客服', dataIndex: 'holder_name', width: 120,
render: (value: string, row: DouyuPoolLease) => value || `#${row.holder_user_id}`,
},
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
{ title: '批次', dataIndex: 'batch_id', width: 130 },
{
title: '状态', dataIndex: 'status', width: 100,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : 'default'}>{value}</Tag>
),
},
{
title: '租用时间', dataIndex: 'leased_at', width: 170,
render: (value: string | null) => (value ? formatTime(value) : '-'),
},
{
title: '释放时间', dataIndex: 'expires_at', width: 170,
render: (value: string | null) => (value ? formatTime(value) : '-'),
},
]}
/>
</Card>
<Card
title="账号时间线"
extra={
+282
View File
@@ -0,0 +1,282 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Descriptions, InputNumber, Row, Select, Space,
Statistic, Table, Tag, Typography, theme,
} from 'antd';
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { douyuPoolApi } from '../api/modules';
import type {
DouyuPoolAccount, DouyuPoolExchangeResult, DouyuPoolLease, DouyuPoolSupply,
} from '../api/modules';
import type { ColumnsType } from 'antd/es/table';
const { Text, Paragraph } = Typography;
/**
* 账号分配台(**新建的工作台**,不是改造精英宝典页)。
*
* 客服在这里只做三件事:选皮肤 → 填数量 → 提交,剩下的(领号、补积分、兑换、
* 用完自动收回)由系统做。旧工作台的手动选号路径完整保留,两者互不影响。
*/
export default function DouyuPoolPage() {
const { token } = theme.useToken();
const [goods, setGoods] = useState<{ commodity_id: string; name: string; score: number | null }[]>([]);
const [commodityId, setCommodityId] = useState<string>('');
const [quantity, setQuantity] = useState(1);
const [allowTopup, setAllowTopup] = useState(true);
const [supply, setSupply] = useState<DouyuPoolSupply | null>(null);
const [result, setResult] = useState<DouyuPoolExchangeResult | null>(null);
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
const [loading, setLoading] = useState(false);
const [previewing, setPreviewing] = useState(false);
const loadGoods = useCallback(async () => {
try {
const rows = await douyuPoolApi.goods();
const usable = rows.filter((item) => (item.score ?? 0) > 0);
setGoods(usable);
if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, [commodityId]);
const loadLeases = useCallback(async () => {
try {
setLeases(await douyuPoolApi.leases(true));
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, []);
useEffect(() => { void loadGoods(); }, [loadGoods]);
useEffect(() => { void loadLeases(); }, [loadLeases]);
const loadSupply = useCallback(async () => {
if (!commodityId) return;
setPreviewing(true);
try {
setSupply(await douyuPoolApi.supply('elite', commodityId));
} catch (e: unknown) {
setSupply(null);
message.error(getErrorMessage(e));
} finally {
setPreviewing(false);
}
}, [commodityId]);
useEffect(() => { void loadSupply(); }, [loadSupply]);
const submit = async () => {
if (!commodityId) { message.warning('请先选择皮肤'); return; }
setLoading(true);
try {
const data = await douyuPoolApi.exchange({
scope: 'elite', commodity_id: commodityId, quantity,
allow_topup: allowTopup, allow_fallback: true,
});
setResult(data);
if (data.shortfall > 0) {
message.warning(`实配 ${data.allocated} / 需求 ${data.requested},缺 ${data.shortfall}`);
} else {
message.success(`已分配 ${data.allocated} 个账号,批次 ${data.batch_id}`);
}
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
};
const releaseLease = async (leaseId: number) => {
try {
await douyuPoolApi.releaseLease(leaseId);
message.success('已释放');
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const goodsOptions = useMemo(
() => goods.map((item) => ({
value: item.commodity_id,
label: `${item.name}(${item.score ?? 0} 分)`,
})),
[goods],
);
const accountColumns: ColumnsType<DouyuPoolAccount> = [
{ title: '账号ID', dataIndex: 'account_id', width: 90 },
{ title: '账号', dataIndex: 'username', ellipsis: true },
{
title: '梯队', dataIndex: 'tier', width: 90,
render: (tier: string) => (
<Tag color={tier === 'A' ? 'green' : 'orange'}>
{tier === 'A' ? '免充值' : '需充值'}
</Tag>
),
},
{ title: '积分', dataIndex: 'points', width: 90 },
{
title: '需充值', width: 160,
render: (_, row) => (row.topup_yuan > 0
? <Text type="warning">{row.topup_yuan} 元({row.recharge_orders.join(' + ')})</Text>
: <Text type="secondary">无需</Text>),
},
{
title: '可换绑时间', dataIndex: 'role_change_available_at', width: 180,
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
},
];
const leaseColumns: ColumnsType<DouyuPoolLease> = [
{ title: '账号', dataIndex: 'username', ellipsis: true },
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
{ title: '批次', dataIndex: 'batch_id', width: 130 },
{
title: '到期', dataIndex: 'expires_at', width: 170,
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
},
{
title: '操作', width: 100,
render: (_, row) => (
<Button size="small" danger onClick={() => void releaseLease(row.id)}>释放</Button>
),
},
];
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card
title="发起兑换(系统自动分配账号)"
extra={(
<Button icon={<ReloadOutlined />} onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}>
刷新
</Button>
)}
>
<Row gutter={[16, 16]} align="bottom">
<Col xs={24} md={10}>
<Text type="secondary">皮肤</Text>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要兑换的皮肤"
value={commodityId || undefined}
onChange={setCommodityId}
options={goodsOptions}
/>
</Col>
<Col xs={12} md={4}>
<Text type="secondary">需要几个账号</Text>
<InputNumber
min={1}
max={50}
style={{ width: '100%' }}
value={quantity}
onChange={(value) => setQuantity(Number(value) || 1)}
/>
</Col>
<Col xs={12} md={5}>
<Text type="secondary">积分不足时</Text>
<Select
style={{ width: '100%' }}
value={allowTopup ? 'topup' : 'skip'}
onChange={(value) => setAllowTopup(value === 'topup')}
options={[
{ value: 'topup', label: '自动充值积分' },
{ value: 'skip', label: '只用已有积分' },
]}
/>
</Col>
<Col xs={24} md={5}>
<Button
type="primary"
block
icon={<ThunderboltOutlined />}
loading={loading}
onClick={() => void submit()}
>
提交并自动分配
</Button>
</Col>
</Row>
{supply && (
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} md={6}>
<Statistic title="可直接兑(免充值)" value={supply.zero_topup} suffix="个" />
</Col>
<Col xs={12} md={6}>
<Statistic title="充值后可兑" value={supply.after_topup} suffix="个" />
</Col>
<Col xs={12} md={6}>
<Statistic
title="全部兑完预计花费"
value={supply.cost.est_yuan}
suffix="元"
loading={previewing}
/>
</Col>
<Col xs={12} md={6}>
<Statistic title="当前租用中" value={supply.leased} suffix="个" />
</Col>
</Row>
)}
{supply?.manual_payment_required && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message={`当前鱼翅渠道为「${supply.recharge_channel}」,需要人工扫码支付`}
description="系统会生成支付码并等待 10 分钟;超时后转入人工处理,需要你到「我的租约」里继续或放弃。"
/>
)}
</Card>
{result && (
<Card title={`本次分配结果(批次 ${result.batch_id || '—'})`}>
<Descriptions size="small" column={4} style={{ marginBottom: 12 }}>
<Descriptions.Item label="需求">{result.requested}</Descriptions.Item>
<Descriptions.Item label="实配">{result.allocated}</Descriptions.Item>
<Descriptions.Item label="缺口">
<Text type={result.shortfall > 0 ? 'danger' : undefined}>{result.shortfall}</Text>
</Descriptions.Item>
<Descriptions.Item label="充值渠道">{result.recharge_channel || '—'}</Descriptions.Item>
</Descriptions>
{result.reasons.length > 0 && (
<Paragraph type="warning" style={{ marginBottom: 12 }}>
{result.reasons.map((reason) => <div key={reason}>· {reason}</div>)}
</Paragraph>
)}
<Table
rowKey="account_id"
size="small"
pagination={false}
columns={accountColumns}
dataSource={result.accounts}
locale={{ emptyText: '本次没有分配账号' }}
/>
</Card>
)}
<Card title="我的租约(用完自动收回,也可手动释放)">
<Table
rowKey="id"
size="small"
columns={leaseColumns}
dataSource={leases}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{ emptyText: '当前没有租用中的账号' }}
style={{ background: token.colorBgContainer }}
/>
</Card>
</Space>
);
}
@@ -15,6 +15,10 @@ export const TASK_STATUS_COLORS: Record<string, string> = {
failed: 'error',
error: 'error',
stopped: 'warning',
// 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红)
skipped: 'default',
// 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色
manual_required: 'warning',
};
export const TASK_STATUS_LABELS: Record<string, string> = {
@@ -25,6 +29,8 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
failed: '失败',
error: '异常',
stopped: '已停止',
skipped: '已跳过',
manual_required: '转人工',
};
export const ELITE_QUICK_ACTIONS = [