新增电竞手册独立绑定流程

This commit is contained in:
yml2213
2026-07-28 17:12:08 +08:00
parent 63962811f8
commit d7a8666fe4
13 changed files with 3725 additions and 145 deletions
+163 -1
View File
@@ -42,7 +42,14 @@ class DouyuActivityClient:
CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance"
CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff"
PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item"
PEACE_TIME_API = "https://www.douyu.com/japi/oms/web/peace/time"
PAY_QR_API = "https://www.douyu.com/japi/oms/web/pay/getQrCode"
ESPORTS_BIND_INFO_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindInfo"
ESPORTS_BIND_ROLE_PARAM_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindRoleParam"
ESPORTS_CSRF_API = "https://www.douyu.com/wgapi/livenc/liveweb/csrfApi/getCsrfCookie"
ESPORTS_AGREEMENT_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/agreement"
ESPORTS_ACT_BIND_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/actBind"
ESPORTS_USER_INFO_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/getUserInfo"
GOLD_QR_API = "https://cz.douyu.com/m/gold/getQrCode"
GOLD_ACCOUNT_API = "https://cz.douyu.com/item/gold/account"
EXCHANGE_BALANCE_API = "https://www.douyu.com/wjapi/nc/exchange/fim"
@@ -238,6 +245,10 @@ class DouyuActivityClient:
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询绑定信息失败")
return self._parse_bind_info(payload, act_alias=act_alias, api_version="v2" if v2 else "v1")
def _parse_bind_info(self, payload: dict[str, Any], *, act_alias: str, api_version: str) -> dict[str, Any]:
"""统一解析不同活动的腾讯游戏绑定信息。"""
data = payload.get("data") or {}
game_account = data.get("gameAccount") or {}
game_role = data.get("gameRole") or {}
@@ -270,7 +281,7 @@ class DouyuActivityClient:
can_change_role = self._first_present(tx_act, "canChangeRole", "canChangeBind")
return {
"act_alias": act_alias,
"api_version": "v2" if v2 else "v1",
"api_version": api_version,
"bind_account": bind_account,
"bind_role": bind_role,
"bind_act": bind_act,
@@ -289,6 +300,96 @@ class DouyuActivityClient:
"raw": payload,
}
def esports_bind_info(self, act_alias: str) -> dict[str, Any]:
"""电竞手册唯一的角色状态查询,返回角色、绑定状态和换绑冷却。"""
payload = self._request_json(
"get",
self.ESPORTS_BIND_INFO_API,
"查询电竞手册绑定信息",
params={"actAlias": act_alias},
headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询电竞手册绑定信息失败")
return self._parse_bind_info(payload, act_alias=act_alias, api_version="esports")
def get_esports_bind_qr(self, act_alias: str) -> dict[str, Any]:
"""生成电竞手册的腾讯角色绑定二维码链接。"""
payload = self._request_json(
"get",
self.ESPORTS_BIND_ROLE_PARAM_API,
"获取电竞手册绑定二维码",
params={"actAlias": act_alias},
headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "获取电竞手册绑定二维码失败")
data = payload.get("data") or {}
# 电竞手册网页实际打开 data.urlgameRoleBindUrl 是兼容旧入口。
bind_url = data.get("url") or data.get("gameRoleBindUrl") or ""
if not bind_url:
raise DouyuActivityError(payload.get("msg") or "电竞手册绑定二维码链接为空")
return {
"url": bind_url,
"bind_url": bind_url,
"role_param_url": data.get("url") or "",
"game_role_bind_url": data.get("gameRoleBindUrl") or "",
"act_alias": act_alias,
"raw": payload,
}
def esports_ctn(self, *, referer: str) -> str:
"""获取电竞手册页面当前 ctn,仅在 Cookie 缺失时走页面 CSRF 接口。"""
ctn = cookie_value(self.cookie, "acf_ccn")
if ctn:
return ctn
response = self._request(
"get",
self.ESPORTS_CSRF_API,
source="获取电竞手册 ctn",
headers={"Origin": "", "Referer": referer},
)
ctn = cookie_value(self.cookie, "acf_ccn")
if not ctn:
raise DouyuActivityError(f"获取电竞手册 ctn 失败: {response.text[:200]}")
return ctn
def confirm_esports_bind(self, act_alias: str, *, room_id: str = "9263298") -> dict[str, Any]:
"""自动完成协议前置请求后,通过 actBind 确认电竞手册绑定。"""
referer = (
"https://www.douyu.com/pages/live-peace-pel/web"
f"?roomId={room_id or '9263298'}&isAnchorSide=0"
)
# 页面成功流程固定为 agreement -> actBind;协议不作为独立业务状态暴露。
# 两次 POST 都使用当前 Cookie 中的 acf_ccn,不额外主动刷新 CSRF。
agreement_ctn = self.esports_ctn(referer=referer)
agreement = self._request_json(
"post",
self.ESPORTS_AGREEMENT_API,
"同意电竞手册协议",
files={"ctn": (None, agreement_ctn)},
headers={"Referer": referer},
)
if agreement.get("error") not in (0, "0", None):
raise DouyuActivityError(agreement.get("msg") or "同意电竞手册协议失败")
bind_ctn = self.esports_ctn(referer=referer)
bind = self._request_json(
"post",
self.ESPORTS_ACT_BIND_API,
"完成电竞手册绑定",
files={
"actAlias": (None, act_alias),
"ctn": (None, bind_ctn),
},
headers={"Referer": referer},
)
if bind.get("error") not in (0, "0", None):
raise DouyuActivityError(bind.get("msg") or "完成电竞手册绑定失败")
return {"agreement": agreement, "act_bind": bind}
def confirm_bind(self, act_alias: str) -> dict[str, Any]:
"""确认绑定当前扫码选择的游戏角色。"""
token = self.csrf_token()
@@ -337,6 +438,67 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "生成精英宝典支付码失败")
return {"pay_url": qr, "ctn": ctn, "act_alias": act_alias, "item": item_payload, "raw": payload}
def create_esports_qr(self, *, ctn: str, act_alias: str, amount: int, room_id: str) -> dict[str, Any]:
"""生成电竞手册支付二维码。"""
self._set_cookie_value("acf_ccn", ctn)
time_payload = self._request_json(
"get",
self.PEACE_TIME_API,
"校验电竞手册开通时间",
headers={"Referer": "https://www.douyu.com/pages/live-peace-pel/web/"},
)
if time_payload.get("error") not in (0, "0"):
raise DouyuActivityError(time_payload.get("msg") or "电竞手册当前不可开通")
payload = self._request_json(
"post",
self.PAY_QR_API,
"生成电竞手册支付码",
json={
"bizType": "peacePel",
"biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)},
"ctn": ctn,
},
headers={
"Content-Type": "application/json;charset=UTF-8",
"Referer": "https://www.douyu.com/pages/live-peace-pel/web/",
},
)
data = payload.get("data") or {}
qr = data.get("qr") or ""
if not qr:
raise DouyuActivityError(payload.get("msg") or "生成电竞手册支付码失败")
return {
"pay_url": qr,
"pay_token": data.get("token") or "",
"pay_ttl": data.get("ttl"),
"ctn": ctn,
"act_alias": act_alias,
"time": time_payload,
"raw": payload,
}
def esports_user_info(self, *, manual_id: str) -> dict[str, Any]:
"""查询电竞手册的开通状态和积分。"""
payload = self._request_json(
"get",
self.ESPORTS_USER_INFO_API,
"查询电竞手册状态",
params={"manualID": manual_id},
headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询电竞手册状态失败")
user_info = (payload.get("data") or {}).get("userInfo") or {}
return {
"manual_id": manual_id,
"manual_type": user_info.get("manualType"),
"manual_score": user_info.get("manualScore"),
"expire_time": user_info.get("expireTime"),
"user_info": user_info,
"raw": payload,
}
def create_gold_qr(self, *, amount: int, pay_type: int = 1, product_id: str = "DYTV_Product_Gold_1") -> dict[str, Any]:
"""生成鱼翅充值支付二维码。"""
payload = self._request_json(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
"""新增斗鱼电竞手册配置
Revision ID: 20260728_0010
Revises: 20260725_0009
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260728_0010"
down_revision: Union[str, None] = "20260725_0009"
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("esports_manual_id", sa.String(length=64), nullable=True),
sa.Column("esports_act_alias", sa.String(length=64), nullable=True),
sa.Column("esports_amount", sa.Integer(), 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("esports_points", sa.Integer(), nullable=True),
sa.Column("esports_game_name", sa.String(length=128), nullable=True),
sa.Column("esports_game_channel", sa.String(length=128), nullable=True),
sa.Column("esports_bind_status", sa.String(length=32), nullable=True),
sa.Column("esports_change_role_wait_time", sa.Integer(), nullable=True),
]:
if column.name not in account_columns:
op.add_column("accounts", column)
def downgrade() -> None:
bind = op.get_bind()
account_columns = _columns(bind, "accounts")
for column_name in [
"esports_change_role_wait_time",
"esports_bind_status",
"esports_game_channel",
"esports_game_name",
"esports_points",
]:
if column_name in account_columns:
op.drop_column("accounts", column_name)
config_columns = _columns(bind, "douyu_config")
for column_name in ["esports_amount", "esports_act_alias", "esports_manual_id"]:
if column_name in config_columns:
op.drop_column("douyu_config", column_name)
@@ -0,0 +1,51 @@
"""补充电竞手册账号独立字段
Revision ID: 20260728_0011
Revises: 20260728_0010
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260728_0011"
down_revision: Union[str, None] = "20260728_0010"
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()
columns = _columns(bind, "accounts")
for column in [
sa.Column("esports_points", sa.Integer(), nullable=True),
sa.Column("esports_game_name", sa.String(length=128), nullable=True),
sa.Column("esports_game_channel", sa.String(length=128), nullable=True),
sa.Column("esports_bind_status", sa.String(length=32), nullable=True),
sa.Column("esports_change_role_wait_time", sa.Integer(), nullable=True),
]:
if column.name not in columns:
op.add_column("accounts", column)
def downgrade() -> None:
bind = op.get_bind()
columns = _columns(bind, "accounts")
for column_name in [
"esports_change_role_wait_time",
"esports_bind_status",
"esports_game_channel",
"esports_game_name",
"esports_points",
]:
if column_name in columns:
op.drop_column("accounts", column_name)
+8
View File
@@ -64,6 +64,11 @@ class Account(Base):
exchange_balance = Column(Integer, nullable=True)
bind_status = Column(String(32), default="")
change_role_wait_time = Column(Integer, nullable=True)
esports_points = Column(Integer, nullable=True)
esports_game_name = Column(String(128), default="")
esports_game_channel = Column(String(128), default="")
esports_bind_status = Column(String(32), default="")
esports_change_role_wait_time = Column(Integer, nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
@@ -119,6 +124,9 @@ class DouyuConfig(Base):
legacy_act_alias = Column(String(64), default="cjm")
room_id = Column(String(64), default="9263298")
elite_amount = Column(Integer, default=3000)
esports_manual_id = Column(String(64), default="1W0yGkOdDbAXb")
esports_act_alias = Column(String(64), default="20260226ZMGDG")
esports_amount = Column(Integer, default=3000)
gold_pay_type = Column(Integer, default=1)
gift_id = Column(String(64), default="23643")
skin_id = Column(String(64), default="2942")
+9
View File
@@ -107,6 +107,11 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
exchange_balance=account.exchange_balance,
bind_status=account.bind_status or "",
change_role_wait_time=account.change_role_wait_time,
esports_points=account.esports_points,
esports_game_name=account.esports_game_name or "",
esports_game_channel=account.esports_game_channel or "",
esports_bind_status=account.esports_bind_status or "",
esports_change_role_wait_time=account.esports_change_role_wait_time,
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username if account.assigned_user else None,
)
@@ -140,6 +145,9 @@ def _config_out(config: DouyuConfig) -> DouyuConfigOut:
legacy_act_alias=douyu_config_value("legacy_act_alias", config.legacy_act_alias),
room_id=douyu_config_value("room_id", config.room_id),
elite_amount=douyu_config_value("elite_amount", config.elite_amount),
esports_manual_id=douyu_config_value("esports_manual_id", config.esports_manual_id),
esports_act_alias=douyu_config_value("esports_act_alias", config.esports_act_alias),
esports_amount=douyu_config_value("esports_amount", config.esports_amount),
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),
@@ -170,6 +178,7 @@ def list_task_accounts(
Account.nickname.ilike(pattern),
Account.tag.ilike(pattern),
Account.game_name.ilike(pattern),
Account.esports_game_name.ilike(pattern),
))
rows = query.order_by(Account.id.desc()).limit(500).all()
return [_account_out(account) for account in rows]
+14
View File
@@ -542,6 +542,9 @@ class DouyuConfigOut(BaseModel):
legacy_act_alias: str = "cjm"
room_id: str = "9263298"
elite_amount: int = 3000
esports_manual_id: str = "1W0yGkOdDbAXb"
esports_act_alias: str = "20260226ZMGDG"
esports_amount: int = 3000
gold_pay_type: int = 1
gift_id: str = "23643"
skin_id: str = "2942"
@@ -557,6 +560,9 @@ class DouyuConfigOut(BaseModel):
"legacy_act_alias": self.legacy_act_alias,
"room_id": self.room_id,
"elite_amount": self.elite_amount,
"esports_manual_id": self.esports_manual_id,
"esports_act_alias": self.esports_act_alias,
"esports_amount": self.esports_amount,
"gold_pay_type": self.gold_pay_type,
"gift_id": self.gift_id,
"skin_id": self.skin_id,
@@ -572,6 +578,9 @@ class DouyuConfigUpdate(BaseModel):
legacy_act_alias: Optional[str] = None
room_id: Optional[str] = None
elite_amount: Optional[int] = Field(None, ge=1)
esports_manual_id: Optional[str] = None
esports_act_alias: Optional[str] = None
esports_amount: Optional[int] = Field(None, ge=1)
gold_pay_type: Optional[int] = Field(None, ge=1, le=9)
gift_id: Optional[str] = None
skin_id: Optional[str] = None
@@ -657,6 +666,11 @@ class DouyuTaskAccountOut(BaseModel):
exchange_balance: Optional[int] = None
bind_status: str = ""
change_role_wait_time: Optional[int] = None
esports_points: Optional[int] = None
esports_game_name: str = ""
esports_game_channel: str = ""
esports_bind_status: str = ""
esports_change_role_wait_time: Optional[int] = None
assigned_to: Optional[int] = None
assigned_username: Optional[str] = None
+397
View File
@@ -277,6 +277,53 @@ class DouyuBatchRunner:
account.bind_status = status
account.updated_at = datetime.now(timezone.utc)
def _apply_esports_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None:
"""将电竞手册角色状态写入专属字段,避免覆盖精英宝典数据。"""
role_name = str(bind_info.get("role_name") or "")
account.esports_game_name = role_name or account.esports_game_name
account.esports_game_channel = self._role_channel(bind_info) or account.esports_game_channel
account.esports_change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time"))
account.esports_bind_status = status
account.updated_at = datetime.now(timezone.utc)
def _esports_bind_state(
self,
client: DouyuActivityClient,
act_alias: str,
) -> dict:
"""查询电竞手册活动状态,接口同时返回当前角色和换绑冷却信息。"""
activity_info = client.esports_bind_info(act_alias)
activity_snapshot = self._bind_snapshot(activity_info)
esports_bound = activity_snapshot["is_bound_act"]
has_selected_role = bool(activity_snapshot["role_name"])
tx_act = activity_info.get("tx_act") or {}
return {
**activity_snapshot,
"is_bound_role": has_selected_role,
"act_alias": act_alias,
"game_id": tx_act.get("gameId") or "",
"esports_bound": esports_bound,
"has_selected_role": has_selected_role,
"bind_ready_for_confirm": has_selected_role and not esports_bound,
"bind_confirmed": esports_bound,
"bind_phase": "confirmed" if esports_bound else ("role_ready" if has_selected_role else "waiting_role"),
"activity_bind_info": activity_info,
"activity_bind_snapshot": activity_snapshot,
"role_source": "activity",
}
@staticmethod
def _esports_role_text(state: dict) -> str:
role_name = str(state.get("role_name") or "")
channel = " / ".join(
str(part)
for part in [state.get("plat_name"), state.get("area_name")]
if part
)
if not role_name:
return ""
return f"{role_name}{channel}" if channel else role_name
def _mark_task(
self,
db: Session,
@@ -415,6 +462,100 @@ class DouyuBatchRunner:
result["points"] = last_points
return False
def _refresh_esports_handbook(
self,
client: DouyuActivityClient,
account: Account,
*,
manual_id: str,
) -> dict:
"""刷新电竞手册开通状态并将积分写回账号。"""
result = client.esports_user_info(manual_id=manual_id)
manual_type = self._to_int(result.get("manual_type"))
manual_score = self._to_int(result.get("manual_score"))
account.esports_points = manual_score
account.updated_at = datetime.now(timezone.utc)
return {
"esports_manual_type": manual_type,
"esports_manual_score": manual_score,
"esports_expire_time": result.get("expire_time"),
"esports_user_info": result,
"esports_points": manual_score,
"points": manual_score,
}
def _wait_esports_open_after_payment(
self,
db: Session,
task: DouyuTask,
account: Account,
client: DouyuActivityClient,
*,
manual_id: str,
result: dict,
baseline_manual_type: int | None,
baseline_manual_score: int | None,
) -> bool:
"""等待电竞手册支付到账,以 manualType=1 或积分变化作为成功条件。"""
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
result["payment_polling"] = True
result["esports_manual_type_baseline"] = baseline_manual_type
result["esports_manual_score_baseline"] = baseline_manual_score
poll_count = 0
last_manual_type = baseline_manual_type
last_manual_score = baseline_manual_score
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
handbook_result = self._refresh_esports_handbook(
client,
account,
manual_id=manual_id,
)
db.commit()
poll_count += 1
last_manual_type = handbook_result["esports_manual_type"]
last_manual_score = handbook_result["esports_manual_score"]
result.update(handbook_result)
result["payment_poll_count"] = poll_count
result["payment_polling"] = True
opened = (
last_manual_type is not None
and last_manual_type >= 1
) or (
baseline_manual_score is not None
and last_manual_score is not None
and last_manual_score > baseline_manual_score
)
if opened:
result["payment_polling"] = False
result["esports_opened"] = True
return True
self._update_task_progress(
db,
task,
"running",
"电竞手册支付码已生成,等待开通到账"
f"(类型 {last_manual_type if last_manual_type is not None else '-'}"
f"积分 {last_manual_score if last_manual_score is not None else '-'}",
result,
)
except Exception as exc:
poll_count += 1
result["payment_poll_count"] = poll_count
result["payment_poll_error"] = str(exc)
self._update_task_progress(db, task, "running", f"等待电竞手册到账: {exc}", result)
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
break
result["payment_polling"] = False
result["esports_opened"] = False
result["esports_manual_type"] = last_manual_type
result["esports_manual_score"] = last_manual_score
result["esports_points"] = last_manual_score
result["points"] = last_manual_score
return False
def _wait_gold_balance_after_payment(
self,
db: Session,
@@ -1093,6 +1234,257 @@ class DouyuBatchRunner:
result,
)
def _execute_prepare_esports_bind(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""打开电竞手册绑定面板:查活动状态、当前角色和换绑冷却。"""
client = self._client(cookie)
act_alias = str(config.get("esports_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias")
return
state = self._esports_bind_state(client, act_alias)
esports_bound = state["esports_bound"]
role_text = self._esports_role_text(state)
self._apply_esports_bind_info_to_account(
account,
state,
"esports_bound" if esports_bound else ("esports_bind_ready" if role_text else "game_not_bound"),
)
# 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。
# 冷却是否允许最终由 actBind 返回结果决定。
qr_result = client.get_esports_bind_qr(act_alias)
result = {
**state,
**qr_result,
"esports_bind_dialog": True,
"can_open_role_selector": True,
}
if esports_bound:
message = f"电竞手册已绑定: {role_text or '-'},可扫码换绑"
elif role_text:
message = f"当前角色: {role_text},可扫码切换角色或直接完成绑定"
else:
message = "请扫码选择游戏角色,完成后查询最新角色"
self._mark_task(db, task, "success", message, result)
def _execute_get_esports_bind_qr(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""生成电竞手册切换角色用的腾讯入口。"""
client = self._client(cookie)
act_alias = str(config.get("esports_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias")
return
state = self._esports_bind_state(client, act_alias)
qr_result = client.get_esports_bind_qr(act_alias)
result = {
**state,
**qr_result,
"esports_bind_dialog": True,
"can_open_role_selector": True,
"bind_phase": "switching_role",
}
account.esports_bind_status = "esports_role_switching"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
"请在腾讯页面选择角色,返回后查询最新角色",
result,
)
def _execute_confirm_esports_bind(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""通过 actBind 确认电竞手册绑定,并回读唯一状态接口确认结果。"""
client = self._client(cookie)
act_alias = str(config.get("esports_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias")
return
account.esports_bind_status = "esports_bind_confirming"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(
db,
task,
"running",
"正在确认电竞手册绑定",
{
"bind_phase": "confirming",
"bind_confirmed": False,
},
)
confirm_result = client.confirm_esports_bind(
act_alias,
room_id=str(config.get("room_id") or "9263298"),
)
after_state = self._esports_bind_state(client, act_alias)
result = {
**after_state,
"confirm": confirm_result,
"bind_phase": "confirmed" if after_state["esports_bound"] else "confirm_failed",
}
if not after_state["esports_bound"]:
self._apply_esports_bind_info_to_account(account, after_state, "esports_bind_ready")
self._mark_task(
db,
task,
"failed",
f"电竞手册绑定未生效: {self._esports_role_text(after_state) or '-'}",
result,
)
return
self._apply_esports_bind_info_to_account(account, after_state, "esports_bound")
self._mark_task(
db,
task,
"success",
f"电竞手册绑定成功: {self._esports_role_text(after_state) or '-'}",
result,
)
def _execute_query_esports_game_name(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询电竞手册活动返回的最新角色和换绑冷却状态。"""
client = self._client(cookie)
act_alias = str(config.get("esports_act_alias") or "").strip()
if not act_alias:
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias")
return
state = self._esports_bind_state(client, act_alias)
role_name = state["role_name"]
is_bound = state["esports_bound"]
self._apply_esports_bind_info_to_account(
account,
state,
"esports_bound" if is_bound else ("esports_bind_ready" if role_name else "game_not_bound"),
)
result = {
**state,
"esports_bind_dialog": True,
"can_open_role_selector": True,
}
if role_name:
message = f"最新角色: {self._esports_role_text(state)}"
else:
message = "未查询到游戏角色,请先切换角色"
self._mark_task(db, task, "success", message, result)
def _execute_create_esports_qr(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""生成电竞手册支付二维码,并通过活动状态确认开通到账。"""
client = self._client(cookie)
ctn = str(self._task_payload(task).get("ctn") or "")
if not ctn:
ctn = client.acf_ccn(refresh_subscribe=True)
act_alias = str(config.get("esports_act_alias") or "").strip()
manual_id = str(config.get("esports_manual_id") or "").strip()
if not act_alias or not manual_id:
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias 和 manualID")
return
baseline_manual_type = None
baseline_manual_score = None
baseline_result: dict = {}
try:
baseline_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
baseline_manual_type = baseline_result["esports_manual_type"]
baseline_manual_score = baseline_result["esports_manual_score"]
db.commit()
except Exception as exc:
self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}")
if baseline_manual_type is not None and baseline_manual_type >= 1:
account.esports_bind_status = "esports_opened"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
f"电竞手册已开通,积分: {baseline_manual_score if baseline_manual_score is not None else '-'}",
{**baseline_result, "esports_opened": True, "payment_polling": False},
)
return
result = client.create_esports_qr(
ctn=ctn,
act_alias=act_alias,
amount=int(config["esports_amount"]),
room_id=str(config["room_id"]),
)
result.update(baseline_result)
account.esports_bind_status = "esports_qr_created"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(db, task, "running", "电竞手册支付码已生成,等待开通到账", result)
opened = self._wait_esports_open_after_payment(
db,
task,
account,
client,
manual_id=manual_id,
result=result,
baseline_manual_type=baseline_manual_type,
baseline_manual_score=baseline_manual_score,
)
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if opened:
account.esports_bind_status = "esports_opened"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
f"电竞手册已开通,积分: {account.esports_points if account.esports_points is not None else '-'}",
result,
)
return
self._mark_task(
db,
task,
"failed",
"未检测到电竞手册开通到账"
f",类型: {result.get('esports_manual_type', '-')}"
f"积分: {result.get('esports_manual_score', '-')}",
result,
)
def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task)
amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
@@ -1474,6 +1866,11 @@ class DouyuBatchRunner:
"get_bind_qr": self._execute_get_bind_qr,
"confirm_bind": self._execute_confirm_bind,
"create_elite_qr": self._execute_create_elite_qr,
"prepare_esports_bind": self._execute_prepare_esports_bind,
"get_esports_bind_qr": self._execute_get_esports_bind_qr,
"query_esports_game_name": self._execute_query_esports_game_name,
"confirm_esports_bind": self._execute_confirm_esports_bind,
"create_esports_qr": self._execute_create_esports_qr,
"create_gold_qr": self._execute_create_gold_qr,
"donate_elite_gift": self._execute_donate_elite_gift,
"query_points": self._execute_query_points,
+8
View File
@@ -17,6 +17,11 @@ SUPPORTED_DOUYU_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码",
"confirm_bind": "确认绑定",
"create_elite_qr": "开通精英宝典30",
"prepare_esports_bind": "绑定电竞手册角色",
"get_esports_bind_qr": "切换电竞角色",
"query_esports_game_name": "查询最新游戏角色",
"confirm_esports_bind": "确认电竞手册绑定",
"create_esports_qr": "开通电竞手册30",
"create_gold_qr": "充值鱼翅",
"donate_elite_gift": "赠送精英令",
"query_points": "一键查询积分",
@@ -39,6 +44,9 @@ DOUYU_CONFIG_DEFAULTS = {
"legacy_act_alias": "cjm",
"room_id": "9263298",
"elite_amount": 3000,
"esports_manual_id": "1W0yGkOdDbAXb",
"esports_act_alias": "20260226ZMGDG",
"esports_amount": 3000,
"gold_pay_type": 1,
"gift_id": "23643",
"skin_id": "2942",
+3 -1
View File
@@ -72,7 +72,9 @@ function AppContent() {
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="douyu/tasks" element={lazyRoute(<DouyuTasksPage />)} />
<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="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+8
View File
@@ -196,6 +196,11 @@ export interface DouyuTaskAccountItem {
exchange_balance: number | null;
bind_status: string;
change_role_wait_time: number | null;
esports_points: number | null;
esports_game_name: string;
esports_game_channel: string;
esports_bind_status: string;
esports_change_role_wait_time: number | null;
assigned_to: number | null;
assigned_username: string | null;
}
@@ -208,6 +213,9 @@ export interface DouyuConfig {
legacy_act_alias: string;
room_id: string;
elite_amount: number;
esports_manual_id: string;
esports_act_alias: string;
esports_amount: number;
gold_pay_type: number;
gift_id: string;
skin_id: string;
+3 -1
View File
@@ -6,6 +6,7 @@ import {
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -71,7 +72,8 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
}
// 虎牙
+510 -142
View File
@@ -24,7 +24,9 @@ import { message } from '../utils/antdMessage';
const { Text } = Typography;
const QUICK_ACTIONS = [
type HandbookKind = 'elite' | 'esports';
const ELITE_QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
@@ -38,8 +40,37 @@ const QUICK_ACTIONS = [
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
];
const BIND_ACTION_KEYS = ['get_bind_qr', 'confirm_bind', 'query_game_name', 'query_change_bind_time'];
const QUERY_ACTION_KEYS = ['query_points', 'query_limited_goods', 'query_gold_balance'];
const ESPORTS_QUICK_ACTIONS = [
{ key: 'prepare_esports_bind', icon: <QrcodeOutlined /> },
{ key: 'query_esports_game_name', icon: <SearchOutlined /> },
{ key: 'create_esports_qr', icon: <CreditCardOutlined /> },
];
const ELITE_BIND_ACTION_KEYS = ['get_bind_qr', 'confirm_bind', 'query_game_name', 'query_change_bind_time'];
const ELITE_QUERY_ACTION_KEYS = ['query_points', 'query_limited_goods', 'query_gold_balance'];
const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'create_gold_qr',
'donate_elite_gift',
'query_points',
'exchange_goods',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
'refresh_goods',
'query_exchange_records',
'prefetch_csrf_token',
]);
const ESPORTS_TASK_TYPES = new Set([
'prepare_esports_bind',
'get_esports_bind_qr',
'query_esports_game_name',
'confirm_esports_bind',
'create_esports_qr',
]);
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
@@ -58,6 +89,14 @@ const BIND_STATUS_LABELS: Record<string, string> = {
game_queried: '已识别角色',
game_not_bound: '未绑定',
bind_confirmed: '已绑定',
esports_bind_qr_generated: '电竞待绑定',
esports_role_queried: '已查电竞角色',
esports_bind_ready: '电竞待确认',
esports_role_switching: '电竞换角色',
esports_bind_confirming: '电竞绑定中',
esports_bound: '电竞已绑定',
esports_qr_created: '电竞待开通',
esports_opened: '电竞已开通',
change_time_queried: '已查换绑',
};
@@ -93,17 +132,25 @@ function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
if (!task || task.task_type !== 'get_bind_qr') return false;
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
return Boolean(resultText(task.result, 'url')) || bindReadyForConfirm(task);
return Boolean(resultText(task.result, 'url'))
|| bindReadyForConfirm(task)
|| resultFlag(task.result, 'esports_bound');
}
function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
return Boolean(task && ['create_elite_qr', 'create_gold_qr'].includes(task.task_type) && resultText(task.result, 'pay_url'));
return Boolean(
task
&& ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)
&& resultText(task.result, 'pay_url'),
);
}
function paymentArrived(task: DouyuTaskItem | null | undefined): boolean {
if (!task || !['create_elite_qr', 'create_gold_qr'].includes(task.task_type)) return false;
if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false;
if (task.status !== 'success') return false;
return task.result?.gold_recharged === true || task.result?.elite_opened === true;
return task.result?.gold_recharged === true
|| task.result?.elite_opened === true
|| task.result?.esports_opened === true;
}
function bindPhaseText(phase: string, polling: boolean): string {
@@ -149,7 +196,14 @@ function savedPositiveInteger(key: string, fallback = 1): number {
return Number.isInteger(value) && value >= 1 ? value : fallback;
}
export default function DouyuTasksPage() {
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
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 { can } = usePermissions();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -174,10 +228,13 @@ export default function DouyuTasksPage() {
// 绑定/支付弹窗以 task 为唯一数据源
const [qrTask, setQrTask] = useState<DouyuTaskItem | null>(null);
const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenedEsportsBindTaskIds = useRef<Set<number>>(new Set());
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
const autoOpenPayReady = useRef(false);
const logs = useWebSocketLogs();
@@ -192,22 +249,41 @@ export default function DouyuTasksPage() {
return map;
}, [tasks]);
const latestEsportsStateTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (!['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
.includes(task.task_type) || !task.result) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const visibleTasks = useMemo(
() => tasks.filter((task) => activeTaskTypes.has(task.task_type)),
[activeTaskTypes, tasks],
);
// 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段
const latestChangeWaitByAccount = useMemo(() => {
const map = new Map<number, { wait: number | null; text: string; taskId: number }>();
for (const task of tasks) {
if (!['query_change_bind_time', 'get_bind_qr'].includes(task.task_type)) continue;
const taskTypes = isEsportsHandbook
? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
: ['query_change_bind_time', 'get_bind_qr'];
if (!taskTypes.includes(task.task_type)) continue;
if (!['success', 'failed'].includes(task.status)) continue;
const wait = resultNumber(task.result, 'change_role_wait_time');
// get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖
if (task.task_type === 'get_bind_qr' && wait == null) continue;
if (!isEsportsHandbook && task.task_type === 'get_bind_qr' && wait == null) continue;
const existing = map.get(task.account_id);
if (existing && existing.taskId > task.id) continue;
const text = resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait);
map.set(task.account_id, { wait, text, taskId: task.id });
}
return map;
}, [tasks]);
}, [isEsportsHandbook, tasks]);
const loadData = useCallback(async () => {
setLoading(true);
@@ -227,9 +303,13 @@ export default function DouyuTasksPage() {
if (!autoOpenQrReady.current) {
for (const task of taskResult.value) {
if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id);
if (task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true) {
autoOpenedEsportsBindTaskIds.current.add(task.id);
}
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
}
autoOpenQrReady.current = true;
autoOpenEsportsBindReady.current = true;
autoOpenPayReady.current = true;
}
}
@@ -264,8 +344,8 @@ export default function DouyuTasksPage() {
}, [giftCount]);
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[tasks],
() => visibleTasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[visibleTasks],
);
// 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
@@ -277,33 +357,55 @@ export default function DouyuTasksPage() {
const openQrTask = useCallback((task: DouyuTaskItem) => {
autoOpenedQrTaskIds.current.add(task.id);
setEsportsBindTask(null);
setPayTask(null);
setQrTask(task);
}, []);
const openEsportsBindTask = useCallback((task: DouyuTaskItem) => {
autoOpenedEsportsBindTaskIds.current.add(task.id);
setQrTask(null);
setPayTask(null);
setEsportsBindTask(task);
}, []);
const openPayTask = useCallback((task: DouyuTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
setQrTask(null);
setEsportsBindTask(null);
setPayTask(task);
}, []);
// 自动弹出最新绑定二维码(running 有 url 即可)
useEffect(() => {
if (!autoOpenQrReady.current || qrTask) return;
const nextQrTask = tasks
const nextQrTask = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextQrTask) openQrTask(nextQrTask);
}, [openQrTask, qrTask, tasks]);
}, [openQrTask, qrTask, visibleTasks]);
// 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。
useEffect(() => {
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
const nextTask = visibleTasks
.filter((task) => (
task.task_type === 'prepare_esports_bind'
&& task.result?.esports_bind_dialog === true
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id)[0];
if (nextTask) openEsportsBindTask(nextTask);
}, [esportsBindTask, openEsportsBindTask, visibleTasks]);
// 自动弹出支付二维码
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = tasks
const nextPayTask = visibleTasks
.filter((task) => hasPaymentQrcode(task) && !paymentArrived(task) && !autoOpenedPayTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, tasks]);
}, [openPayTask, payTask, visibleTasks]);
// 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新)
useEffect(() => {
@@ -312,6 +414,12 @@ export default function DouyuTasksPage() {
if (latest && latest !== qrTask) setQrTask(latest);
}, [qrTask, tasks]);
useEffect(() => {
if (!esportsBindTask) return;
const latest = tasks.find((task) => task.id === esportsBindTask.id);
if (latest && latest !== esportsBindTask) setEsportsBindTask(latest);
}, [esportsBindTask, tasks]);
useEffect(() => {
if (!payTask) return;
const latest = tasks.find((task) => task.id === payTask.id);
@@ -325,14 +433,25 @@ export default function DouyuTasksPage() {
const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks);
const startTask = async (taskType: string, accountIds?: number[]) => {
const startTask = async (
taskType: string,
accountIds?: number[],
extraPayload: Record<string, unknown> = {},
) => {
const ids = accountIds || selectedIds;
if (ids.length === 0) {
message.warning('请先选择账号');
return;
}
// 绑定确认/查询允许在其他账号仍轮询时继续,避免卡在多账号批次里
const allowDuringBatch = taskType === 'confirm_bind' || taskType === 'query_game_name';
const allowDuringBatch = [
'confirm_bind',
'confirm_esports_bind',
'query_game_name',
'query_esports_game_name',
'prepare_esports_bind',
'get_esports_bind_qr',
].includes(taskType);
if (batchBusy && !allowDuringBatch) {
message.warning('当前已有批次在运行,请先停止或等待结束');
return;
@@ -341,7 +460,7 @@ export default function DouyuTasksPage() {
message.warning('请先选择兑换商品');
return;
}
const payload: Record<string, unknown> = {};
const payload: Record<string, unknown> = { ...extraPayload };
if (taskType === 'exchange_goods') payload.commodity_id = selectedGoodsId;
if (taskType === 'create_gold_qr') payload.amount = goldAmount;
if (taskType === 'donate_elite_gift') payload.gift_count = giftCount;
@@ -460,12 +579,87 @@ export default function DouyuTasksPage() {
void startTask(taskType, contextMenu.accountIds);
};
// ---- 电竞手册绑定面板 ----
const esportsRelatedTasks = esportsBindTask
? tasks
.filter((task) => (
task.account_id === esportsBindTask.account_id
&& task.id >= esportsBindTask.id
&& ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
.includes(task.task_type)
))
.sort((a, b) => b.id - a.id)
: [];
const esportsLatestStateTask = esportsRelatedTasks.find((task) => task.result) || esportsBindTask;
const esportsLatestRoleTask = esportsRelatedTasks.find(
(task) => task.task_type === 'query_esports_game_name',
) || null;
const esportsLatestQrTask = esportsRelatedTasks.find(
(task) => Boolean(resultText(task.result, 'url')),
) || null;
const esportsConfirmRunning = esportsRelatedTasks.some(
(task) => task.task_type === 'confirm_esports_bind' && ['planned', 'pending', 'running'].includes(task.status),
);
const esportsRoleQueryRunning = Boolean(
esportsLatestRoleTask && ['planned', 'pending', 'running'].includes(esportsLatestRoleTask.status),
);
const esportsResult = esportsLatestStateTask?.result || esportsBindTask?.result || null;
const esportsBound = resultFlag(esportsResult, 'esports_bound') || resultFlag(esportsResult, 'bind_confirmed');
const esportsRoleName = resultText(esportsResult, 'role_name');
const esportsAreaName = resultText(esportsResult, 'area_name');
const esportsPlatName = resultText(esportsResult, 'plat_name');
const esportsRoleLine = [esportsPlatName, esportsAreaName].filter(Boolean).join(' / ');
const esportsCanChangeRole = Boolean(esportsBindTask && !esportsConfirmRunning);
const esportsChangeWait = resultNumber(esportsResult, 'change_role_wait_time');
const esportsQrUrl = resultText(esportsLatestQrTask?.result, 'url');
const esportsAccountName = esportsBindTask
? (
esportsBindTask.account_nickname
|| esportsBindTask.account_username
|| esportsBindTask.account_uid
|| `#${esportsBindTask.account_id}`
)
: '';
const esportsStatusText = esportsBound
? '电竞手册已绑定'
: esportsConfirmRunning
? '正在完成电竞手册绑定'
: esportsRoleQueryRunning
? '正在查询最新角色'
: esportsLatestStateTask?.message || '加载电竞手册绑定状态';
const esportsCanConfirm = Boolean(
esportsBindTask
&& !esportsBound
&& esportsRoleName
&& !esportsConfirmRunning,
);
const queryEsportsRole = () => {
if (!esportsBindTask || esportsRoleQueryRunning) return;
void startTask('query_esports_game_name', [esportsBindTask.account_id]);
};
const switchEsportsRole = () => {
if (!esportsBindTask || !esportsCanChangeRole) return;
void startTask('get_esports_bind_qr', [esportsBindTask.account_id]);
};
const confirmEsportsBindFromDialog = () => {
if (!esportsBindTask || !esportsCanConfirm) {
message.warning('请先确认当前角色');
return;
}
void startTask('confirm_esports_bind', [esportsBindTask.account_id]);
};
// ---- QR panel derived state ----
const qrResult = qrTask?.result || null;
const qrUrl = resultText(qrResult, 'url');
const qrBindPhase = resultText(qrResult, 'bind_phase');
const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true;
const qrQueryTask = qrTask ? latestQueryGameTaskByAccount.get(qrTask.account_id) || null : null;
const qrQueryTask = qrTask
? latestQueryGameTaskByAccount.get(qrTask.account_id) || null
: null;
const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null;
const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running');
const qrQueryRoleName = resultText(qrQueryResult, 'role_name');
@@ -485,14 +679,29 @@ export default function DouyuTasksPage() {
const qrRoleName = resultText(qrRoleSourceResult, 'role_name');
const qrAreaName = resultText(qrRoleSourceResult, 'area_name');
const qrPlatName = resultText(qrRoleSourceResult, 'plat_name');
const qrCurrentRoleName = resultText(qrResult, 'current_role_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'role_name') : '')
const qrCurrentRoleName = qrQueryRoleName
|| resultText(qrResult, 'current_role_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'role_name')
: ''
)
|| (qrQueryIsBoundAct ? qrQueryRoleName : '');
const qrCurrentAreaName = resultText(qrResult, 'current_area_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'area_name') : '')
const qrCurrentAreaName = resultText(qrQueryResult, 'area_name')
|| resultText(qrResult, 'current_area_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'area_name')
: ''
)
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'area_name') : '');
const qrCurrentPlatName = resultText(qrResult, 'current_plat_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'plat_name') : '')
const qrCurrentPlatName = resultText(qrQueryResult, 'plat_name')
|| resultText(qrResult, 'current_plat_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'plat_name')
: ''
)
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'plat_name') : '');
const qrRoleLine = qrBindReady
? [qrPlatName, qrAreaName, qrRoleName].filter(Boolean).join(' - ')
@@ -542,13 +751,18 @@ export default function DouyuTasksPage() {
{
title: '游戏名', dataIndex: 'game_name', width: 130,
render: (_, record) => {
const queryResult = latestQueryGameTaskByAccount.get(record.id)?.result || null;
const roleName = resultText(queryResult, 'role_name') || record.game_name;
const queryResult = (
isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id)
: latestQueryGameTaskByAccount.get(record.id)
)?.result || null;
const roleName = resultText(queryResult, 'role_name')
|| (isEsportsHandbook ? record.esports_game_name : record.game_name);
const channel = (
[resultText(queryResult, 'area_name'), resultText(queryResult, 'plat_name')]
.filter(Boolean)
.join(' / ')
|| record.game_channel
|| (isEsportsHandbook ? record.esports_game_channel : record.game_channel)
);
return roleName ? (
<Space direction="vertical" size={0}>
@@ -559,18 +773,22 @@ export default function DouyuTasksPage() {
},
},
{
title: '积分', dataIndex: 'points', width: 70,
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
{
...(isEsportsHandbook ? [] : [{
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
}]),
{
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => {
const fromTask = latestChangeWaitByAccount.get(record.id);
const wait = fromTask?.wait ?? record.change_role_wait_time;
const wait = fromTask?.wait ?? (
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
);
const text = fromTask?.text || formatWaitSeconds(wait);
if (wait == null && !text) {
return <Text type="secondary"></Text>;
@@ -591,16 +809,21 @@ export default function DouyuTasksPage() {
return <Text type="secondary">{text || '-'}</Text>;
},
sorter: (a, b) => {
const aw = latestChangeWaitByAccount.get(a.id)?.wait ?? a.change_role_wait_time ?? -1;
const bw = latestChangeWaitByAccount.get(b.id)?.wait ?? b.change_role_wait_time ?? -1;
const aw = latestChangeWaitByAccount.get(a.id)?.wait
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
const bw = latestChangeWaitByAccount.get(b.id)?.wait
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
return aw - bw;
},
},
{
title: '状态', dataIndex: 'bind_status', width: 100,
render: (v) => (v
? <Tag>{BIND_STATUS_LABELS[v] || v}</Tag>
: <Text type="secondary">-</Text>),
render: (_, record) => {
const value = isEsportsHandbook ? record.esports_bind_status : record.bind_status;
return value
? <Tag>{BIND_STATUS_LABELS[value] || value}</Tag>
: <Text type="secondary">-</Text>;
},
},
];
@@ -618,6 +841,22 @@ export default function DouyuTasksPage() {
{
title: '结果', dataIndex: 'message', ellipsis: true,
render: (_, r) => {
if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(r.task_type)) {
const roleName = resultText(r.result, 'role_name');
const esportsBound = resultFlag(r.result, 'esports_bound') || resultFlag(r.result, 'bind_confirmed');
const bindUrl = resultText(r.result, 'url');
return (
<Space wrap size={4}>
<Text>{r.message || '-'}</Text>
{roleName ? <Tag color={esportsBound ? 'green' : 'blue'}>{roleName}</Tag> : null}
{bindUrl || r.result?.esports_bind_dialog === true ? (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openEsportsBindTask(r)}>
</Button>
) : null}
</Space>
);
}
if (r.task_type === 'get_bind_qr') {
const roleName = resultText(r.result, 'role_name');
const phase = resultText(r.result, 'bind_phase');
@@ -639,7 +878,7 @@ export default function DouyuTasksPage() {
</Space>
);
}
if (r.task_type === 'query_game_name') {
if (['query_game_name', 'query_esports_game_name'].includes(r.task_type)) {
const roleName = resultText(r.result, 'role_name');
const areaName = resultText(r.result, 'area_name');
const platName = resultText(r.result, 'plat_name');
@@ -663,6 +902,16 @@ export default function DouyuTasksPage() {
</Space>
);
}
if (r.task_type === 'confirm_esports_bind') {
const roleName = resultText(r.result, 'role_name');
const confirmed = resultFlag(r.result, 'bind_confirmed');
return (
<Space wrap size={4}>
<Text>{r.message || '-'}</Text>
{roleName ? <Tag color={confirmed ? 'green' : 'orange'}>{roleName}</Tag> : null}
</Space>
);
}
const url = taskPayUrl(r);
return (
<Space wrap size={4}>
@@ -696,7 +945,8 @@ export default function DouyuTasksPage() {
(a.nickname || '').toLowerCase().includes(kw)
|| (a.username || '').toLowerCase().includes(kw)
|| (a.uid || '').toLowerCase().includes(kw)
|| (a.game_name || '').toLowerCase().includes(kw),
|| (a.game_name || '').toLowerCase().includes(kw)
|| (a.esports_game_name || '').toLowerCase().includes(kw),
);
}, [accounts, accountSearch]);
@@ -710,7 +960,7 @@ export default function DouyuTasksPage() {
? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`)
: '';
const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号';
const actionByKey = useMemo(() => new Map(QUICK_ACTIONS.map((item) => [item.key, item])), []);
const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]);
const actionDisabled = (taskType: string) => {
if (batchBusy || selectedIds.length === 0) return true;
if (taskType === 'exchange_goods') return !selectedGoodsId;
@@ -757,8 +1007,8 @@ export default function DouyuTasksPage() {
{/* Header */}
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0 }}></h2>
<Text type="secondary"></Text>
<h2 style={{ margin: 0 }}>{handbookTitle}</h2>
<Text type="secondary">{handbookDescription}</Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
@@ -822,15 +1072,15 @@ export default function DouyuTasksPage() {
size="small" title="任务记录"
extra={
<Space size={12}>
<Tag color="success"> {tasks.filter((t) => t.status === 'success').length}</Tag>
<Tag color="error"> {tasks.filter((t) => ['failed', 'error'].includes(t.status)).length}</Tag>
<Tag color="success"> {visibleTasks.filter((t) => t.status === 'success').length}</Tag>
<Tag color="error"> {visibleTasks.filter((t) => ['failed', 'error'].includes(t.status)).length}</Tag>
</Space>
}
style={{ flex: '0 0 300px', minHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: 8, overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
<Table
rowKey="id" size="small" columns={taskColumns} dataSource={tasks}
rowKey="id" size="small" columns={taskColumns} dataSource={visibleTasks}
style={{ flex: 1, minHeight: 0 }}
loading={loading} pagination={{ pageSize: 4, showSizeChanger: false, size: 'small' }}
scroll={{ x: 700, y: 150 }}
@@ -842,7 +1092,7 @@ export default function DouyuTasksPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, overflow: 'auto' }}>
<Card
size="small"
title="操作"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
extra={<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedText}</Tag>}
styles={{ body: { padding: 8 } }}
>
@@ -859,92 +1109,116 @@ export default function DouyuTasksPage() {
/>
</Space.Compact>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{BIND_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{QUERY_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</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: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
{isEsportsHandbook ? (
<>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{renderActionButton('prepare_esports_bind', 'primary')}
{renderActionButton('query_esports_game_name')}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
{renderActionButton('create_esports_qr', 'primary')}
</div>
</>
) : (
<>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{ELITE_BIND_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{ELITE_QUERY_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</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: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
</>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
@@ -965,7 +1239,7 @@ export default function DouyuTasksPage() {
style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{QUICK_ACTIONS.map((item) => (
{quickActions.map((item) => (
<Button
key={item.key} block size="small" type="text"
icon={item.icon}
@@ -1011,8 +1285,16 @@ export default function DouyuTasksPage() {
columns={[
{ title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username },
{ title: 'UID', dataIndex: 'uid' },
{ title: '游戏名', dataIndex: 'game_name', render: (v) => v || '-' },
{ title: '积分', dataIndex: 'points', render: (v) => v ?? '-' },
{
title: '游戏名',
dataIndex: isEsportsHandbook ? 'esports_game_name' : 'game_name',
render: (v) => v || '-',
},
{
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
render: (v) => v ?? '-',
},
]}
pagination={{ pageSize: 10 }}
locale={{ emptyText: '没有可导入的账号(操作台已包含所有账号)' }}
@@ -1021,7 +1303,7 @@ export default function DouyuTasksPage() {
{/* Config modal */}
<Modal
title="斗鱼活动配置"
title={isEsportsHandbook ? '电竞手册配置' : '精英宝典配置'}
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)}
@@ -1030,7 +1312,11 @@ export default function DouyuTasksPage() {
>
{configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{[
{(isEsportsHandbook ? [
{ label: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' },
] : [
{ label: 'manualID', key: 'manual_id' },
{ label: 'RID', key: 'rid' },
{ label: '绑定二维码活动', key: 'bind_act_alias' },
@@ -1039,7 +1325,7 @@ export default function DouyuTasksPage() {
{ label: '房间 ID', key: 'room_id' },
{ label: '精英令礼物 ID', key: 'gift_id' },
{ label: '皮肤 ID', key: 'skin_id' },
].map(({ label, key }) => (
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<Input
@@ -1049,10 +1335,12 @@ export default function DouyuTasksPage() {
/>
</Space>
))}
{[
{(isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' },
] : [
{ label: '宝典金额(分)', key: 'elite_amount' },
{ label: '鱼翅支付方式', key: 'gold_pay_type' },
].map(({ label, key }) => (
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<InputNumber
@@ -1066,7 +1354,87 @@ export default function DouyuTasksPage() {
)}
</Modal>
{/* Bind QR Modal — driven by qrTask */}
{/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */}
<Modal
title={
<Space>
<QrcodeOutlined />
<span></span>
</Space>
}
open={!!esportsBindTask}
onCancel={() => {
setEsportsBindTask(null);
}}
footer={null}
centered
width={440}
>
{esportsBindTask && (
<Space direction="vertical" style={{ width: '100%' }} size={14}>
{esportsAccountName ? (
<Text type="secondary">{esportsAccountName}</Text>
) : null}
<Tag color={esportsBound ? 'green' : esportsConfirmRunning ? 'processing' : 'blue'}>
{esportsStatusText}
</Tag>
{esportsRoleName ? (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}></Text>
<Text strong style={{ fontSize: 16 }}>{esportsRoleName}</Text>
{esportsRoleLine ? (
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>{esportsRoleLine}</Text>
) : null}
</div>
) : (
<Text type="secondary"></Text>
)}
{esportsChangeWait != null && esportsChangeWait > 0 && esportsRoleName ? (
<Text type="secondary">{formatWaitSeconds(esportsChangeWait)}</Text>
) : null}
{esportsQrUrl ? (
<div style={{ textAlign: 'center' }}>
<QRCode value={esportsQrUrl} size={220} bordered={false} />
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
</Text>
</div>
) : null}
<Space wrap>
<Button
icon={<SearchOutlined />}
onClick={queryEsportsRole}
loading={esportsRoleQueryRunning}
disabled={esportsConfirmRunning}
>
</Button>
<Button
icon={<QrcodeOutlined />}
onClick={switchEsportsRole}
disabled={!esportsCanChangeRole || esportsConfirmRunning}
>
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={confirmEsportsBindFromDialog}
disabled={!esportsCanConfirm}
loading={esportsConfirmRunning}
>
</Button>
</Space>
</Space>
)}
</Modal>
{/* 精英宝典绑定二维码弹窗。 */}
<Modal
title={
<Space>