diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index 13edace..f360735 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -136,6 +136,131 @@ class GetUserPrizeRecordsReq(TafStruct): self.sid = ins.read_int32(1, default=self.sid) +class ScoreExchangePrizeReq(TafStruct): + """webActUI.scoreExchangePrize 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.sid: int = 0 + self.pid: int = 0 + self.ip: str = "" + self.clientEnv: dict = {} + self.source: str = "" + self.isRole: int = 1 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.sid) + os.write_int32(2, self.pid) + os.write_string(3, self.ip) + os.write_map(4, self.clientEnv) + os.write_string(5, self.source) + os.write_int32(6, self.isRole) + + def read_from(self, ins: TafInputStream): + self.userId = ins.read_struct(0, ActivityUserId) or self.userId + self.sid = ins.read_int32(1, default=self.sid) + self.pid = ins.read_int32(2, default=self.pid) + self.ip = ins.read_string(3, default=self.ip) + self.clientEnv = ins.read_map(4) + self.source = ins.read_string(5, default=self.source) + self.isRole = ins.read_int32(6, default=self.isRole) + + +class ExchangeInfo(TafStruct): + """积分兑换结果信息。""" + + def __init__(self): + self.pid: int = 0 + self.exchangeScore: int = 0 + self.desc: str = "" + + def read_from(self, ins: TafInputStream): + self.pid = ins.read_int32(1, default=self.pid) + self.exchangeScore = ins.read_int64(2, default=self.exchangeScore) + self.desc = ins.read_string(3, default=self.desc) + + def write_to(self, os: TafOutputStream): + os.write_int32(1, self.pid) + os.write_int64(2, self.exchangeScore) + os.write_string(3, self.desc) + + def to_dict(self) -> dict: + return { + "pid": self.pid, + "exchange_score": self.exchangeScore, + "desc": self.desc, + } + + +class ExchangeActPreCondition(TafStruct): + """兑换前置条件提示。""" + + def __init__(self): + self.conditionType: int = 0 + self.conditionParams: dict = {} + self.tip: str = "" + self.jumpUrl: str = "" + self.buttonDesc: str = "" + + def read_from(self, ins: TafInputStream): + self.conditionType = ins.read_int32(0, default=self.conditionType) + self.conditionParams = ins.read_map(1) + self.tip = ins.read_string(2, default=self.tip) + self.jumpUrl = ins.read_string(3, default=self.jumpUrl) + self.buttonDesc = ins.read_string(4, default=self.buttonDesc) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.conditionType) + os.write_map(1, self.conditionParams) + os.write_string(2, self.tip) + os.write_string(3, self.jumpUrl) + os.write_string(4, self.buttonDesc) + + def to_dict(self) -> dict: + return { + "condition_type": self.conditionType, + "condition_params": dict(self.conditionParams), + "tip": self.tip, + "jump_url": self.jumpUrl, + "button_desc": self.buttonDesc, + } + + +class ScoreExchangePrizeResp(TafStruct): + """webActUI.scoreExchangePrize 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: str = "" + self.orderId: str = "" + self.exchangeInfo = ExchangeInfo() + self.actPreCondition = ExchangeActPreCondition() + + def read_from(self, ins: TafInputStream): + self.status = ins.read_int32(0, default=self.status) + self.msg = ins.read_string(1, default=self.msg) + self.orderId = ins.read_string(3, default=self.orderId) + self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo + self.actPreCondition = ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.status) + os.write_string(1, self.msg) + os.write_string(3, self.orderId) + os.write_struct(4, self.exchangeInfo) + os.write_struct(5, self.actPreCondition) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + "order_id": self.orderId, + "exchange_info": self.exchangeInfo.to_dict(), + "act_pre_condition": self.actPreCondition.to_dict(), + } + + class UserPrizeRecordItem(TafStruct): """用户兑换记录项,字段来自 getUserPrizeRecords 实测响应。""" diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 416ec21..7062452 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -302,6 +302,23 @@ class HuyaHttpClient: timeout=timeout, ) + def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0): + """兑换活动积分商品。""" + from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp + req = ScoreExchangePrizeReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + req.pid = int(pid or 0) + return self.call_rpc( + "webActUI", + "scoreExchangePrize", + req, + ScoreExchangePrizeResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + def get_act_task_detail(self, uid: int, cookie: str, act_id: int, timeout: float = 15.0): """查询活动任务详情,用于发现充值商品 SPU。""" from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index aebb0fd..3ea3fd7 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -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": diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 053115d..53ddcdb 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -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( diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 6851faf..661139e 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -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 = { 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: }, { key: 'refresh_goods', icon: }, { key: 'refresh_recharge_goods', icon: }, + { key: 'exchange_goods', icon: }, { key: 'create_recharge_order', icon: }, ]; @@ -140,6 +143,8 @@ export default function HuyaTasksPage() { const [selectedIds, setSelectedIds] = useState([]); const [selectedTaskType, setSelectedTaskType] = useState('query_points'); const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); + const [selectedExchangeGoodsId, setSelectedExchangeGoodsId] = useState(''); + const [exchangeAt, setExchangeAt] = useState(null); const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState(''); 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 商品 {goodsCount} 个; return -; } + if (record.task_type === 'exchange_goods') { + const productName = resultText(value, 'product_name'); + const orderId = resultText(value, 'order_id'); + if (record.status !== 'success') { + return productName ? {productName} : -; + } + return ( + + 已兑换 + {productName ? {productName} : null} + {orderId ? {orderId} : null} + + ); + } 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['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) => ( + + {value || '-'} + + ), + }, { title: '分类', - width: 110, + width: 96, render: (_: unknown, record) => { const label = goodsCategoryLabel(record); return label ? {label} : -; @@ -573,20 +641,20 @@ export default function HuyaTasksPage() { { title: '价格', dataIndex: 'price', - width: 90, + width: 78, align: 'center', render: (value: number | null) => value ?? -, }, { title: '库存', dataIndex: 'remain_text', - width: 100, + width: 72, render: (value: string) => formatRemainText(value) || -, }, { title: '更新时间', dataIndex: 'updated_at', - width: 160, + width: 154, render: (value: string | null) => value ? formatTime(value) : -, }, ]; @@ -728,7 +796,52 @@ export default function HuyaTasksPage() { - 兑换商品列表} style={{ marginBottom: 12 }}> + 兑换商品列表} + extra={( + + + + + )} + style={{ marginBottom: 12 }} + > + +