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 json
import random import random
import re
import string import string
import time
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import unquote from urllib.parse import parse_qs, unquote, urlsplit
import requests import requests
@@ -813,3 +815,230 @@ class DouyuActivityClient:
) )
data = payload.get("data") or {} data = payload.get("data") or {}
return {"count": data.get("count"), "raw": payload} 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_bind_status = Column(String(32), default="")
esports_change_role_wait_time = Column(Integer, nullable=True) esports_change_role_wait_time = Column(Integer, nullable=True)
esports_can_change_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) created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_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_chicken_skin_id = Column(String(64), default="0")
esports_firework_gift_id = Column(String(64), default="24767") esports_firework_gift_id = Column(String(64), default="24767")
esports_firework_skin_id = Column(String(64), default="3850") 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) gold_pay_type = Column(Integer, default=1)
gift_id = Column(String(64), default="23643") gift_id = Column(String(64), default="23643")
skin_id = Column(String(64), default="2942") skin_id = Column(String(64), default="2942")
@@ -151,6 +161,21 @@ class DouyuGoodsSnapshot(Base):
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) 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): class DouyuEsportsGoodsSnapshot(Base):
"""斗鱼电竞手册皮肤商城商品快照""" """斗鱼电竞手册皮肤商城商品快照"""
__tablename__ = "douyu_esports_goods_snapshot" __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 ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission 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 ..permissions import user_has_permission
from ..schemas import ( from ..schemas import (
DouyuConfigOut, DouyuConfigOut,
@@ -21,6 +21,7 @@ from ..schemas import (
DouyuTaskAccountOut, DouyuTaskAccountOut,
DouyuTaskBatchRequest, DouyuTaskBatchRequest,
DouyuTaskOut, DouyuTaskOut,
DouyuXpdGoodsOut,
) )
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
from ..services.douyu_service import ( from ..services.douyu_service import (
@@ -113,6 +114,13 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
esports_bind_status=account.esports_bind_status or "", esports_bind_status=account.esports_bind_status or "",
esports_change_role_wait_time=account.esports_change_role_wait_time, esports_change_role_wait_time=account.esports_change_role_wait_time,
esports_can_change_time=account.esports_can_change_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_to=account.assigned_to,
assigned_username=account.assigned_user.username if account.assigned_user else None, 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) 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") goods = data.get("goods")
if isinstance(goods, list): if isinstance(goods, list):
data.pop("goods", None) 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_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_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), 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), gold_pay_type=douyu_config_value("gold_pay_type", config.gold_pay_type),
gift_id=douyu_config_value("gift_id", config.gift_id), gift_id=douyu_config_value("gift_id", config.gift_id),
skin_id=douyu_config_value("skin_id", config.skin_id), skin_id=douyu_config_value("skin_id", config.skin_id),
@@ -267,6 +283,7 @@ def list_task_accounts(
Account.tag.ilike(pattern), Account.tag.ilike(pattern),
Account.game_name.ilike(pattern), Account.game_name.ilike(pattern),
Account.esports_game_name.ilike(pattern), Account.esports_game_name.ilike(pattern),
Account.xpd_game_name.ilike(pattern),
)) ))
total = None total = None
if page is not None: if page is not None:
@@ -331,6 +348,16 @@ def list_esports_goods(
return rows 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") @router.post("/tasks/batch")
async def create_task_batch( async def create_task_batch(
req: DouyuTaskBatchRequest, req: DouyuTaskBatchRequest,
+44
View File
@@ -554,6 +554,9 @@ class DouyuConfigOut(BaseModel):
esports_chicken_skin_id: str = "0" esports_chicken_skin_id: str = "0"
esports_firework_gift_id: str = "24767" esports_firework_gift_id: str = "24767"
esports_firework_skin_id: str = "3850" 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 gold_pay_type: int = 1
gift_id: str = "23643" gift_id: str = "23643"
skin_id: str = "2942" skin_id: str = "2942"
@@ -576,6 +579,9 @@ class DouyuConfigOut(BaseModel):
"esports_chicken_skin_id": self.esports_chicken_skin_id, "esports_chicken_skin_id": self.esports_chicken_skin_id,
"esports_firework_gift_id": self.esports_firework_gift_id, "esports_firework_gift_id": self.esports_firework_gift_id,
"esports_firework_skin_id": self.esports_firework_skin_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, "gold_pay_type": self.gold_pay_type,
"gift_id": self.gift_id, "gift_id": self.gift_id,
"skin_id": self.skin_id, "skin_id": self.skin_id,
@@ -598,6 +604,9 @@ class DouyuConfigUpdate(BaseModel):
esports_chicken_skin_id: Optional[str] = None esports_chicken_skin_id: Optional[str] = None
esports_firework_gift_id: Optional[str] = None esports_firework_gift_id: Optional[str] = None
esports_firework_skin_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) gold_pay_type: Optional[int] = Field(None, ge=1, le=9)
gift_id: Optional[str] = None gift_id: Optional[str] = None
skin_id: Optional[str] = None skin_id: Optional[str] = None
@@ -689,10 +698,45 @@ class DouyuTaskAccountOut(BaseModel):
esports_bind_status: str = "" esports_bind_status: str = ""
esports_change_role_wait_time: Optional[int] = None esports_change_role_wait_time: Optional[int] = None
esports_can_change_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_to: Optional[int] = None
assigned_username: Optional[str] = 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): class ProxyConfigOut(BaseModel):
enabled: bool = False enabled: bool = False
+163 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session, joinedload
from core.douyu import DouyuActivityClient, DouyuActivityError from core.douyu import DouyuActivityClient, DouyuActivityError
from ..database import SessionLocal from ..database import SessionLocal
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
from .douyu_service import ( from .douyu_service import (
DOUYU_CONFIG_FIELDS, DOUYU_CONFIG_FIELDS,
account_uid, account_uid,
@@ -415,6 +415,30 @@ class DouyuBatchRunner:
row.updated_at = now row.updated_at = now
db.commit() 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: def _config_info(self, db: Session) -> dict:
config = ensure_douyu_config(db) config = ensure_douyu_config(db)
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} 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}, {"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): def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie) client = self._client(cookie)
qr_act_alias = self._bind_qr_act_alias(config) qr_act_alias = self._bind_qr_act_alias(config)
@@ -2284,6 +2443,9 @@ class DouyuBatchRunner:
"query_gold_balance": self._execute_query_gold_balance, "query_gold_balance": self._execute_query_gold_balance,
"query_exchange_records": self._execute_query_exchange_records, "query_exchange_records": self._execute_query_exchange_records,
"prefetch_csrf_token": self._execute_prefetch_csrf_token, "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) }.get(task.task_type)
if handler is None: if handler is None:
self._mark_task(worker_db, task, "failed", "不支持的任务类型") self._mark_task(worker_db, task, "failed", "不支持的任务类型")
+7 -1
View File
@@ -38,6 +38,9 @@ SUPPORTED_DOUYU_TASK_TYPES = {
"refresh_goods": "刷新商品列表", "refresh_goods": "刷新商品列表",
"query_exchange_records": "一键查询兑换记录", "query_exchange_records": "一键查询兑换记录",
"prefetch_csrf_token": "一键获取兑换 CSRF Token", "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, "gold_pay_type": 1,
"gift_id": "23643", "gift_id": "23643",
"skin_id": "2942", "skin_id": "2942",
"xpd_act_alias": "20260623KDQFH",
"xpd_act_id": "46195",
"xpd_rid": "9263298",
} }
DOUYU_CONFIG_FIELDS = tuple(DOUYU_CONFIG_DEFAULTS.keys()) DOUYU_CONFIG_FIELDS = tuple(DOUYU_CONFIG_DEFAULTS.keys())
@@ -169,7 +175,7 @@ def create_douyu_planned_tasks(
raise ValueError("不支持的任务类型") raise ValueError("不支持的任务类型")
accounts = visible_douyu_task_accounts(db, account_ids) 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 时前端无法选账号创建任务。 # 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
accounts = accounts[:1] 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/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage handbook="elite" />)} /> <Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage handbook="esports" />)} /> <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/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} /> <Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} /> <Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+2
View File
@@ -6,6 +6,7 @@ import type {
DouyuTaskBatchRequest, DouyuTaskBatchRequest,
DouyuTaskBatchResult, DouyuTaskBatchResult,
DouyuTaskItem, DouyuTaskItem,
DouyuXpdGoodsItem,
MessageResponse, MessageResponse,
PageParams, PageParams,
PaginatedResponse, PaginatedResponse,
@@ -21,6 +22,7 @@ export const douyuApi = {
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data), updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'), listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
listEsportsGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/esports-goods'), listEsportsGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/esports-goods'),
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
createTasks: (data: DouyuTaskBatchRequest) => createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data), api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) => listTasks: (batchId?: string) =>
+17
View File
@@ -227,6 +227,13 @@ export interface DouyuTaskAccountItem {
esports_bind_status: string; esports_bind_status: string;
esports_change_role_wait_time: number | null; esports_change_role_wait_time: number | null;
esports_can_change_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_to: number | null;
assigned_username: string | null; assigned_username: string | null;
} }
@@ -246,6 +253,9 @@ export interface DouyuConfig {
esports_chicken_skin_id: string; esports_chicken_skin_id: string;
esports_firework_gift_id: string; esports_firework_gift_id: string;
esports_firework_skin_id: string; esports_firework_skin_id: string;
xpd_act_alias: string;
xpd_act_id: string;
xpd_rid: string;
gold_pay_type: number; gold_pay_type: number;
gift_id: string; gift_id: string;
skin_id: string; skin_id: string;
@@ -291,6 +301,13 @@ export interface DouyuGoodsItem {
updated_at: string | null; updated_at: string | null;
} }
export interface DouyuXpdGoodsItem extends DouyuGoodsItem {
price: number | null;
org_price: number | null;
category: string;
goods_left: number | null;
}
// ==================== Huya ==================== // ==================== Huya ====================
export interface HuyaAccountItem { export interface HuyaAccountItem {
+2 -1
View File
@@ -6,7 +6,7 @@ import {
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined, SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined, BookOutlined, TrophyOutlined, ShopOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -74,6 +74,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('douyu:task')) { if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> }); douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> }); 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; const { Text } = Typography;
type HandbookKind = 'elite' | 'esports'; type HandbookKind = 'elite' | 'esports' | 'peace';
const TASK_STATUS_COLORS: Record<string, string> = { const TASK_STATUS_COLORS: Record<string, string> = {
planned: 'default', planned: 'default',
@@ -83,6 +83,12 @@ const ESPORTS_QUICK_ACTIONS = [
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> }, { 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([ const ELITE_TASK_TYPES = new Set([
'get_bind_qr', 'get_bind_qr',
'confirm_bind', 'confirm_bind',
@@ -115,6 +121,11 @@ const ESPORTS_TASK_TYPES = new Set([
'donate_esports_chicken_gift', 'donate_esports_chicken_gift',
'donate_esports_firework_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_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode'; 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}`; 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 { function formatWaitSeconds(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(Number(seconds))) return ''; if (seconds == null || Number.isNaN(Number(seconds))) return '';
const total = Math.max(0, Math.floor(Number(seconds))); 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 }) { export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isPeaceHandbook = handbook === 'peace';
const isEsportsHandbook = handbook === 'esports'; const isEsportsHandbook = handbook === 'esports';
const handbookTitle = isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台'; const handbookTitle = isPeaceHandbook
const handbookDescription = isEsportsHandbook ? '和平小店工作台'
? '电竞手册角色绑定与开通任务' : (isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台');
: '精英宝典绑定、开通、积分与兑换任务'; const handbookDescription = isPeaceHandbook
const activeTaskTypes = isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES; ? '和平小店绑定角色、商品与点券余额查询'
const quickActions = isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS; : (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 { can } = usePermissions();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]); const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除) // 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
@@ -462,7 +489,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
try { try {
const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([ const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([
workbenchIds.length > 0 ? douyuApi.listAccounts({ ids: workbenchIds.join(',') }) : Promise.resolve([]), 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(), douyuApi.listTasks(),
canConfig ? douyuApi.getConfig() : Promise.resolve(null), canConfig ? douyuApi.getConfig() : Promise.resolve(null),
douyuApi.taskTypes(), douyuApi.taskTypes(),
@@ -508,7 +537,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canConfig, isEsportsHandbook, workbenchIds]); }, [canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return; if (tasksLoadingRef.current) return;
@@ -1104,6 +1133,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{ {
title: '游戏名', dataIndex: 'game_name', width: 170, title: '游戏名', dataIndex: 'game_name', width: 170,
render: (_, record) => { 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 const queryTask = isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id) || null ? latestEsportsStateTaskByAccount.get(record.id) || null
: latestQueryGameTaskByAccount.get(record.id) || null; : latestQueryGameTaskByAccount.get(record.id) || null;
@@ -1175,8 +1209,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, },
}, },
{ {
title: isEsportsHandbook ? '电竞积分' : '积分', title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isEsportsHandbook ? 'esports_points' : 'points', dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
width: 80, width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>, 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, title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => { render: (_, record) => {
if (isPeaceHandbook) return <Text type="secondary">-</Text>;
const fromTask = latestChangeWaitByAccount.get(record.id); const fromTask = latestChangeWaitByAccount.get(record.id);
const canChangeTime = fromTask?.canChangeTime ?? ( const canChangeTime = fromTask?.canChangeTime ?? (
isEsportsHandbook ? record.esports_can_change_time : null isEsportsHandbook ? record.esports_can_change_time : null
@@ -1239,6 +1274,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return <Text type="secondary">{text || '-'}</Text>; return <Text type="secondary">{text || '-'}</Text>;
}, },
sorter: (a, b) => { sorter: (a, b) => {
if (isPeaceHandbook) return 0;
const aw = isEsportsHandbook const aw = isEsportsHandbook
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1 ? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
: latestChangeWaitByAccount.get(a.id)?.wait : latestChangeWaitByAccount.get(a.id)?.wait
@@ -1398,7 +1434,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
); );
const operationBody = ( 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={compactOperationGridStyle}>
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={sectionTitleStyle}> <div style={sectionTitleStyle}>
@@ -1690,7 +1758,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</Card> </Card>
<Card <Card
size="small" size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'} title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra} extra={operationExtra}
style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }} styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }}
@@ -1702,7 +1770,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<> <>
<Card <Card
size="small" size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'} title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra} extra={operationExtra}
style={{ flexShrink: 0, marginBottom: 12 }} style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }} 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: 'tag', width: 110, render: (v: string) => (v ? <Tag color="blue">{v}</Tag> : '-') },
{ {
title: '游戏名', title: '游戏名',
dataIndex: isEsportsHandbook ? 'esports_game_name' : 'game_name', dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'),
render: (v) => v || '-', render: (v) => v || '-',
}, },
{ {
title: isEsportsHandbook ? '电竞积分' : '积分', title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isEsportsHandbook ? 'esports_points' : 'points', dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
render: (v) => v ?? '-', render: (v) => v ?? '-',
}, },
]} ]}
@@ -1804,7 +1872,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{/* Config modal */} {/* Config modal */}
<Modal <Modal
title={isEsportsHandbook ? '电竞手册配置' : '精英宝典配置'} title={isPeaceHandbook ? '和平小店配置' : (isEsportsHandbook ? '电竞手册配置' : '精英宝典配置')}
open={configOpen} open={configOpen}
onCancel={() => setConfigOpen(false)} onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)} onOk={() => configFormValues && saveConfig(configFormValues)}
@@ -1813,7 +1881,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
> >
{configFormValues && ( {configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}> <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: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' }, { label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' }, { label: '房间 ID', key: 'room_id' },
@@ -1840,7 +1912,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/> />
</Space> </Space>
))} ))}
{(isEsportsHandbook ? [ {(isPeaceHandbook ? [] : isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' }, { label: '电竞手册金额(分)', key: 'esports_amount' },
] : [ ] : [
{ label: '宝典金额(分)', key: 'elite_amount' }, { label: '宝典金额(分)', key: 'elite_amount' },