feat(douyu): 和平小店绑定功能(生成绑定二维码/查询绑定信息)

- activity_client: xpd_bind_qr(roleParam 签发 livelink 绑定链接, 微信扫码进小程序绑定)
  + xpd_bind_info(bindInfo 查询绑定状态/角色/平台), gameRole 非 dict 边界防护
- runner/service: 注册 get_xpd_bind_qr / query_xpd_bind_info 任务, 回写
  xpd_game_name / xpd_bind_status
- router: get_xpd_bind_qr 的 url 加入列表接口保留白名单(二维码展示)
- 前端: 和平小店面板新增两个按钮, 二维码列支持 get_xpd_bind_qr 展示/放大
This commit is contained in:
yml2213
2026-08-06 17:33:11 +08:00
parent fdc74eba8c
commit 5f63d77dc3
5 changed files with 127 additions and 1 deletions
+63
View File
@@ -819,6 +819,8 @@ class DouyuActivityClient:
# ---- 和平小店(腾讯道聚城 H5)---- # ---- 和平小店(腾讯道聚城 H5)----
XPD_EMBED_API = "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl" XPD_EMBED_API = "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl"
XPD_H5_REFERER = "https://www.douyu.com/topic/h5/hpxd01" XPD_H5_REFERER = "https://www.douyu.com/topic/h5/hpxd01"
XPD_ROLE_PARAM_API = "https://apiv2.douyucdn.cn/japi/carnivalApi/tencent/roleParam"
XPD_BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo"
XPD_DAOJU_REFERER = "https://app.daoju.qq.com/" XPD_DAOJU_REFERER = "https://app.daoju.qq.com/"
XPD_GET_ROLE_API = "https://apps.game.qq.com/daoju/igw/live" XPD_GET_ROLE_API = "https://apps.game.qq.com/daoju/igw/live"
# 2026-08 抓包(8.6-和平小店)实证:本期走 xn_live_cjm 变体(旧期为 recommend_live/common) # 2026-08 抓包(8.6-和平小店)实证:本期走 xn_live_cjm 变体(旧期为 recommend_live/common)
@@ -1002,6 +1004,67 @@ class DouyuActivityClient:
) )
return {"goods": goods, "raw": recommend} return {"goods": goods, "raw": recommend}
def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]:
"""生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。
返回 data.url(可生成二维码)与 gameRoleBindUrl(直接角色绑定页)。
"""
payload = self._request_json(
"post",
self.XPD_ROLE_PARAM_API,
"生成小店绑定二维码",
params={"client_sys": "android"},
data={
"token": self._xpd_token(),
"uid": cookie_value(self.cookie, "acf_uid") or "",
"client": "mobile",
"actAlias": act_alias,
},
headers={
"User-Device": "MDExZTUxMzEyYmYxMmY5MmQ3YmY1M2M4MDA3MDcxMTF8djcuOS4y",
"aid": "android1",
"User-Agent": "android/7.9.2 (android 9; ; M973Q)",
"Accept-Language": "zh-CN",
"Content-Type": "application/x-www-form-urlencoded",
},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "生成小店绑定二维码失败")
data = payload.get("data") or {}
url = str(data.get("url") or "")
if not url:
raise DouyuActivityError(f"生成小店绑定二维码失败: {payload}")
return {
"url": url,
"game_role_bind_url": str(data.get("gameRoleBindUrl") or ""),
"nick_name": str((data.get("queryParams") or {}).get("nickName") or ""),
"raw": payload,
}
def xpd_bind_info(self, *, act_alias: str) -> dict[str, Any]:
"""查询和平小店绑定信息(bindInfo)。"""
payload = self._request_json(
"get",
self.XPD_BIND_INFO_API,
"查询小店绑定信息",
params={"actAlias": act_alias, "token": self._xpd_token()},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询小店绑定信息失败")
data = payload.get("data") or {}
game_role = data.get("gameRole") if isinstance(data.get("gameRole"), dict) else {}
game_account = data.get("gameAccount") if isinstance(data.get("gameAccount"), dict) else {}
return {
"bind_account": self._xpd_int(data.get("bindAccount")),
"bind_role": self._xpd_int(data.get("bindRole")),
"need_bind_role": self._xpd_int(data.get("needBindRole")),
"nick": str(game_account.get("nick") or ""),
"role_name": str(game_role.get("roleName") or ""),
"area_name": str(game_role.get("areaName") or ""),
"plat_name": str(game_role.get("platName") or ""),
"raw": payload,
}
def xpd_balance( def xpd_balance(
self, self,
*, *,
+1 -1
View File
@@ -190,7 +190,7 @@ def _sanitize_task_result(result: dict | None, task_type: str, *, include_detail
if "limited_goods" in data: if "limited_goods" in data:
data["limited_goods"] = _slim_limited_goods(data.get("limited_goods")) data["limited_goods"] = _slim_limited_goods(data.get("limited_goods"))
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr"}: if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}:
data.pop("url", None) data.pop("url", None)
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}: if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
data.pop("pay_url", None) data.pop("pay_url", None)
+53
View File
@@ -1076,6 +1076,57 @@ class DouyuBatchRunner:
{"role": role, "area_id": area_id}, {"role": role, "area_id": area_id},
) )
def _execute_get_xpd_bind_qr(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""生成和平小店绑定二维码(微信扫码在小程序内绑定角色)。"""
client = self._client(cookie)
act_alias = str(config.get("xpd_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
return
result = client.xpd_bind_qr(act_alias=act_alias)
account.xpd_bind_status = "xpd_bind_qr_ready"
account.updated_at = datetime.now(timezone.utc)
db.commit()
self._mark_task(db, task, "success", "小店绑定二维码已生成", result)
def _execute_query_xpd_bind_info(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询和平小店绑定信息(bindInfo)。"""
client = self._client(cookie)
act_alias = str(config.get("xpd_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
return
result = client.xpd_bind_info(act_alias=act_alias)
role_name = str(result.get("role_name") or "")
if role_name:
account.xpd_game_name = role_name
account.xpd_bind_status = "xpd_bound" if result.get("bind_role") else "xpd_not_bound"
account.updated_at = datetime.now(timezone.utc)
db.commit()
status = "已绑定" if result.get("bind_role") else "未绑定"
text = str(result.get("role_name") or result.get("nick") or "-")
self._mark_task(
db,
task,
"success",
f"小店绑定: {status} ({text})",
result,
)
def _execute_refresh_xpd_goods( def _execute_refresh_xpd_goods(
self, self,
db: Session, db: Session,
@@ -2443,6 +2494,8 @@ 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,
"get_xpd_bind_qr": self._execute_get_xpd_bind_qr,
"query_xpd_bind_info": self._execute_query_xpd_bind_info,
"query_xpd_role": self._execute_query_xpd_role, "query_xpd_role": self._execute_query_xpd_role,
"refresh_xpd_goods": self._execute_refresh_xpd_goods, "refresh_xpd_goods": self._execute_refresh_xpd_goods,
"query_xpd_balance": self._execute_query_xpd_balance, "query_xpd_balance": self._execute_query_xpd_balance,
+2
View File
@@ -38,6 +38,8 @@ SUPPORTED_DOUYU_TASK_TYPES = {
"refresh_goods": "刷新商品列表", "refresh_goods": "刷新商品列表",
"query_exchange_records": "一键查询兑换记录", "query_exchange_records": "一键查询兑换记录",
"prefetch_csrf_token": "一键获取兑换 CSRF Token", "prefetch_csrf_token": "一键获取兑换 CSRF Token",
"get_xpd_bind_qr": "生成小店绑定二维码",
"query_xpd_bind_info": "查询小店绑定信息",
"query_xpd_role": "查询小店绑定角色", "query_xpd_role": "查询小店绑定角色",
"refresh_xpd_goods": "刷新小店商品列表", "refresh_xpd_goods": "刷新小店商品列表",
"query_xpd_balance": "查询小店点券余额", "query_xpd_balance": "查询小店点券余额",
@@ -84,6 +84,8 @@ const ESPORTS_QUICK_ACTIONS = [
]; ];
const PEACE_QUICK_ACTIONS = [ const PEACE_QUICK_ACTIONS = [
{ key: 'get_xpd_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
{ key: 'query_xpd_role', icon: <SearchOutlined /> }, { key: 'query_xpd_role', icon: <SearchOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> }, { key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'query_xpd_balance', icon: <SearchOutlined /> }, { key: 'query_xpd_balance', icon: <SearchOutlined /> },
@@ -122,6 +124,8 @@ const ESPORTS_TASK_TYPES = new Set([
'donate_esports_firework_gift', 'donate_esports_firework_gift',
]); ]);
const PEACE_TASK_TYPES = new Set([ const PEACE_TASK_TYPES = new Set([
'get_xpd_bind_qr',
'query_xpd_bind_info',
'query_xpd_role', 'query_xpd_role',
'refresh_xpd_goods', 'refresh_xpd_goods',
'query_xpd_balance', 'query_xpd_balance',
@@ -149,6 +153,7 @@ function resultFlag(result: Record<string, unknown> | null | undefined, key: str
function taskQrUrl(task: DouyuTaskItem | null | undefined): string { function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
if (!task) return ''; if (!task) return '';
if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url'); if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url');
if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url');
if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) { if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) {
return resultText(task.result, 'pay_url'); return resultText(task.result, 'pay_url');
} }
@@ -1323,6 +1328,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (!task) return; if (!task) return;
if (hasBindQrcode(task)) openQrTask(task); if (hasBindQrcode(task)) openQrTask(task);
else if (hasPaymentQrcode(task)) openPayTask(task); else if (hasPaymentQrcode(task)) openPayTask(task);
else if (task.task_type === 'get_xpd_bind_qr') openQrTask(task);
else openEsportsBindTask(task); else openEsportsBindTask(task);
}; };
return ( return (
@@ -1442,6 +1448,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<span></span> <span></span>
</div> </div>
<Space direction="vertical" style={{ width: '100%' }} size={6}> <Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('get_xpd_bind_qr', 'primary')}
{renderActionButton('query_xpd_bind_info', 'primary')}
{renderActionButton('query_xpd_role', 'primary')} {renderActionButton('query_xpd_role', 'primary')}
{renderActionButton('query_xpd_balance', 'primary')} {renderActionButton('query_xpd_balance', 'primary')}
{renderActionButton('refresh_xpd_goods')} {renderActionButton('refresh_xpd_goods')}