实现虎牙商品兑换并优化列表展示

This commit is contained in:
yml2213
2026-07-05 10:46:00 +08:00
parent b63d685f2f
commit 5534293ed1
5 changed files with 381 additions and 14 deletions
+105
View File
@@ -1,6 +1,7 @@
"""虎牙任务批次执行器。"""
import asyncio
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
@@ -123,6 +124,31 @@ class HuyaBatchRunner:
return ""
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def _parse_scheduled_time(value) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
normalized = text.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
if dt.tzinfo is None:
return dt.astimezone()
return dt
def _wait_until(self, when: datetime, uid: int) -> bool:
target = when.timestamp()
local_text = self._format_local_time(int(target))
self._push_log("info", f"[{uid}] 定时兑换等待到 {local_text}")
while not self._stop.is_set():
remaining = target - time.time()
if remaining <= 0:
return True
time.sleep(min(0.2, max(0.02, remaining)))
return False
@classmethod
def _bind_change_state(cls, bind_status) -> dict:
account_data = bind_status.accountData
@@ -337,6 +363,82 @@ class HuyaBatchRunner:
message = f"已刷新商品 {len(goods)}"
self._mark_task(worker_db, task, "success", message, {**result, "goods": goods})
def _execute_exchange_goods(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
sid = str(self.payload.get("sid") or config_info.get("sid") or "").strip()
if not sid:
self._mark_task(worker_db, task, "failed", "请先配置虎牙活动 SID")
return
sid_int = self._to_int(sid)
if not sid_int:
self._mark_task(worker_db, task, "failed", f"虎牙活动 SID 无效: {sid}")
return
product_id = self._to_int(self.payload.get("product_id"))
if not product_id:
self._mark_task(worker_db, task, "failed", "请选择兑换商品")
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
snapshot = worker_db.query(HuyaGoodsSnapshot).filter(
HuyaGoodsSnapshot.product_id == str(product_id)
).first()
product_name = str(self.payload.get("product_name") or (snapshot.name if snapshot else "") or product_id)
scheduled_at = self._parse_scheduled_time(self.payload.get("scheduled_at"))
if self.payload.get("scheduled_at") and scheduled_at is None:
self._mark_task(worker_db, task, "failed", "定时兑换时间格式无效")
return
if scheduled_at and scheduled_at.timestamp() > time.time():
if not self._wait_until(scheduled_at, uid):
self._mark_task(worker_db, task, "failed", "兑换任务已停止")
return
client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}"))
response = client.score_exchange_prize(uid=uid, cookie=cookie, sid=sid_int, pid=product_id)
if response is None:
self._mark_task(worker_db, task, "error", "虎牙兑换接口无响应")
return
result = response.to_dict()
result.update({
"sid": sid_int,
"product_id": str(product_id),
"product_name": product_name,
"scheduled_at": scheduled_at.isoformat() if scheduled_at else "",
"executed_at": datetime.now(timezone.utc).isoformat(),
"goods": snapshot.raw if snapshot else None,
})
if response.status != 200:
self._mark_task(
worker_db,
task,
"failed",
response.msg or f"虎牙兑换失败: {response.status}",
result,
)
return
account.status = "goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
message = response.msg or f"兑换成功: {product_name}"
self._mark_task(worker_db, task, "success", message, result)
@staticmethod
def _normalize_pay_channel(value) -> str:
text = str(value or "").strip()
@@ -1017,6 +1119,7 @@ class HuyaBatchRunner:
"query_exchange_records",
"refresh_goods",
"refresh_recharge_goods",
"exchange_goods",
"create_recharge_order",
}:
self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现")
@@ -1030,6 +1133,8 @@ class HuyaBatchRunner:
self._execute_refresh_goods(worker_db, task, account, account_info, config_info)
elif self.task_type == "refresh_recharge_goods":
self._execute_refresh_recharge_goods(worker_db, task, account, account_info, config_info)
elif self.task_type == "exchange_goods":
self._execute_exchange_goods(worker_db, task, account, account_info, config_info)
elif self.task_type == "create_recharge_order":
self._execute_create_recharge_order(worker_db, task, account, account_info, config_info)
elif self.task_type == "get_bind_qr":
+3 -2
View File
@@ -18,6 +18,7 @@ SUPPORTED_TASK_TYPES = {
"confirm_bind": "确认绑定",
"refresh_goods": "刷新商品列表",
"refresh_recharge_goods": "刷新充值商品列表",
"exchange_goods": "兑换商品",
"create_recharge_order": "生成支付二维码",
}
@@ -172,8 +173,8 @@ def create_planned_tasks(
batch_id = uuid.uuid4().hex[:12]
payload = payload or {}
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts:
# 全局快照和单笔支付二维码都使用一个选中的 CK 即可。
if task_type in {"refresh_goods", "refresh_recharge_goods", "exchange_goods", "create_recharge_order"} and accounts:
# 全局快照、单笔兑换和单笔支付二维码都使用一个选中的 CK 即可。
accounts = accounts[:1]
for account in accounts:
db.add(HuyaTask(
+131 -12
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, message, Modal, QRCode, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme,
Button, Card, Col, DatePicker, Form, Input, InputNumber, message, Modal, QRCode, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import type { Dayjs } from 'dayjs';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
@@ -31,6 +32,7 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
exchange_goods: '兑换商品',
create_recharge_order: '生成支付二维码',
};
@@ -42,6 +44,7 @@ const QUICK_ACTIONS = [
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_goods', icon: <ReloadOutlined /> },
{ key: 'refresh_recharge_goods', icon: <ShoppingOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_recharge_order', icon: <CreditCardOutlined /> },
];
@@ -140,6 +143,8 @@ export default function HuyaTasksPage() {
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedTaskType, setSelectedTaskType] = useState('query_points');
const [selectedGoodsCategory, setSelectedGoodsCategory] = useState('');
const [selectedExchangeGoodsId, setSelectedExchangeGoodsId] = useState('');
const [exchangeAt, setExchangeAt] = useState<Dayjs | null>(null);
const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState<string>('');
const [rechargeCount, setRechargeCount] = useState(1);
const [rechargePayChannel, setRechargePayChannel] = useState('Weixin');
@@ -340,7 +345,35 @@ export default function HuyaTasksPage() {
return sortedGoods.filter((item) => goodsCategoryKey(item) === selectedGoodsCategory);
}, [sortedGoods, selectedGoodsCategory]);
const goodsOptions = useMemo(() => {
return sortedGoods.map((item) => ({
value: item.product_id,
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
}));
}, [sortedGoods]);
const selectedExchangeGoods = useMemo(() => {
return sortedGoods.find((item) => item.product_id === selectedExchangeGoodsId) || null;
}, [selectedExchangeGoodsId, sortedGoods]);
useEffect(() => {
if (sortedGoods.length === 0) {
if (selectedExchangeGoodsId) setSelectedExchangeGoodsId('');
return;
}
if (!sortedGoods.some((item) => item.product_id === selectedExchangeGoodsId)) {
setSelectedExchangeGoodsId(sortedGoods[0].product_id);
}
}, [selectedExchangeGoodsId, sortedGoods]);
const createPayload = (taskType: string) => {
if (taskType === 'exchange_goods') {
return {
product_id: selectedExchangeGoods?.product_id || selectedExchangeGoodsId,
product_name: selectedExchangeGoods?.name || '',
scheduled_at: exchangeAt ? exchangeAt.toISOString() : '',
};
}
if (taskType !== 'create_recharge_order') return {};
return {
spu_id: selectedRechargeGoods?.spu_id || selectedRechargeGoodsId,
@@ -360,6 +393,10 @@ export default function HuyaTasksPage() {
message.warning('请先选择充值商品');
return;
}
if (taskType === 'exchange_goods' && !selectedExchangeGoodsId) {
message.warning('请先选择兑换商品');
return;
}
setStarting(true);
try {
@@ -489,6 +526,20 @@ export default function HuyaTasksPage() {
if (typeof goodsCount === 'number') return <Tag color="green"> {goodsCount} </Tag>;
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'exchange_goods') {
const productName = resultText(value, 'product_name');
const orderId = resultText(value, 'order_id');
if (record.status !== 'success') {
return productName ? <Text>{productName}</Text> : <Text type="secondary">-</Text>;
}
return (
<Space size={6}>
<Tag color="green"></Tag>
{productName ? <Text>{productName}</Text> : null}
{orderId ? <Text type="secondary">{orderId}</Text> : null}
</Space>
);
}
if (record.task_type === 'refresh_recharge_goods') {
const goodsCount = value?.goods_count;
const failedCount = value?.failed_count;
@@ -560,11 +611,28 @@ export default function HuyaTasksPage() {
];
const goodsColumns: TableProps<HuyaGoodsItem>['columns'] = [
{ title: '商品ID', dataIndex: 'product_id', width: 120, ellipsis: true },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '商品ID', dataIndex: 'product_id', width: 88, ellipsis: true },
{
title: '名称',
dataIndex: 'name',
width: 230,
render: (value: string) => (
<Text
title={value}
style={{
display: 'block',
lineHeight: '20px',
whiteSpace: 'normal',
wordBreak: 'break-all',
}}
>
{value || '-'}
</Text>
),
},
{
title: '分类',
width: 110,
width: 96,
render: (_: unknown, record) => {
const label = goodsCategoryLabel(record);
return label ? <Tag>{label}</Tag> : <Text type="secondary">-</Text>;
@@ -573,20 +641,20 @@ export default function HuyaTasksPage() {
{
title: '价格',
dataIndex: 'price',
width: 90,
width: 78,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'remain_text',
width: 100,
width: 72,
render: (value: string) => formatRemainText(value) || <Text type="secondary">-</Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
width: 154,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
@@ -728,7 +796,52 @@ export default function HuyaTasksPage() {
</Form>
</Card>
<Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}>
<Card
size="small"
title={<Space><ShoppingOutlined /></Space>}
extra={(
<Space size={6}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => startTask('exchange_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
</Space>
)}
style={{ marginBottom: 12 }}
>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择兑换商品"
value={selectedExchangeGoodsId || undefined}
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 220 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<DatePicker
showTime
allowClear
value={exchangeAt}
onChange={setExchangeAt}
placeholder="立即兑换"
style={{ width: 190 }}
/>
</Space>
{goodsCategories.length > 0 && (
<Tabs
size="small"
@@ -750,6 +863,10 @@ export default function HuyaTasksPage() {
pagination={false}
scroll={{ y: 220 }}
locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedExchangeGoodsId(record.product_id),
})}
rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
@@ -885,9 +1002,11 @@ export default function HuyaTasksPage() {
? '使用一个选中的 CK 刷新当前 SID 兑换商品'
: item.key === 'refresh_recharge_goods'
? '使用一个选中的 CK 刷新充值商品列表'
: item.key === 'create_recharge_order'
? '按左侧选择生成扫码支付二维码'
: undefined
: item.key === 'exchange_goods'
? '按左侧选择商品,支持立即或定时兑换'
: item.key === 'create_recharge_order'
? '按左侧选择生成扫码支付二维码'
: undefined
}
>
<Button
@@ -902,7 +1021,7 @@ export default function HuyaTasksPage() {
))}
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</div>
</Card>