From fa0d49ff8cc1b49f4ea4ad3327968dc1e4914e23 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 4 Jul 2026 22:21:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=99=8E=E7=89=99=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=95=86=E5=93=81=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/activity_structs.py | 214 +++++++++++++++++++++++ core/huya/http_client.py | 16 ++ web/backend/routers/huya.py | 2 +- web/backend/services/huya_runner.py | 79 ++++++++- web/backend/services/huya_service.py | 3 + web/frontend/src/pages/HuyaTasksPage.tsx | 136 ++++++++++++-- 6 files changed, 432 insertions(+), 18 deletions(-) diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index 15a63d4..c1a45bb 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -104,6 +104,191 @@ class GetUserScoreResp(TafStruct): } +class GetActPrizeListReq(TafStruct): + """webActUI.getActPrizeList 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.sid: int = 0 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.sid) + + 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) + + +class ActPrizeItem(TafStruct): + """活动商品项,字段位置来自 getActPrizeList 实测响应。""" + + def __init__(self): + self.prizeId: int = 0 + self.prizeType: int = 0 + self.name: str = "" + self.score: int = 0 + self.icon: str = "" + self.stock: int = 0 + self.remainText: str = "" + self.price: int = 0 + self.categoryId: str = "" + self.totalRemain: int = 0 + self.rawOrder: int = 0 + self.startTime: int = 0 + self.endTime: int = 0 + + def read_from(self, ins: TafInputStream): + self.prizeId = ins.read_int64(0, default=self.prizeId) + self.prizeType = ins.read_int32(1, default=self.prizeType) + self.name = ins.read_string(2, default=self.name) + self.score = ins.read_int64(3, default=self.score) + ins.read_int32(4, default=0) + self.icon = ins.read_string(5, default=self.icon) + self.stock = ins.read_int64(10, default=self.stock) + self.remainText = ins.read_string(12, default=self.remainText) + self.price = ins.read_int64(13, default=self.price) + self.categoryId = ins.read_string(14, default=self.categoryId) + self.totalRemain = ins.read_int64(17, default=self.totalRemain) + self.rawOrder = ins.read_int64(22, default=self.rawOrder) + self.startTime = ins.read_int64(25, default=self.startTime) + self.endTime = ins.read_int64(26, default=self.endTime) + + def write_to(self, os: TafOutputStream): + os.write_int64(0, self.prizeId) + os.write_int32(1, self.prizeType) + os.write_string(2, self.name) + os.write_int64(3, self.score) + os.write_string(5, self.icon) + os.write_int64(10, self.stock) + os.write_string(12, self.remainText) + os.write_int64(13, self.price) + os.write_string(14, self.categoryId) + os.write_int64(17, self.totalRemain) + os.write_int64(22, self.rawOrder) + os.write_int64(25, self.startTime) + os.write_int64(26, self.endTime) + + @staticmethod + def read_list_item(ins: TafInputStream, _tag: int): + item = ActPrizeItem() + item.read_from(ins) + while True: + _end_tag, dtype = ins.read_head() + if dtype == TafType.STRUCT_END: + break + ins.skip_field(dtype) + return item + + @staticmethod + def _remain_percent_text(value: str) -> str: + text = str(value or "").strip() + if not text or text.endswith("%"): + return text + try: + float(text) + except ValueError: + return text + return f"{text}%" + + def to_dict(self) -> dict: + return { + "product_id": str(self.prizeId), + "name": self.name, + "price": self.price, + "remain_text": self._remain_percent_text(self.remainText), + "remain_percent": self.remainText, + "stock": self.stock, + "score": self.score, + "icon": self.icon, + "prize_type": self.prizeType, + "category_id": self.categoryId, + "total_remain": self.totalRemain, + "raw_order": self.rawOrder, + "start_time": self.startTime, + "end_time": self.endTime, + } + + +class ActPrizeTag(TafStruct): + """活动商品分类。""" + + def __init__(self): + self.tagId: int = 0 + self.name: str = "" + self.sort: int = 0 + + def read_from(self, ins: TafInputStream): + self.tagId = ins.read_int64(0, default=self.tagId) + self.name = ins.read_string(1, default=self.name) + self.sort = ins.read_int32(2, default=self.sort) + + def write_to(self, os: TafOutputStream): + os.write_int64(0, self.tagId) + os.write_string(1, self.name) + os.write_int32(2, self.sort) + + @staticmethod + def read_list_item(ins: TafInputStream, _tag: int): + item = ActPrizeTag() + item.read_from(ins) + while True: + _end_tag, dtype = ins.read_head() + if dtype == TafType.STRUCT_END: + break + ins.skip_field(dtype) + return item + + def to_dict(self) -> dict: + return { + "category_id": str(self.tagId), + "category_name": self.name, + "sort": self.sort, + } + + +class GetActPrizeListResp(TafStruct): + """webActUI.getActPrizeList 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: str = "" + self.prizes: list[ActPrizeItem] = [] + self.tags: list[ActPrizeTag] = [] + + 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.prizes = ins.read_list(2, item_reader=ActPrizeItem.read_list_item) + self.tags = ins.read_list(4, item_reader=ActPrizeTag.read_list_item) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.status) + os.write_string(1, self.msg) + os.write_list(2, self.prizes) + os.write_list(4, self.tags) + + def to_dict(self) -> dict: + tag_info = { + str(item.tagId): {"name": item.name, "sort": index, "raw_sort": item.sort} + for index, item in enumerate(self.tags) + } + goods = [] + for item in self.prizes: + data = item.to_dict() + category = tag_info.get(data["category_id"], {}) + data["category_name"] = category.get("name", "") + data["category_sort"] = category.get("sort", 0) + goods.append(data) + return { + "status": self.status, + "msg": self.msg, + "goods_count": len(self.prizes), + "categories": [item.to_dict() for item in self.tags], + "goods": goods, + } + + class GameAccount(TafStruct): """游戏账号信息。""" @@ -532,6 +717,35 @@ def _self_check(): assert parsed.status == 200 assert parsed.available_score == 180 + prize_rsp = GetActPrizeListResp() + prize = ActPrizeItem() + prize.prizeId = 12885 + prize.name = "赤蝎幽灵大礼包" + prize.price = 3600 + prize.remainText = "39" + prize.categoryId = "85" + tag = ActPrizeTag() + tag.tagId = 85 + tag.name = "限量返场" + tag.sort = 2 + prize_rsp.status = 200 + prize_rsp.msg = "请求成功" + prize_rsp.prizes = [prize] + prize_rsp.tags = [tag] + os = TafOutputStream() + os.write_struct(0, prize_rsp) + ins = TafInputStream(os.get_bytes()) + _, dtype = ins.read_head() + assert dtype == TafType.STRUCT_BEGIN + parsed_prize = GetActPrizeListResp() + parsed_prize.read_from(ins) + assert parsed_prize.status == 200 + assert parsed_prize.prizes[0].name == "赤蝎幽灵大礼包" + assert parsed_prize.prizes[0].price == 3600 + parsed_prize_data = parsed_prize.to_dict() + assert parsed_prize_data["goods"][0]["category_name"] == "限量返场" + assert parsed_prize_data["goods"][0]["remain_text"] == "39%" + link_rsp = GetLiveLinkParamResp() link_rsp.status = 200 link_rsp.livelinkParam = { diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 0b35b1a..8196b9f 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -270,6 +270,22 @@ class HuyaHttpClient: timeout=timeout, ) + def get_act_prize_list(self, uid: int, cookie: str, sid: int, timeout: float = 15.0): + """查询活动可兑换商品列表。""" + from .activity_structs import GetActPrizeListReq, GetActPrizeListResp + req = GetActPrizeListReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = sid + return self.call_rpc( + "webActUI", + "getActPrizeList", + req, + GetActPrizeListResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + def check_user_bind_game_account( self, uid: int, diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 37e0574..d098f0a 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -196,7 +196,7 @@ def list_goods( current: User = Depends(require_permission("huya:task")), ): """查看已缓存的虎牙商品快照。""" - rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.updated_at.desc()).all() + rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.id.asc()).all() return rows diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index abd0871..8bc5fc7 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, joinedload from core.huya import HuyaHttpClient from ..database import SessionLocal -from ..models import HuyaAccount, HuyaTask +from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask from .huya_service import HUYA_CONFIG_FIELDS, cookie_value, ensure_huya_config, huya_config_value @@ -197,6 +197,73 @@ class HuyaBatchRunner: account.updated_at = datetime.now(timezone.utc) self._mark_task(worker_db, task, "success", f"积分: {points}", result) + def _execute_refresh_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 + + 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 + + client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")) + response = client.get_act_prize_list(uid=uid, cookie=cookie, sid=sid_int) + if response is None: + self._mark_task(worker_db, task, "error", "虎牙商品列表接口无响应") + return + + result = response.to_dict() + result["sid"] = sid_int + if response.status != 200: + self._mark_task( + worker_db, + task, + "failed", + response.msg or f"虎牙商品列表刷新失败: {response.status}", + result, + ) + return + + goods = [ + item for item in result.get("goods", []) + if item.get("product_id") and item.get("name") + ] + now = datetime.now(timezone.utc) + worker_db.query(HuyaGoodsSnapshot).delete(synchronize_session=False) + for item in goods: + worker_db.add(HuyaGoodsSnapshot( + product_id=item["product_id"], + name=item["name"], + price=item["price"], + remain_text=item["remain_text"], + raw=item, + updated_at=now, + )) + + account.status = "goods_refreshed" + account.updated_at = now + message = f"已刷新商品 {len(goods)} 个" + self._mark_task(worker_db, task, "success", message, {**result, "goods": goods}) + def _execute_get_bind_qr( self, worker_db: Session, @@ -556,7 +623,13 @@ class HuyaBatchRunner: name = self._account_name(account_info) self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}") - if self.task_type not in {"query_points", "get_bind_qr", "confirm_bind", "query_game_name"}: + if self.task_type not in { + "query_points", + "get_bind_qr", + "confirm_bind", + "query_game_name", + "refresh_goods", + }: self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") return @@ -564,6 +637,8 @@ class HuyaBatchRunner: try: if self.task_type == "query_points": self._execute_query_points(worker_db, task, account, account_info, config_info) + elif self.task_type == "refresh_goods": + self._execute_refresh_goods(worker_db, task, account, account_info, config_info) elif self.task_type == "get_bind_qr": self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) elif self.task_type == "confirm_bind": diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index adfd316..b24c623 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -172,6 +172,9 @@ 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 == "refresh_goods" and accounts: + # 商品列表是全局快照,使用一个可用 CK 刷新即可。 + accounts = accounts[:1] for account in accounts: db.add(HuyaTask( batch_id=batch_id, diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 8a42ad9..2f6c2c1 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Col, Form, Input, InputNumber, message, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography, theme, + Button, Card, Col, Form, Input, InputNumber, message, Modal, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme, } from 'antd'; import type { TableProps } from 'antd'; import { @@ -87,6 +87,34 @@ function resultProfileNick(result: Record | null | undefined): return typeof nick === 'string' ? nick : ''; } +function goodsRawString(item: HuyaGoodsItem, key: string): string { + const value = item.raw?.[key]; + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return ''; +} + +function goodsRawNumber(item: HuyaGoodsItem, key: string): number { + const value = item.raw?.[key]; + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim()) return Number(value) || 0; + return 0; +} + +function goodsCategoryKey(item: HuyaGoodsItem): string { + return goodsRawString(item, 'category_id') || goodsRawString(item, 'category_name') || 'uncategorized'; +} + +function goodsCategoryLabel(item: HuyaGoodsItem): string { + return goodsRawString(item, 'category_name') || goodsRawString(item, 'category_id') || '未分类'; +} + +function formatRemainText(value: string): string { + const text = String(value || '').trim(); + if (!text || text.endsWith('%')) return text; + return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text; +} + function hasMiniQrcode(task: HuyaTaskItem): boolean { return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image')); } @@ -101,6 +129,7 @@ export default function HuyaTasksPage() { const [selectedIds, setSelectedIds] = useState([]); const [selectedTaskType, setSelectedTaskType] = useState('query_points'); const [selectedGoodsId, setSelectedGoodsId] = useState(''); + const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); const [rechargeCount, setRechargeCount] = useState(1); const [concurrency, setConcurrency] = useState(() => { const v = localStorage.getItem('huya_task_concurrency'); @@ -200,16 +229,58 @@ export default function HuyaTasksPage() { return accounts.map((account) => ({ value: account.id, label: accountLabel(account) })); }, [accounts]); + const sortedGoods = useMemo(() => { + return [...goods].sort((a, b) => ( + goodsRawNumber(a, 'category_sort') - goodsRawNumber(b, 'category_sort') + || goodsRawNumber(a, 'raw_order') - goodsRawNumber(b, 'raw_order') + || a.id - b.id + )); + }, [goods]); + const goodsOptions = useMemo(() => { - return goods.map((item) => ({ + return sortedGoods.map((item) => ({ value: item.product_id, label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`, })); - }, [goods]); + }, [sortedGoods]); const selectedGoods = useMemo(() => { - return goods.find((item) => item.product_id === selectedGoodsId) || null; - }, [goods, selectedGoodsId]); + return sortedGoods.find((item) => item.product_id === selectedGoodsId) || null; + }, [sortedGoods, selectedGoodsId]); + + const goodsCategories = useMemo(() => { + const map = new Map(); + sortedGoods.forEach((item) => { + const key = goodsCategoryKey(item); + const current = map.get(key); + if (current) { + current.count += 1; + return; + } + map.set(key, { + key, + label: goodsCategoryLabel(item), + sort: goodsRawNumber(item, 'category_sort'), + count: 1, + }); + }); + return Array.from(map.values()).sort((a, b) => a.sort - b.sort || a.label.localeCompare(b.label)); + }, [sortedGoods]); + + useEffect(() => { + if (goodsCategories.length === 0) { + if (selectedGoodsCategory) setSelectedGoodsCategory(''); + return; + } + if (!goodsCategories.some((item) => item.key === selectedGoodsCategory)) { + setSelectedGoodsCategory(goodsCategories[0].key); + } + }, [goodsCategories, selectedGoodsCategory]); + + const filteredGoods = useMemo(() => { + if (!selectedGoodsCategory) return sortedGoods; + return sortedGoods.filter((item) => goodsCategoryKey(item) === selectedGoodsCategory); + }, [sortedGoods, selectedGoodsCategory]); const createPayload = (taskType: string) => { if (taskType !== 'recharge_points') return {}; @@ -238,13 +309,22 @@ export default function HuyaTasksPage() { concurrency, payload: createPayload(taskType), }); + const finishTask = () => { + setBatchId(null); + setStarting(false); + if (taskType === 'refresh_goods') { + void loadAll(); + } else { + void loadTasks(); + } + }; setBatchId(result.batch_id); message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`); await loadTasks(); connectLogs(`/api/huya/ws/${result.batch_id}`, { - onClose: () => { setBatchId(null); setStarting(false); loadTasks(); }, - onResult: () => { setBatchId(null); setStarting(false); loadTasks(); }, - onError: () => { setBatchId(null); setStarting(false); loadTasks(); }, + onClose: finishTask, + onResult: finishTask, + onError: finishTask, }); } catch (e: unknown) { message.error(getErrorMessage(e)); @@ -315,6 +395,11 @@ export default function HuyaTasksPage() { if (value?.is_bound === false) return 未绑定; return -; } + if (record.task_type === 'refresh_goods') { + const goodsCount = value?.goods_count; + if (typeof goodsCount === 'number') return 商品 {goodsCount} 个; + return -; + } const availableScore = value?.available_score; if (typeof availableScore === 'number') { @@ -365,6 +450,14 @@ export default function HuyaTasksPage() { const goodsColumns: TableProps['columns'] = [ { title: '商品ID', dataIndex: 'product_id', width: 120, ellipsis: true }, { title: '名称', dataIndex: 'name', ellipsis: true }, + { + title: '分类', + width: 110, + render: (_: unknown, record) => { + const label = goodsCategoryLabel(record); + return label ? {label} : -; + }, + }, { title: '价格', dataIndex: 'price', @@ -376,7 +469,7 @@ export default function HuyaTasksPage() { title: '库存', dataIndex: 'remain_text', width: 100, - render: (value: string) => value || -, + render: (value: string) => formatRemainText(value) || -, }, { title: '更新时间', @@ -398,7 +491,7 @@ export default function HuyaTasksPage() { -
+
+ {goodsCategories.length > 0 && ( + ({ + key: item.key, + label: `${item.label} (${item.count})`, + })) + } + /> + )} @@ -534,7 +640,7 @@ export default function HuyaTasksPage() {
{QUICK_ACTIONS.map((item) => ( - +