feat(douyu): 新增和平小店查询模块(绑定角色/商品列表/点券余额)

- 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 三态/商品下拉/配置弹窗
This commit is contained in:
yml2213
2026-08-06 17:23:59 +08:00
parent f8de495029
commit fdc74eba8c
12 changed files with 702 additions and 25 deletions
+230 -1
View File
@@ -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}
@@ -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)
+25
View File
@@ -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"
+28 -1
View File
@@ -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,
+44
View File
@@ -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
+163 -1
View File
@@ -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", "不支持的任务类型")
+7 -1
View File
@@ -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]
+1
View File
@@ -75,6 +75,7 @@ function AppContent() {
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage handbook="peace" />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+2
View File
@@ -6,6 +6,7 @@ import type {
DouyuTaskBatchRequest,
DouyuTaskBatchResult,
DouyuTaskItem,
DouyuXpdGoodsItem,
MessageResponse,
PageParams,
PaginatedResponse,
@@ -21,6 +22,7 @@ export const douyuApi = {
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
listEsportsGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/esports-goods'),
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
+17
View File
@@ -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 {
+2 -1
View File
@@ -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: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
}
// 虎牙
+92 -20
View File
@@ -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<string, string> = {
planned: 'default',
@@ -83,6 +83,12 @@ const ESPORTS_QUICK_ACTIONS = [
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> },
];
const PEACE_QUICK_ACTIONS = [
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
];
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<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
@@ -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
? <Text ellipsis title={record.xpd_game_name}>{record.xpd_game_name}</Text>
: <Text type="secondary"></Text>;
}
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 ?? <Text type="secondary">-</Text>,
},
@@ -1201,6 +1235,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => {
if (isPeaceHandbook) return <Text type="secondary">-</Text>;
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 type="secondary">{text || '-'}</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 ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('query_xpd_role', 'primary')}
{renderActionButton('query_xpd_balance', 'primary')}
{renderActionButton('refresh_xpd_goods')}
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: xpdGoodsLabel(item) }))}
placeholder="选择小店商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
</Space>
</div>
</div>
) : isEsportsHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
@@ -1690,7 +1758,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</Card>
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }}
@@ -1702,7 +1770,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<>
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }}
@@ -1788,12 +1856,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{ title: '标签', dataIndex: 'tag', width: 110, render: (v: string) => (v ? <Tag color="blue">{v}</Tag> : '-') },
{
title: '游戏名',
dataIndex: isEsportsHandbook ? 'esports_game_name' : 'game_name',
dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'),
render: (v) => v || '-',
},
{
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
render: (v) => v ?? '-',
},
]}
@@ -1804,7 +1872,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{/* Config modal */}
<Modal
title={isEsportsHandbook ? '电竞手册配置' : '精英宝典配置'}
title={isPeaceHandbook ? '和平小店配置' : (isEsportsHandbook ? '电竞手册配置' : '精英宝典配置')}
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)}
@@ -1813,7 +1881,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
>
{configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{(isEsportsHandbook ? [
{(isPeaceHandbook ? [
{ label: '小店活动代号', key: 'xpd_act_alias' },
{ label: '道聚城活动 ID', key: 'xpd_act_id' },
{ label: '房间 ID', key: 'xpd_rid' },
] : isEsportsHandbook ? [
{ label: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' },
@@ -1840,7 +1912,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/>
</Space>
))}
{(isEsportsHandbook ? [
{(isPeaceHandbook ? [] : isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' },
] : [
{ label: '宝典金额(分)', key: 'elite_amount' },