新增斗鱼活动任务模块:绑定、宝典、鱼翅、积分、兑换等功能

- 新增 activity_client.py:封装斗鱼活动/兑换/充值/送礼接口
- 新增 cookie_utils.py:Cookie 解析与规范化工具
- 新增 douyu_service/douyu_runner:斗鱼任务服务层与批量执行器
- 新增 douyu 路由:任务类型查询、账号列表、配置管理、商品管理、批量任务、WebSocket 日志
- 新增 models/schemas:DouyuTask/DouyuConfig/DouyuGoodsSnapshot 模型,Account 扩展点数/鱼翅/绑定状态等字段
- 新增数据库迁移:斗鱼活动相关表与 accounts 字段补充
- 新增前端 DouyuTasksPage 任务操作台页面
- 兑换商品请求添加 sec-ch-ua 反检测头
- 兑换商品支持最多 8 次重试 + csrf_token 自动刷新
- 注册 douyu:task / douyu:config 权限点
- 侧边栏新增斗鱼分组与任务操作台菜单入口
This commit is contained in:
yml2213
2026-07-24 16:11:39 +08:00
parent f91c05c108
commit 62099e8d68
17 changed files with 2506 additions and 1 deletions
+2
View File
@@ -17,6 +17,7 @@ const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
const ProxyPage = lazy(() => import('./pages/ProxyPage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
@@ -71,6 +72,7 @@ function AppContent() {
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="douyu/tasks" element={lazyRoute(<DouyuTasksPage />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+25
View File
@@ -0,0 +1,25 @@
import api from './client';
import type {
DouyuConfig,
DouyuGoodsItem,
DouyuTaskAccountItem,
DouyuTaskBatchRequest,
DouyuTaskBatchResult,
DouyuTaskItem,
MessageResponse,
} from './types';
export const douyuApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
listAccounts: (params?: { search?: string }) =>
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+1
View File
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
export { appApi } from './app';
export { authApi } from './auth';
export { cookieApi } from './cookies';
export { douyuApi } from './douyu';
export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
+72
View File
@@ -168,6 +168,78 @@ export interface CookieItem {
account_password: string;
}
// ==================== Douyu Activity ====================
export interface DouyuTaskAccountItem {
id: number;
username: string;
uid: string;
nickname: string;
tag: string;
points: number | null;
game_name: string;
game_channel: string;
gold_balance: number | null;
exchange_balance: number | null;
bind_status: string;
change_role_wait_time: number | null;
assigned_to: number | null;
assigned_username: string | null;
}
export interface DouyuConfig {
manual_id: string;
rid: string;
bind_act_alias: string;
confirm_act_alias: string;
legacy_act_alias: string;
room_id: string;
elite_amount: number;
gold_pay_type: number;
gift_id: string;
skin_id: string;
updated_at: string | null;
}
export interface DouyuTaskBatchRequest {
account_ids: number[];
task_type: string;
concurrency?: number;
payload?: Record<string, unknown>;
}
export interface DouyuTaskBatchResult {
batch_id: string;
count: number;
success: boolean;
}
export interface DouyuTaskItem {
id: number;
batch_id: string;
account_id: number;
account_username: string;
account_uid: string;
account_nickname: string;
task_type: string;
status: string;
message: string;
result: Record<string, unknown> | null;
created_by: number;
created_at: string | null;
finished_at: string | null;
}
export interface DouyuGoodsItem {
id: number;
commodity_id: string;
name: string;
score: number | null;
status: string;
raw: Record<string, unknown> | null;
updated_at: string | null;
}
// ==================== Huya ====================
export interface HuyaAccountItem {
+3
View File
@@ -70,6 +70,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('cookie:view')) {
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
// 虎牙
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
+516
View File
@@ -0,0 +1,516 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Modal, QRCode, Row, Select, Space, Table, Tag, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, LinkOutlined,
QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import {
douyuApi,
type DouyuConfig,
type DouyuGoodsItem,
type DouyuTaskAccountItem,
type DouyuTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
const { Text } = Typography;
const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定',
create_elite_qr: '开通精英宝典30',
create_gold_qr: '充值鱼翅',
donate_elite_gift: '赠送精英令',
query_points: '一键查询积分',
exchange_goods: '兑换商品',
query_game_name: '一键获取游戏名',
query_change_bind_time: '一键查询换绑时间',
query_limited_goods: '一键查询限兑商品',
query_gold_balance: '一键查询鱼刺余额',
refresh_goods: '刷新商品列表',
query_exchange_records: '一键查询兑换记录',
prefetch_csrf_token: '一键获取兑换 CSRF Token',
};
const TASK_ICONS: Record<string, React.ReactNode> = {
get_bind_qr: <QrcodeOutlined />,
confirm_bind: <CheckCircleOutlined />,
create_elite_qr: <CreditCardOutlined />,
create_gold_qr: <CreditCardOutlined />,
donate_elite_gift: <GiftOutlined />,
query_points: <SearchOutlined />,
exchange_goods: <ShoppingOutlined />,
query_game_name: <SearchOutlined />,
query_change_bind_time: <FieldTimeOutlined />,
query_limited_goods: <SearchOutlined />,
query_gold_balance: <SearchOutlined />,
refresh_goods: <ReloadOutlined />,
query_exchange_records: <FieldTimeOutlined />,
prefetch_csrf_token: <LinkOutlined />,
};
const STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
};
function accountLabel(account: DouyuTaskAccountItem): string {
const name = account.nickname || account.username || account.uid || `#${account.id}`;
const tag = account.tag ? ` [${account.tag}]` : '';
const game = account.game_name ? ` / ${account.game_name}` : '';
return `${name}${tag}${game}`;
}
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
const value = result?.[key];
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim()) return Number(value) || null;
return null;
}
function taskPayUrl(task: DouyuTaskItem | null): string {
return resultText(task?.result, 'pay_url') || resultText(task?.result, 'url');
}
function goodsLabel(item: DouyuGoodsItem): string {
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}`;
}
const defaultConfig: DouyuConfig = {
manual_id: 'G4KA4Qnz4LDp7',
rid: '9263298',
bind_act_alias: '20250213NQCYX',
confirm_act_alias: '20260120QYOOB',
legacy_act_alias: 'cjm',
room_id: '9263298',
elite_amount: 3000,
gold_pay_type: 1,
gift_id: '23643',
skin_id: '2942',
updated_at: null,
};
export default function DouyuTasksPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [config, setConfig] = useState<DouyuConfig>(defaultConfig);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [concurrency, setConcurrency] = useState(3);
const [goldAmount, setGoldAmount] = useState(1);
const [giftCount, setGiftCount] = useState(1);
const [loading, setLoading] = useState(false);
const [runningBatchId, setRunningBatchId] = useState('');
const [configOpen, setConfigOpen] = useState(false);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const [configForm] = Form.useForm<DouyuConfig>();
const logs = useWebSocketLogs();
const loadData = useCallback(async () => {
setLoading(true);
try {
const [accountResult, goodsResult, taskResult, configResult, taskTypeResult] = await Promise.allSettled([
douyuApi.listAccounts(),
douyuApi.listGoods(),
douyuApi.listTasks(),
douyuApi.getConfig(),
douyuApi.taskTypes(),
]);
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (taskResult.status === 'fulfilled') setTasks(taskResult.value);
if (configResult.status === 'fulfilled') setConfig(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes(taskTypeResult.value);
const errors = [
accountResult.status === 'rejected' ? `账号: ${getErrorMessage(accountResult.reason)}` : '',
goodsResult.status === 'rejected' ? `商品: ${getErrorMessage(goodsResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务: ${getErrorMessage(taskResult.reason)}` : '',
configResult.status === 'rejected' ? `配置: ${getErrorMessage(configResult.reason)}` : '',
].filter(Boolean);
if (errors.length) message.warning(errors.join(''));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
if (configOpen) configForm.setFieldsValue(config);
}, [configOpen, config, configForm]);
const activeTask = tasks.find((item) => ['planned', 'pending', 'running'].includes(item.status));
const goodsOptions = useMemo(
() => goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) })),
[goods],
);
const startTask = async (taskType: string) => {
if (selectedAccountIds.length === 0) {
message.warning('请先选择斗鱼账号');
return;
}
if (taskType === 'exchange_goods' && !selectedGoodsId) {
message.warning('请先选择兑换商品');
return;
}
const payload: Record<string, unknown> = {};
if (taskType === 'exchange_goods') {
payload.commodity_id = selectedGoodsId;
}
if (taskType === 'create_gold_qr') {
payload.amount = goldAmount;
}
if (taskType === 'donate_elite_gift') {
payload.gift_count = giftCount;
}
try {
const result = await douyuApi.createTasks({
account_ids: selectedAccountIds,
task_type: taskType,
concurrency,
payload,
});
setRunningBatchId(result.batch_id);
logs.connect(`/api/douyu/ws/${result.batch_id}`, {
clear: true,
onResult: () => {
setRunningBatchId('');
loadData();
},
});
message.success(`已创建 ${result.count} 个任务`);
setTimeout(loadData, 500);
} catch (error) {
message.error(getErrorMessage(error));
}
};
const stopTask = async () => {
if (!runningBatchId) return;
try {
await douyuApi.stopBatch(runningBatchId);
message.success('已发送停止信号');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const saveConfig = async () => {
try {
const values = await configForm.validateFields();
const saved = await douyuApi.updateConfig(values);
setConfig(saved);
setConfigOpen(false);
message.success('配置已保存');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const openPayTask = (task: DouyuTaskItem) => {
const url = taskPayUrl(task);
if (!url) {
message.warning('任务结果中没有二维码链接');
return;
}
setPayTask(task);
};
const accountColumns: TableProps<DouyuTaskAccountItem>['columns'] = [
{
title: '账号',
dataIndex: 'username',
width: 190,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || '-'}</Text>
</Space>
),
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 180,
render: (_, record) => record.game_name ? (
<Space direction="vertical" size={0}>
<Text>{record.game_name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.game_channel || '-'}</Text>
</Space>
) : <Text type="secondary"></Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 90,
render: (value) => value ?? <Text type="secondary">-</Text>,
sorter: (a, b) => (a.points ?? -1) - (b.points ?? -1),
},
{
title: '鱼翅',
dataIndex: 'gold_balance',
width: 90,
render: (value) => value ?? <Text type="secondary">-</Text>,
},
{
title: '状态',
dataIndex: 'bind_status',
width: 120,
render: (value) => value ? <Tag>{value}</Tag> : <Text type="secondary">-</Text>,
},
];
const taskColumns: TableProps<DouyuTaskItem>['columns'] = [
{
title: '账号',
dataIndex: 'account_username',
width: 150,
render: (_, record) => record.account_nickname || record.account_username || record.account_uid,
},
{
title: '任务',
dataIndex: 'task_type',
width: 170,
render: (value) => taskTypes[value] || value,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value) => <Tag color={STATUS_COLORS[value] || 'default'}>{STATUS_LABELS[value] || value}</Tag>,
},
{
title: '结果',
dataIndex: 'message',
ellipsis: true,
render: (_, record) => {
const payUrl = taskPayUrl(record);
const points = resultNumber(record.result, 'points');
return (
<Space wrap>
<Text>{record.message || '-'}</Text>
{typeof points === 'number' && <Tag color="blue"> {points}</Tag>}
{payUrl && (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openPayTask(record)}>
</Button>
)}
</Space>
);
},
},
{
title: '时间',
dataIndex: 'finished_at',
width: 170,
render: (_, record) => formatTime(record.finished_at || record.created_at),
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<h2 style={{ margin: 0 }}></h2>
<Text type="secondary"></Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
<Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}></Button>
{runningBatchId && (
<Button danger icon={<StopOutlined />} onClick={stopTask}></Button>
)}
</Space>
</Space>
<Row gutter={[12, 12]}>
<Col xs={24} xl={15}>
<Card
size="small"
title="账号"
extra={<Tag color="blue"> {selectedAccountIds.length}</Tag>}
>
<Table
rowKey="id"
size="small"
loading={loading}
rowSelection={{
selectedRowKeys: selectedAccountIds,
onChange: (keys) => setSelectedAccountIds(keys.map(Number)),
}}
columns={accountColumns}
dataSource={accounts}
pagination={{ pageSize: 8, showSizeChanger: false }}
scroll={{ x: 700 }}
/>
</Card>
</Col>
<Col xs={24} xl={9}>
<Card size="small" title="操作">
<Space direction="vertical" style={{ width: '100%' }} size={10}>
<Select
mode="multiple"
value={selectedAccountIds}
onChange={setSelectedAccountIds}
options={accounts.map((item) => ({ value: item.id, label: accountLabel(item) }))}
placeholder="选择账号"
maxTagCount="responsive"
style={{ width: '100%' }}
/>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 110 }} />
<Input value="并发数" disabled />
</Space.Compact>
<Row gutter={[8, 8]}>
{[
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'query_points',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
].map((key) => (
<Col span={12} key={key}>
<Button block icon={TASK_ICONS[key]} onClick={() => startTask(key)} disabled={!!activeTask}>
{taskTypes[key] || key}
</Button>
</Col>
))}
</Row>
<Card size="small" title="兑换">
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goodsOptions}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => startTask('refresh_goods')} disabled={!!activeTask}>
</Button>
<Button type="primary" icon={<ShoppingOutlined />} onClick={() => startTask('exchange_goods')} disabled={!!activeTask}>
</Button>
</Space>
</Space>
</Card>
<Card size="small" title="充值与送礼">
<Space direction="vertical" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} value={goldAmount} onChange={(value) => setGoldAmount(value || 1)} style={{ width: 120 }} />
<Button icon={<CreditCardOutlined />} onClick={() => startTask('create_gold_qr')} disabled={!!activeTask}>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} value={giftCount} onChange={(value) => setGiftCount(value || 1)} style={{ width: 120 }} />
<Button icon={<GiftOutlined />} onClick={() => startTask('donate_elite_gift')} disabled={!!activeTask}>
</Button>
</Space.Compact>
</Space>
</Card>
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} height={180} />
</Space>
</Card>
</Col>
</Row>
<Card size="small" title="任务记录">
<Table
rowKey="id"
size="small"
columns={taskColumns}
dataSource={tasks}
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title="斗鱼活动配置"
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={saveConfig}
okText="保存"
cancelText="取消"
width={720}
>
<Form form={configForm} layout="vertical" initialValues={config}>
<Row gutter={12}>
<Col span={12}><Form.Item label="manualID" name="manual_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="RID" name="rid"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="绑定二维码活动" name="bind_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="确认绑定活动" name="confirm_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="旧版查询活动" name="legacy_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="房间 ID" name="room_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="宝典金额(分)" name="elite_amount"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item label="鱼翅支付方式" name="gold_pay_type"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item label="精英令礼物 ID" name="gift_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="皮肤 ID" name="skin_id"><Input /></Form.Item></Col>
</Row>
</Form>
</Modal>
<Modal
title="二维码"
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
centered
>
{payTask && (
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Text strong>{taskTypes[payTask.task_type] || payTask.task_type}</Text>
<QRCode value={taskPayUrl(payTask)} size={260} />
<Text copyable style={{ maxWidth: '100%', color: token.colorTextSecondary }}>
{taskPayUrl(payTask)}
</Text>
</Space>
)}
</Modal>
</div>
);
}