Files
live-hub-py/web/frontend/src/pages/douyu-tasks/config.tsx
T
yml2213 18bc7be4b1 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
2026-10-07 12:29:08 +08:00

376 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from 'react';
import {
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined,
} from '@ant-design/icons';
import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules';
export type HandbookKind = 'elite' | 'esports' | 'peace';
export const TASK_STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
// 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红)
skipped: 'default',
// 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色
manual_required: 'warning',
};
export const TASK_STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
skipped: '已跳过',
manual_required: '转人工',
};
export const ELITE_QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_elite_status', icon: <SearchOutlined /> },
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'query_game_name', icon: <SearchOutlined /> },
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
];
export const ESPORTS_QUICK_ACTIONS = [
{ key: 'prepare_esports_bind', icon: <QrcodeOutlined /> },
{ key: 'query_esports_game_name', icon: <SearchOutlined /> },
{ key: 'create_esports_qr', icon: <CreditCardOutlined /> },
{ key: 'query_esports_points', icon: <SearchOutlined /> },
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'refresh_esports_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_esports_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_esports_chicken_gift', icon: <GiftOutlined /> },
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> },
];
export const PEACE_QUICK_ACTIONS = [
{ key: 'get_xpd_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
{ key: 'query_xpd_purchase_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
];
export const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'query_elite_status',
'create_gold_qr',
'donate_elite_gift',
'query_points',
'lock_goods',
'pay_locked_order',
'exchange_goods',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
'refresh_goods',
'query_exchange_records',
'prefetch_csrf_token',
'query_account_overview',
]);
export const ESPORTS_TASK_TYPES = new Set([
'prepare_esports_bind',
'get_esports_bind_qr',
'query_esports_game_name',
'confirm_esports_bind',
'create_esports_qr',
'query_esports_points',
'query_gold_balance',
'query_change_bind_time',
'query_limited_goods',
'refresh_esports_goods',
'exchange_esports_goods',
'create_gold_qr',
'donate_esports_chicken_gift',
'donate_esports_firework_gift',
'query_account_overview',
]);
export const PEACE_TASK_TYPES = new Set([
'get_xpd_bind_qr',
'query_xpd_bind_info',
'confirm_xpd_bind',
'query_xpd_role',
'refresh_xpd_goods',
'query_xpd_balance',
'query_xpd_fragments',
'query_xpd_purchase_records',
'exchange_xpd_goods',
'query_account_overview',
]);
export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
export const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
// v2 避开旧版页面切换时写入其他工作台的共享账号列表。
export const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`;
export const CONFIRM_FAIL_TIP_SECONDS = 6;
export function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
export function taskPayloadText(task: DouyuTaskItem, key: string): string {
const payload = task.result?.payload;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
return resultText(payload as Record<string, unknown>, key);
}
export function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
const value = result?.[key];
if (value === true) return true;
if (value === false || value == null) return false;
const text = String(value).trim().toLowerCase();
return text === '1' || text === 'true' || text === 'yes';
}
// 提取任务结果中的二维码 URL:绑定/电竞绑定用 url,支付类用 pay_url
export function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
if (!task) return '';
if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url');
if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url');
if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) {
return resultText(task.result, 'pay_url');
}
if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(task.task_type)) {
return resultText(task.result, 'url');
}
return '';
}
// 精英宝典开通状态展示:积分区分不了"开通后积分花完"和"从未开通",
// 以后端落库的 manualType(elite_opened)为准。
export function eliteStatusText(
account: { elite_opened?: boolean | null; elite_expire_time?: number | null },
): string {
if (account.elite_opened == null) return '';
if (!account.elite_opened) return '未开通';
const expire = account.elite_expire_time;
if (!expire || expire <= 0) return '已开通';
const at = new Date(expire * 1000);
if (Number.isNaN(at.getTime())) return '已开通';
const pad = (n: number) => String(n).padStart(2, '0');
return `已开通 ${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} 到期`;
}
export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
if (!task) return false;
if (task.task_type === 'get_xpd_bind_qr') {
if (resultFlag(task.result, 'xpd_bound') || resultFlag(task.result, 'bind_confirmed')) return false;
// 轮询识别到角色后停在待确认(xpd_pending_confirm),有角色名即可确认绑定
return Boolean(resultText(task.result, 'role_name'));
}
if (task.task_type !== 'get_bind_qr') return false;
if (task.result?.bind_ready_for_confirm === true) return true;
const roleName = resultText(task.result, 'role_name');
if (!roleName) return false;
// 已确认绑定的角色不能再次直接确认
if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) {
return false;
}
return true;
}
export function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
if (!task) return false;
if (!['get_bind_qr', 'get_xpd_bind_qr'].includes(task.task_type)) return false;
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
return Boolean(resultText(task.result, 'url'))
|| bindReadyForConfirm(task)
|| resultFlag(task.result, 'esports_bound');
}
export function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
return Boolean(
task
&& ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)
&& resultText(task.result, 'pay_url'),
);
}
export function paymentArrived(task: DouyuTaskItem | null | undefined): boolean {
if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false;
if (task.status !== 'success') return false;
return task.result?.gold_recharged === true
|| task.result?.elite_opened === true
|| task.result?.esports_opened === true;
}
export function bindPhaseText(phase: string, polling: boolean): string {
if (phase === 'role_ready') return '已识别角色,待确认';
if (phase === 'change_waiting') return '换绑冷却中';
if (phase === 'role_timeout') return '未检测到角色';
if (phase === 'stopped') return '任务已停止';
if (phase === 'confirmed') return '已绑定';
if (phase === 'confirm_failed') return '确认未生效';
if (polling || phase === 'waiting_scan') return '等待扫码绑定';
return phase || '等待绑定';
}
export function goodsLabel(item: DouyuGoodsItem): string {
const stock = item.raw?.count;
const stockText =
stock != null && Number.isFinite(Number(stock)) ? ` / 库存${stock}` : '';
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`;
}
// 道聚城 iPrice 为 10 倍点券单位(如 6800=680 点券),仅展示层换算,兑换请求仍传原值。
export const XPD_POINT_PRICE_SCALE = 10;
export function xpdPointPrice(item: DouyuGoodsItem): number {
const xpd = item as DouyuGoodsItem & { price?: number | null };
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const rawPrice = Number(xpd.price ?? raw?.iPrice);
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 0;
return Math.round(rawPrice / XPD_POINT_PRICE_SCALE);
}
export function xpdGoodsLabel(item: DouyuGoodsItem): string {
const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null };
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const pointPrice = xpdPointPrice(item);
const fragmentPrice = Number(raw?.iJb2Price);
const prices = [
Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '',
Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '',
].filter(Boolean);
const stock = xpd.goods_left;
const stockText = stock != null && Number.isFinite(Number(stock))
? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`)
: '库存未知';
return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`;
}
export function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> {
if (!item) return [];
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const pointPrice = xpdPointPrice(item);
const fragmentPrice = Number(raw?.iJb2Price);
return [
Number.isFinite(pointPrice) && pointPrice > 0
? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` }
: null,
Number.isFinite(fragmentPrice) && fragmentPrice > 0
? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` }
: null,
].filter((option): option is { value: '1' | '5'; label: string } => option !== null);
}
export function formatWaitSeconds(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(Number(seconds))) return '';
const total = Math.max(0, Math.floor(Number(seconds)));
if (total <= 0) return '可换绑';
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (days > 0) return `${days}天${hours}小时${minutes}分`;
if (hours > 0) return `${hours}小时${minutes}分${secs}秒`;
return `${minutes}分${secs}秒`;
}
export function esportsRebindStatus(canChangeTime: number | null | undefined): {
available: boolean;
text: string;
} | null {
if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null;
const timestamp = Math.floor(Number(canChangeTime));
if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) {
return { available: true, text: '可换绑' };
}
return {
available: false,
text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }),
};
}
export function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
const value = result?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
return null;
}
export function limitedGoodsName(item: unknown): string {
if (!item || typeof item !== 'object') return '';
const record = item as Record<string, unknown>;
const value = record.commodityName ?? record.name;
return typeof value === 'string' ? value : '';
}
export function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): string {
if (!task) return '';
if (task.message?.trim()) return task.message.trim();
const count = resultNumber(task.result, 'limited_count');
if (count == null) return '';
if (count <= 0) return '无限制商品';
const goods = task.result?.limited_goods;
const names = Array.isArray(goods)
? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5)
: [];
return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`;
}
export function savedPositiveInteger(key: string, fallback = 1): number {
const value = Number(localStorage.getItem(key));
return Number.isInteger(value) && value >= 1 ? value : fallback;
}
export const HANDBOOK_META: Record<HandbookKind, {
title: string;
description: string;
taskTypes: Set<string>;
quickActions: Array<{ key: string; icon: ReactNode }>;
}> = {
elite: {
title: '精英宝典工作台',
description: '精英宝典绑定、开通、积分与兑换任务',
taskTypes: ELITE_TASK_TYPES,
quickActions: ELITE_QUICK_ACTIONS,
},
esports: {
title: '电竞手册工作台',
description: '电竞手册角色绑定与开通任务',
taskTypes: ESPORTS_TASK_TYPES,
quickActions: ESPORTS_QUICK_ACTIONS,
},
peace: {
title: '和平小店工作台',
description: '和平小店绑定角色、商品与点券余额查询',
taskTypes: PEACE_TASK_TYPES,
quickActions: PEACE_QUICK_ACTIONS,
},
};