From fdc74eba8c1008f350c02537811406d3d74e97c9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 6 Aug 2026 17:23:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E6=96=B0=E5=A2=9E=E5=92=8C?= =?UTF-8?q?=E5=B9=B3=E5=B0=8F=E5=BA=97=E6=9F=A5=E8=AF=A2=E6=A8=A1=E5=9D=97?= =?UTF-8?q?(=E7=BB=91=E5=AE=9A=E8=A7=92=E8=89=B2/=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=88=97=E8=A1=A8/=E7=82=B9=E5=88=B8=E4=BD=99=E9=A2=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - activity_client: getIframeUrl 签发 code/sig,道聚城 getrole.out/recommend/balance 接口 (2026-08 起需 isCode=1 + authType=delegate + sAnchorId 才能通过 Livelink 校验) - 迁移 0016: accounts xpd_* 字段、douyu_config 小店配置(actAlias/actId/rid)、 douyu_xpd_goods_snapshot 商品快照表 - runner/service/router/schema 注册 query_xpd_role/refresh_xpd_goods/query_xpd_balance 三个任务,任务结果剥离 role.raw 防 openid 泄露 - 前端: 和平小店路由/菜单/DouyuTasksPage 三态/商品下拉/配置弹窗 --- core/douyu/activity_client.py | 231 +++++++++++++++++- .../20260806_0016_douyu_peace_shop.py | 91 +++++++ web/backend/models.py | 25 ++ web/backend/routers/douyu.py | 29 ++- web/backend/schemas.py | 44 ++++ web/backend/services/douyu_runner.py | 164 ++++++++++++- web/backend/services/douyu_service.py | 8 +- web/frontend/src/App.tsx | 1 + web/frontend/src/api/douyu.ts | 2 + web/frontend/src/api/types.ts | 17 ++ web/frontend/src/layouts/MainLayout.tsx | 3 +- web/frontend/src/pages/DouyuTasksPage.tsx | 112 +++++++-- 12 files changed, 702 insertions(+), 25 deletions(-) create mode 100644 web/backend/migrations/versions/20260806_0016_douyu_peace_shop.py diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index c020371..d1643b2 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -4,10 +4,12 @@ from __future__ import annotations import json import random +import re import string +import time from collections.abc import Callable from typing import Any -from urllib.parse import unquote +from urllib.parse import parse_qs, unquote, urlsplit import requests @@ -813,3 +815,230 @@ class DouyuActivityClient: ) data = payload.get("data") or {} return {"count": data.get("count"), "raw": payload} + + # ---- 和平小店(腾讯道聚城 H5)---- + XPD_EMBED_API = "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl" + XPD_H5_REFERER = "https://www.douyu.com/topic/h5/hpxd01" + XPD_DAOJU_REFERER = "https://app.daoju.qq.com/" + XPD_GET_ROLE_API = "https://apps.game.qq.com/daoju/igw/live" + # 2026-08 抓包(8.6-和平小店)实证:本期走 xn_live_cjm 变体(旧期为 recommend_live/common) + XPD_RECOMMEND_API = "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common" + XPD_BALANCE_API = "https://apps.game.qq.com/daoju/igw/live/" + XPD_UA = ( + "Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + "Chrome/101.0.4951.61 Mobile Safari/537.36, Douyu_Android" + ) + + @staticmethod + def _xpd_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _xpd_parse_var(text: str, varname: str) -> dict[str, Any]: + """解析道聚城 `var xxx={...};` 形式的响应(容忍等号两侧空白)。""" + match = re.search(rf"var\s+{varname}\s*=", text) + if not match: + raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}") + chunk = text[match.end():].strip() + if chunk.endswith(";"): + chunk = chunk[:-1] + try: + return json.loads(chunk) + except json.JSONDecodeError as exc: + raise DouyuActivityError(f"var {varname}= 解析失败: {chunk[:200]}") from exc + + @classmethod + def _xpd_daoju_headers(cls) -> dict[str, str]: + return { + "User-Agent": cls.XPD_UA, + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9", + "X-Requested-With": "air.tv.douyu.android", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Dest": "script", + "Referer": cls.XPD_DAOJU_REFERER, + } + + def _xpd_token(self) -> str: + """拼斗鱼 token: acf_uid_1_acf_stk_0_acf_ltkid。""" + uid = cookie_value(self.cookie, "acf_uid") + stk = cookie_value(self.cookie, "acf_stk") + ltkid = cookie_value(self.cookie, "acf_ltkid") + if not uid or not stk or not ltkid: + raise DouyuActivityError("Cookie 缺少 acf_uid/acf_stk/acf_ltkid,无法获取小店参数") + return f"{uid}_1_{stk}_0_{ltkid}" + + def xpd_embed_query(self, *, act_alias: str, rid: str) -> dict[str, Any]: + """从斗鱼换取和平小店 H5 参数(code/sig/timestamp/actId 等,每次现签)。""" + payload = self._request_json( + "get", + self.XPD_EMBED_API, + "获取小店 H5 参数", + params={"actAlias": act_alias, "rid": rid, "token": self._xpd_token()}, + headers={"Referer": self.XPD_H5_REFERER}, + ) + if payload.get("error") not in (0, "0", None): + raise DouyuActivityError(payload.get("msg") or "获取小店 H5 参数失败") + data = payload.get("data") or {} + txurl_h5 = str(data.get("txurlH5") or "") + if not txurl_h5: + raise DouyuActivityError(f"获取小店 H5 参数失败: {payload}") + query = parse_qs(urlsplit(txurl_h5).query) + embed_query = {key: values[0] for key, values in query.items() if values} + return {"query": embed_query, "txurl_h5": txurl_h5, "raw": payload} + + def xpd_get_role( + self, + *, + embed_query: dict[str, str], + act_id: str, + rid: str = "", + ) -> dict[str, Any]: + """用小店 code/sig 换取当前绑定角色信息。 + + 2026-08 起道聚城校验升级:必须带 isCode=1 + authType=delegate + sAnchorId(房间号)。 + """ + params = { + "acctype": "livelink", + "_jsvar": "info", + "_service": "other.livelink.getrole.out", + "_biz_code": "cjm", + "_act_id": act_id, + "_app_id": "2123", + "isCode": "1", + "gameId": embed_query.get("gameId") or "cjm", + "actId": embed_query.get("actId") or "", + "appId": embed_query.get("appId") or "bp_cf", + "livePlatId": embed_query.get("livePlatId") or "douyu", + "code": embed_query.get("code") or "", + "timestamp": embed_query.get("timestamp") or "", + "v": embed_query.get("v") or "", + "sig": embed_query.get("sig") or "", + "authType": "delegate", + "sAnchorId": rid, + "sVideoId": "", + } + response = self._request( + "get", + self.XPD_GET_ROLE_API, + source="查询小店绑定角色", + params=params, + headers=self._xpd_daoju_headers(), + ) + info = self._xpd_parse_var(response.text, "info") + return { + "game_open_id": str(info.get("gameOpenId") or ""), + "role_id": str(info.get("roleId") or ""), + "role_name": str(info.get("roleName") or ""), + "type": str(info.get("type") or ""), + "plat_id": str(info.get("platId") or ""), + "area": str(info.get("area") or ""), + "raw": info, + } + + def xpd_list_goods( + self, + *, + embed_query: dict[str, str], + act_id: str, + openid: str, + roleid: str, + areaid: str = "1", + ) -> dict[str, Any]: + """查询和平小店推荐商品(category=76 全部商品)。""" + params = { + "_service": "act.recommend.query", + "_biz_code": "cjm", + "_app_id": "2123", + "app_id": "2123", + "page_begin": "0", + "page_num": "1", + "page_size": "200", + "fields": ( + "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime," + "dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price," + "iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime," + "dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd" + ), + "actionFields": "iActionId,sActionName,dtBeginTime,dtEndTime", + "order_by": "dtShowBeginTime", + "orderColumnBatch": '[{"key":"iSort","sort":"desc"},{"key":"dtShowBeginTime","sort":"desc"},{"key":"iGoodsId","sort":"asc"}]', + "desc": "1", + "_act_id": act_id, + "actid": act_id, + "category": "76", + "userid": openid, + "roleid": roleid, + "area": "1", + "areaid": str(areaid), + "need_limit": "1", + } + response = self._request( + "get", + self.XPD_RECOMMEND_API, + source="刷新小店商品", + params=params, + headers=self._xpd_daoju_headers(), + ) + recommend = self._xpd_parse_var(response.text, "recommend") + items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or [] + goods = [] + for item in items: + goods.append( + { + "commodity_id": str(item.get("iGoodsId") or ""), + "name": str(item.get("sGoodsName") or ""), + "price": self._xpd_int(item.get("iPrice")), + "org_price": self._xpd_int(item.get("iOrgPrice")), + "category": str(item.get("iCategoryId") or ""), + "goods_left": self._xpd_int(item.get("iGoodsLeft")), + "raw": item, + } + ) + return {"goods": goods, "raw": recommend} + + def xpd_balance( + self, + *, + embed_query: dict[str, str], + act_id: str, + openid: str, + roleid: str, + plat: str, + areaid: str = "1", + ) -> dict[str, Any]: + """查询和平小店角色点券余额。""" + params = { + "_jsvar": "banlanceInfo", + "_service": "pay.midas.dq.get.ttpp", + "_app_id": "2123", + "acctype": "ttpp", + "areaid": str(areaid), + "eventid": "", + "interwork": "0", + "openid": openid, + "openkey": "openkey", + "partition": "0", + "pay_token": "", + "plat": str(plat), + "plat_pc": "0", + "roleid": roleid, + "_biz_code": "cjm", + "_act_id": act_id, + "_time": str(int(time.time())), + "_sid": "6", + } + response = self._request( + "get", + self.XPD_BALANCE_API, + source="查询小店点券余额", + params=params, + headers=self._xpd_daoju_headers(), + ) + info = self._xpd_parse_var(response.text, "banlanceInfo") + return {"balance": self._xpd_int(info.get("balance")), "raw": info} diff --git a/web/backend/migrations/versions/20260806_0016_douyu_peace_shop.py b/web/backend/migrations/versions/20260806_0016_douyu_peace_shop.py new file mode 100644 index 0000000..f90b377 --- /dev/null +++ b/web/backend/migrations/versions/20260806_0016_douyu_peace_shop.py @@ -0,0 +1,91 @@ +"""新增斗鱼和平小店配置、账号字段与商品快照 + +Revision ID: 20260806_0016 +Revises: 20260805_0015 +Create Date: 2026-08-06 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260806_0016" +down_revision: Union[str, None] = "20260805_0015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(bind, table_name: str) -> set[str]: + if not sa.inspect(bind).has_table(table_name): + return set() + return {column["name"] for column in sa.inspect(bind).get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + config_columns = _columns(bind, "douyu_config") + for column in [ + sa.Column("xpd_act_alias", sa.String(length=64), nullable=True), + sa.Column("xpd_act_id", sa.String(length=64), nullable=True), + sa.Column("xpd_rid", sa.String(length=64), nullable=True), + ]: + if column.name not in config_columns: + op.add_column("douyu_config", column) + + account_columns = _columns(bind, "accounts") + for column in [ + sa.Column("xpd_game_name", sa.String(length=128), nullable=True), + sa.Column("xpd_openid", sa.String(length=128), nullable=True), + sa.Column("xpd_role_id", sa.String(length=64), nullable=True), + sa.Column("xpd_plat_id", sa.Integer(), nullable=True), + sa.Column("xpd_area_id", sa.Integer(), nullable=True), + sa.Column("xpd_balance", sa.Integer(), nullable=True), + sa.Column("xpd_bind_status", sa.String(length=32), nullable=True), + ]: + if column.name not in account_columns: + op.add_column("accounts", column) + + if not sa.inspect(bind).has_table("douyu_xpd_goods_snapshot"): + op.create_table( + "douyu_xpd_goods_snapshot", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("commodity_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=256), nullable=True), + sa.Column("price", sa.Integer(), nullable=True), + sa.Column("org_price", sa.Integer(), nullable=True), + sa.Column("category", sa.String(length=32), nullable=True), + sa.Column("goods_left", sa.Integer(), nullable=True), + sa.Column("raw", sa.JSON(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + ) + op.create_index( + "ix_douyu_xpd_goods_snapshot_commodity_id", + "douyu_xpd_goods_snapshot", + ["commodity_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + if sa.inspect(bind).has_table("douyu_xpd_goods_snapshot"): + op.drop_table("douyu_xpd_goods_snapshot") + + account_columns = _columns(bind, "accounts") + for column_name in [ + "xpd_bind_status", + "xpd_balance", + "xpd_area_id", + "xpd_plat_id", + "xpd_role_id", + "xpd_openid", + "xpd_game_name", + ]: + if column_name in account_columns: + op.drop_column("accounts", column_name) + + config_columns = _columns(bind, "douyu_config") + for column_name in ["xpd_rid", "xpd_act_id", "xpd_act_alias"]: + if column_name in config_columns: + op.drop_column("douyu_config", column_name) diff --git a/web/backend/models.py b/web/backend/models.py index 7c4ebce..13140e1 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -70,6 +70,13 @@ class Account(Base): esports_bind_status = Column(String(32), default="") esports_change_role_wait_time = Column(Integer, nullable=True) esports_can_change_time = Column(Integer, nullable=True) + xpd_game_name = Column(String(128), default="") + xpd_openid = Column(String(128), default="") + xpd_role_id = Column(String(64), default="") + xpd_plat_id = Column(Integer, nullable=True) + xpd_area_id = Column(Integer, nullable=True) + xpd_balance = Column(Integer, nullable=True) + xpd_bind_status = Column(String(32), default="") created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) @@ -132,6 +139,9 @@ class DouyuConfig(Base): esports_chicken_skin_id = Column(String(64), default="0") esports_firework_gift_id = Column(String(64), default="24767") esports_firework_skin_id = Column(String(64), default="3850") + xpd_act_alias = Column(String(64), default="20260623KDQFH") + xpd_act_id = Column(String(64), default="46195") + xpd_rid = Column(String(64), default="9263298") gold_pay_type = Column(Integer, default=1) gift_id = Column(String(64), default="23643") skin_id = Column(String(64), default="2942") @@ -151,6 +161,21 @@ class DouyuGoodsSnapshot(Base): updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) +class DouyuXpdGoodsSnapshot(Base): + """斗鱼和平小店商品快照""" + __tablename__ = "douyu_xpd_goods_snapshot" + + id = Column(Integer, primary_key=True, autoincrement=True) + commodity_id = Column(String(64), nullable=False, index=True) + name = Column(String(256), default="") + price = Column(Integer, nullable=True) + org_price = Column(Integer, nullable=True) + category = Column(String(32), default="") + goods_left = Column(Integer, nullable=True) + raw = Column(JSON, nullable=True) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + class DouyuEsportsGoodsSnapshot(Base): """斗鱼电竞手册皮肤商城商品快照""" __tablename__ = "douyu_esports_goods_snapshot" diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 06fe15b..44e0a95 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, joinedload from ..database import SessionLocal, get_db from ..deps import authenticate_websocket, get_current_user, require_permission -from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, User +from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot, User from ..permissions import user_has_permission from ..schemas import ( DouyuConfigOut, @@ -21,6 +21,7 @@ from ..schemas import ( DouyuTaskAccountOut, DouyuTaskBatchRequest, DouyuTaskOut, + DouyuXpdGoodsOut, ) from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry from ..services.douyu_service import ( @@ -113,6 +114,13 @@ def _account_out(account: Account) -> DouyuTaskAccountOut: esports_bind_status=account.esports_bind_status or "", esports_change_role_wait_time=account.esports_change_role_wait_time, esports_can_change_time=account.esports_can_change_time, + xpd_game_name=account.xpd_game_name or "", + xpd_openid=account.xpd_openid or "", + xpd_role_id=account.xpd_role_id or "", + xpd_plat_id=account.xpd_plat_id, + xpd_area_id=account.xpd_area_id, + xpd_balance=account.xpd_balance, + xpd_bind_status=account.xpd_bind_status or "", assigned_to=account.assigned_to, assigned_username=account.assigned_user.username if account.assigned_user else None, ) @@ -168,6 +176,11 @@ def _sanitize_task_result(result: dict | None, task_type: str, *, include_detail ): data.pop(key, None) + # 和平小店任务 result 内嵌 role(含 gameOpenId 等),列表接口剥离其原始快照 + role = data.get("role") + if isinstance(role, dict): + data["role"] = {key: value for key, value in role.items() if key != "raw"} + goods = data.get("goods") if isinstance(goods, list): data.pop("goods", None) @@ -223,6 +236,9 @@ def _config_out(config: DouyuConfig) -> DouyuConfigOut: esports_chicken_skin_id=douyu_config_value("esports_chicken_skin_id", config.esports_chicken_skin_id), esports_firework_gift_id=douyu_config_value("esports_firework_gift_id", config.esports_firework_gift_id), esports_firework_skin_id=douyu_config_value("esports_firework_skin_id", config.esports_firework_skin_id), + xpd_act_alias=douyu_config_value("xpd_act_alias", config.xpd_act_alias), + xpd_act_id=douyu_config_value("xpd_act_id", config.xpd_act_id), + xpd_rid=douyu_config_value("xpd_rid", config.xpd_rid), gold_pay_type=douyu_config_value("gold_pay_type", config.gold_pay_type), gift_id=douyu_config_value("gift_id", config.gift_id), skin_id=douyu_config_value("skin_id", config.skin_id), @@ -267,6 +283,7 @@ def list_task_accounts( Account.tag.ilike(pattern), Account.game_name.ilike(pattern), Account.esports_game_name.ilike(pattern), + Account.xpd_game_name.ilike(pattern), )) total = None if page is not None: @@ -331,6 +348,16 @@ def list_esports_goods( return rows +@router.get("/xpd-goods", response_model=list[DouyuXpdGoodsOut]) +def list_xpd_goods( + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:task")), +): + """查看已缓存的和平小店商品。""" + rows = db.query(DouyuXpdGoodsSnapshot).order_by(DouyuXpdGoodsSnapshot.id.asc()).all() + return rows + + @router.post("/tasks/batch") async def create_task_batch( req: DouyuTaskBatchRequest, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 41b1154..c8f741e 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -554,6 +554,9 @@ class DouyuConfigOut(BaseModel): esports_chicken_skin_id: str = "0" esports_firework_gift_id: str = "24767" esports_firework_skin_id: str = "3850" + xpd_act_alias: str = "20260623KDQFH" + xpd_act_id: str = "46195" + xpd_rid: str = "9263298" gold_pay_type: int = 1 gift_id: str = "23643" skin_id: str = "2942" @@ -576,6 +579,9 @@ class DouyuConfigOut(BaseModel): "esports_chicken_skin_id": self.esports_chicken_skin_id, "esports_firework_gift_id": self.esports_firework_gift_id, "esports_firework_skin_id": self.esports_firework_skin_id, + "xpd_act_alias": self.xpd_act_alias, + "xpd_act_id": self.xpd_act_id, + "xpd_rid": self.xpd_rid, "gold_pay_type": self.gold_pay_type, "gift_id": self.gift_id, "skin_id": self.skin_id, @@ -598,6 +604,9 @@ class DouyuConfigUpdate(BaseModel): esports_chicken_skin_id: Optional[str] = None esports_firework_gift_id: Optional[str] = None esports_firework_skin_id: Optional[str] = None + xpd_act_alias: Optional[str] = None + xpd_act_id: Optional[str] = None + xpd_rid: Optional[str] = None gold_pay_type: Optional[int] = Field(None, ge=1, le=9) gift_id: Optional[str] = None skin_id: Optional[str] = None @@ -689,10 +698,45 @@ class DouyuTaskAccountOut(BaseModel): esports_bind_status: str = "" esports_change_role_wait_time: Optional[int] = None esports_can_change_time: Optional[int] = None + xpd_game_name: str = "" + xpd_openid: str = "" + xpd_role_id: str = "" + xpd_plat_id: Optional[int] = None + xpd_area_id: Optional[int] = None + xpd_balance: Optional[int] = None + xpd_bind_status: str = "" assigned_to: Optional[int] = None assigned_username: Optional[str] = None +class DouyuXpdGoodsOut(BaseModel): + id: int + commodity_id: str + name: str = "" + price: Optional[int] = None + org_price: Optional[int] = None + category: str = "" + goods_left: Optional[int] = None + raw: Optional[dict[str, Any]] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "id": self.id, + "commodity_id": self.commodity_id, + "name": self.name, + "price": self.price, + "org_price": self.org_price, + "category": self.category, + "goods_left": self.goods_left, + "raw": self.raw, + "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + } + + # ---- 代理配置 ---- class ProxyConfigOut(BaseModel): enabled: bool = False diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index ef9a462..b3a58fb 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session, joinedload from core.douyu import DouyuActivityClient, DouyuActivityError from ..database import SessionLocal -from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask +from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot from .douyu_service import ( DOUYU_CONFIG_FIELDS, account_uid, @@ -415,6 +415,30 @@ class DouyuBatchRunner: row.updated_at = now db.commit() + def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None: + """写入和平小店商品快照。""" + now = datetime.now(timezone.utc) + for raw in goods: + commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "") + if not commodity_id: + continue + row = ( + db.query(DouyuXpdGoodsSnapshot) + .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if row is None: + row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id) + db.add(row) + row.name = str(raw.get("name") or raw.get("sGoodsName") or "") + row.price = self._to_int(raw.get("price") or raw.get("iPrice")) + row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice")) + row.category = str(raw.get("category") or raw.get("iCategoryId") or "") + row.goods_left = self._to_int(raw.get("goods_left") or raw.get("iGoodsLeft")) + row.raw = raw + row.updated_at = now + db.commit() + def _config_info(self, db: Session) -> dict: config = ensure_douyu_config(db) return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} @@ -993,6 +1017,141 @@ class DouyuBatchRunner: {"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods}, ) + def _xpd_role_context(self, client: DouyuActivityClient, config: dict) -> dict: + """获取小店 H5 参数 + 绑定角色信息,小店任务共用。""" + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + role = client.xpd_get_role( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + ) + return {"embed": embed, "role": role} + + def _xpd_area_id(self, role: dict, account: Account) -> int: + """角色大区: 微信=1, 手Q=2, 未知回退账号已存值或 1。""" + role_type = str(role.get("type") or "") + if role_type == "wx": + return 1 + if role_type == "qq": + return 2 + return account.xpd_area_id or 1 + + def _apply_xpd_role_to_account(self, account: Account, role: dict, area_id: int) -> None: + account.xpd_game_name = str(role.get("role_name") or "") or account.xpd_game_name + account.xpd_openid = str(role.get("game_open_id") or "") or account.xpd_openid + account.xpd_role_id = str(role.get("role_id") or "") or account.xpd_role_id + account.xpd_plat_id = self._to_int(role.get("plat_id")) + account.xpd_area_id = area_id + account.updated_at = datetime.now(timezone.utc) + + def _execute_query_xpd_role( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店绑定角色。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_bind_status = "xpd_bound" + db.commit() + role_text = str(role.get("role_name") or "-") + channel = "微信" if role.get("type") == "wx" else ("手Q" if role.get("type") == "qq" else str(role.get("type") or "-")) + self._mark_task( + db, + task, + "success", + f"小店角色: {role_text}({channel})", + {"role": role, "area_id": area_id}, + ) + + def _execute_refresh_xpd_goods( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """刷新和平小店商品列表快照(全局数据,任一可用 CK 即可)。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + result = client.xpd_list_goods( + embed_query=ctx["embed"]["query"], + act_id=str(config["xpd_act_id"]), + openid=str(role.get("game_open_id") or ""), + roleid=str(role.get("role_id") or ""), + areaid=str(area_id), + ) + goods = result["goods"] + self._upsert_xpd_goods(db, goods) + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_bind_status = "xpd_goods_refreshed" + db.commit() + self._mark_task( + db, + task, + "success", + f"已刷新小店商品 {len(goods)} 个", + {"goods_count": len(goods), "goods": goods}, + ) + + def _execute_query_xpd_balance( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店点券余额。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + result = client.xpd_balance( + embed_query=ctx["embed"]["query"], + act_id=str(config["xpd_act_id"]), + openid=str(role.get("game_open_id") or ""), + roleid=str(role.get("role_id") or ""), + plat=str(role.get("plat_id") or "1"), + areaid=str(area_id), + ) + balance = result.get("balance") + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_balance = balance + account.xpd_bind_status = "xpd_balance_queried" + db.commit() + if balance is None: + self._mark_task(db, task, "failed", "未获取到小店点券余额") + return + self._mark_task( + db, + task, + "success", + f"小店点券余额: {balance}", + {"balance": balance, "role": role, "area_id": area_id}, + ) + def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) qr_act_alias = self._bind_qr_act_alias(config) @@ -2284,6 +2443,9 @@ class DouyuBatchRunner: "query_gold_balance": self._execute_query_gold_balance, "query_exchange_records": self._execute_query_exchange_records, "prefetch_csrf_token": self._execute_prefetch_csrf_token, + "query_xpd_role": self._execute_query_xpd_role, + "refresh_xpd_goods": self._execute_refresh_xpd_goods, + "query_xpd_balance": self._execute_query_xpd_balance, }.get(task.task_type) if handler is None: self._mark_task(worker_db, task, "failed", "不支持的任务类型") diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 372c278..9b4212d 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -38,6 +38,9 @@ SUPPORTED_DOUYU_TASK_TYPES = { "refresh_goods": "刷新商品列表", "query_exchange_records": "一键查询兑换记录", "prefetch_csrf_token": "一键获取兑换 CSRF Token", + "query_xpd_role": "查询小店绑定角色", + "refresh_xpd_goods": "刷新小店商品列表", + "query_xpd_balance": "查询小店点券余额", } @@ -59,6 +62,9 @@ DOUYU_CONFIG_DEFAULTS = { "gold_pay_type": 1, "gift_id": "23643", "skin_id": "2942", + "xpd_act_alias": "20260623KDQFH", + "xpd_act_id": "46195", + "xpd_rid": "9263298", } DOUYU_CONFIG_FIELDS = tuple(DOUYU_CONFIG_DEFAULTS.keys()) @@ -169,7 +175,7 @@ def create_douyu_planned_tasks( raise ValueError("不支持的任务类型") accounts = visible_douyu_task_accounts(db, account_ids) - if task_type in {"refresh_goods", "refresh_esports_goods"} and accounts: + if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts: # 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。 accounts = accounts[:1] diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 092fe12..9de8016 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -75,6 +75,7 @@ function AppContent() { } /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/douyu.ts b/web/frontend/src/api/douyu.ts index 57182dd..0d92c06 100644 --- a/web/frontend/src/api/douyu.ts +++ b/web/frontend/src/api/douyu.ts @@ -6,6 +6,7 @@ import type { DouyuTaskBatchRequest, DouyuTaskBatchResult, DouyuTaskItem, + DouyuXpdGoodsItem, MessageResponse, PageParams, PaginatedResponse, @@ -21,6 +22,7 @@ export const douyuApi = { updateConfig: (data: Partial) => api.put('/douyu/config', data), listGoods: () => api.get('/douyu/goods'), listEsportsGoods: () => api.get('/douyu/esports-goods'), + listPeaceGoods: () => api.get('/douyu/xpd-goods'), createTasks: (data: DouyuTaskBatchRequest) => api.post('/douyu/tasks/batch', data), listTasks: (batchId?: string) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 7798110..d54103c 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -227,6 +227,13 @@ export interface DouyuTaskAccountItem { esports_bind_status: string; esports_change_role_wait_time: number | null; esports_can_change_time: number | null; + xpd_game_name: string; + xpd_openid: string; + xpd_role_id: string; + xpd_plat_id: number | null; + xpd_area_id: number | null; + xpd_balance: number | null; + xpd_bind_status: string; assigned_to: number | null; assigned_username: string | null; } @@ -246,6 +253,9 @@ export interface DouyuConfig { esports_chicken_skin_id: string; esports_firework_gift_id: string; esports_firework_skin_id: string; + xpd_act_alias: string; + xpd_act_id: string; + xpd_rid: string; gold_pay_type: number; gift_id: string; skin_id: string; @@ -291,6 +301,13 @@ export interface DouyuGoodsItem { updated_at: string | null; } +export interface DouyuXpdGoodsItem extends DouyuGoodsItem { + price: number | null; + org_price: number | null; + category: string; + goods_left: number | null; +} + // ==================== Huya ==================== export interface HuyaAccountItem { diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 17537d0..3115bbb 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -6,7 +6,7 @@ import { CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined, - BookOutlined, TrophyOutlined, + BookOutlined, TrophyOutlined, ShopOutlined, SafetyCertificateOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; @@ -74,6 +74,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { if (can('douyu:task')) { douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: }); douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: }); + douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: }); } // 虎牙 diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 1e5287b..978c0c0 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -32,7 +32,7 @@ import { message } from '../utils/antdMessage'; const { Text } = Typography; -type HandbookKind = 'elite' | 'esports'; +type HandbookKind = 'elite' | 'esports' | 'peace'; const TASK_STATUS_COLORS: Record = { planned: 'default', @@ -83,6 +83,12 @@ const ESPORTS_QUICK_ACTIONS = [ { key: 'donate_esports_firework_gift', icon: }, ]; +const PEACE_QUICK_ACTIONS = [ + { key: 'query_xpd_role', icon: }, + { key: 'refresh_xpd_goods', icon: }, + { key: 'query_xpd_balance', icon: }, +]; + const ELITE_TASK_TYPES = new Set([ 'get_bind_qr', 'confirm_bind', @@ -115,6 +121,11 @@ const ESPORTS_TASK_TYPES = new Set([ 'donate_esports_chicken_gift', 'donate_esports_firework_gift', ]); +const PEACE_TASK_TYPES = new Set([ + 'query_xpd_role', + 'refresh_xpd_goods', + 'query_xpd_balance', +]); const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode'; @@ -201,6 +212,13 @@ function goodsLabel(item: DouyuGoodsItem): string { return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`; } +function xpdGoodsLabel(item: DouyuGoodsItem): string { + const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null }; + const price = xpd.price != null ? ` / ${xpd.price}点券` : ''; + const left = xpd.goods_left != null ? ` / 剩${xpd.goods_left}` : ''; + return `${item.name || item.commodity_id}${price}${left}`; +} + function formatWaitSeconds(seconds: number | null | undefined): string { if (seconds == null || Number.isNaN(Number(seconds))) return ''; const total = Math.max(0, Math.floor(Number(seconds))); @@ -265,13 +283,22 @@ function savedPositiveInteger(key: string, fallback = 1): number { } export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) { + const isPeaceHandbook = handbook === 'peace'; const isEsportsHandbook = handbook === 'esports'; - const handbookTitle = isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台'; - const handbookDescription = isEsportsHandbook - ? '电竞手册角色绑定与开通任务' - : '精英宝典绑定、开通、积分与兑换任务'; - const activeTaskTypes = isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES; - const quickActions = isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS; + const handbookTitle = isPeaceHandbook + ? '和平小店工作台' + : (isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台'); + const handbookDescription = isPeaceHandbook + ? '和平小店绑定角色、商品与点券余额查询' + : (isEsportsHandbook + ? '电竞手册角色绑定与开通任务' + : '精英宝典绑定、开通、积分与兑换任务'); + const activeTaskTypes = isPeaceHandbook + ? PEACE_TASK_TYPES + : (isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES); + const quickActions = isPeaceHandbook + ? PEACE_QUICK_ACTIONS + : (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS); const { can } = usePermissions(); const [accounts, setAccounts] = useState([]); // 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除) @@ -462,7 +489,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) try { const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([ workbenchIds.length > 0 ? douyuApi.listAccounts({ ids: workbenchIds.join(',') }) : Promise.resolve([]), - isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods(), + isPeaceHandbook + ? douyuApi.listPeaceGoods() + : (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()), douyuApi.listTasks(), canConfig ? douyuApi.getConfig() : Promise.resolve(null), douyuApi.taskTypes(), @@ -508,7 +537,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } finally { setLoading(false); } - }, [canConfig, isEsportsHandbook, workbenchIds]); + }, [canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]); const loadTasks = useCallback(async () => { if (tasksLoadingRef.current) return; @@ -1104,6 +1133,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) { title: '游戏名', dataIndex: 'game_name', width: 170, render: (_, record) => { + if (isPeaceHandbook) { + return record.xpd_game_name + ? {record.xpd_game_name} + : 未查; + } const queryTask = isEsportsHandbook ? latestEsportsStateTaskByAccount.get(record.id) || null : latestQueryGameTaskByAccount.get(record.id) || null; @@ -1175,8 +1209,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, }, { - title: isEsportsHandbook ? '电竞积分' : '积分', - dataIndex: isEsportsHandbook ? 'esports_points' : 'points', + title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'), + dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'), width: 80, render: (v) => v ?? -, }, @@ -1201,6 +1235,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) { title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130, render: (_, record) => { + if (isPeaceHandbook) return -; const fromTask = latestChangeWaitByAccount.get(record.id); const canChangeTime = fromTask?.canChangeTime ?? ( isEsportsHandbook ? record.esports_can_change_time : null @@ -1239,6 +1274,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return {text || '-'}; }, sorter: (a, b) => { + if (isPeaceHandbook) return 0; const aw = isEsportsHandbook ? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1 : latestChangeWaitByAccount.get(a.id)?.wait @@ -1398,7 +1434,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) ); const operationBody = ( <> - {isEsportsHandbook ? ( + {isPeaceHandbook ? ( +
+
+
+ + 小店查询 +
+ + {renderActionButton('query_xpd_role', 'primary')} + {renderActionButton('query_xpd_balance', 'primary')} + {renderActionButton('refresh_xpd_goods')} + +
+
+
+ + 小店商品 +
+ +