refactor(douyu): runner 按功能域拆分 Mixin + 精英手册兑换对齐真机流程 (HAR 审核通过)

拆分: douyu_runner.py (-3186行) → core/bind/manual/gold/donate/goods/xpd 七个
Mixin + 入口聚合类, douyu_batch_registry 供 routers 重导出

精英手册兑换对齐 HAR 抓包 (activity_client +379 行):
- csrf 复用 Cookie 值不再每次 generateCsrf (HAR 实测全程零 csrf 请求),
  仅服务端报 csrf 错误时 force_refresh 重试一次
- pay Referer 补 roomId 对齐浏览器
- 新增手册链路接口: elite_user_info/storedetail/pre_exchange check+confirm/
  subscribe/batch_limit/赠品兑换/自动转换
- resolve_exchange_plan: 浏览器兑换按钮状态机路由 (normal/subscribe/
  pre_exchange/wait/blocked)
- 兑换执行: 人类节奏抖动 → 锁单(火爆重试1次) → 支付分级退避
  (频控 30/60/90s 4次上限, 普错 2/5/10/20s 5次上限, 300s 锁单期 15s 余量,
  可中断睡眠)

测试: 新增 17 项 (csrf 复用语义/状态机路由/Referer), 全量 94 通过;
未夹带代理改动
This commit is contained in:
yml2213
2026-08-30 11:05:18 +08:00
parent 6e17a575dd
commit 39b202bc3f
11 changed files with 3959 additions and 3196 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
"""斗鱼任务执行器:公共基础(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import asyncio
import threading
import time
from datetime import datetime, timezone
from typing import Optional
from loguru import logger
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload
# 支付/到账轮询(手册与充值共用)
DOUYU_PAYMENT_POLL_SECONDS = 600
DOUYU_PAYMENT_POLL_INTERVAL = 5
class DouyuBatchRunnerCore:
"""斗鱼任务执行器公共基础:批次状态、日志、任务落库与客户端构造。"""
def __init__(
self,
db: Session,
batch_id: str,
task_type: str,
payload: Optional[dict] = None,
log_queue: Optional[asyncio.Queue] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
concurrency: int = 3,
):
self.db = db
self.batch_id = batch_id
self.task_type = task_type
self.payload = payload or {}
self.log_queue = log_queue
self.loop = loop
self.concurrency = max(1, min(concurrency, 10))
self._stop = threading.Event()
self._counter_lock = threading.Lock()
self._started = 0
def stop(self):
self._stop.set()
def _push_log(self, level: str, message: str):
if level == "result":
try:
douyu_batch_registry.mark_finished(self.batch_id)
except NameError:
pass
if level != "result" and message:
log_func = getattr(logger, level, logger.info)
log_func(f"[douyu] {message}")
if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe(
self.log_queue.put({"level": level, "message": message}),
self.loop,
)
@staticmethod
def _account_name(account: Account) -> str:
return account.nickname or account.username or account.uid or f"#{account.id}"
@staticmethod
def _to_int(value) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _format_wait_time(seconds: int | None) -> str:
if seconds is None:
return ""
seconds = max(0, int(seconds))
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes, sec = divmod(rem, 60)
if days:
return f"{days}{hours}小时{minutes}"
if hours:
return f"{hours}小时{minutes}{sec}"
return f"{minutes}{sec}"
def _push_task_event(self, task: DouyuTask) -> None:
"""向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。"""
if not self.log_queue or not self.loop:
return
try:
payload = douyu_task_payload(task)
except Exception:
logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id)
return
event = {
"level": "task",
"message": "",
"task": payload,
}
asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop)
def _mark_task(
self,
db: Session,
task: DouyuTask,
status: str,
message: str,
result: dict | None = None,
) -> None:
task.status = status
task.message = message[:512]
if result is not None:
task.result = result
task.finished_at = datetime.now(timezone.utc)
db.commit()
self._push_task_event(task)
def _update_task_progress(
self,
db: Session,
task: DouyuTask,
status: str,
message: str,
result: dict | None = None,
) -> None:
task.status = status
task.message = message[:512]
if result is not None:
task.result = result
db.commit()
self._push_task_event(task)
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
now = datetime.now(timezone.utc)
for raw in goods:
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not commodity_id:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
score = self._to_int(raw.get("score"))
if row is None:
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = score
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None:
now = datetime.now(timezone.utc)
for raw in goods:
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not commodity_id:
continue
row = (
db.query(DouyuEsportsGoodsSnapshot)
.filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id)
.first()
)
if row is None:
row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = self._to_int(raw.get("score"))
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None:
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
now = datetime.now(timezone.utc)
commodity_ids = {
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
for raw in goods
}
commodity_ids.discard("")
query = db.query(DouyuXpdGoodsSnapshot)
if commodity_ids:
query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete(
synchronize_session=False,
)
else:
query.delete(synchronize_session=False)
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 "")
goods_left = raw.get("goods_left")
if goods_left is None:
goods_left = raw.get("iGoodsLeft")
row.goods_left = self._to_int(goods_left)
row.raw = raw
row.updated_at = now
db.commit()
def _config_info(self, db: Session) -> dict:
config = ensure_douyu_config(db)
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
def _task_payload(self, task: DouyuTask) -> dict:
result = task.result if isinstance(task.result, dict) else {}
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
return {**payload, **self.payload}
def _client(self, cookie: str) -> DouyuActivityClient:
return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg))
def _sleep_interruptible(self, seconds: float) -> bool:
"""分段睡眠,任务停止时提前返回;返回 False 表示已被停止。"""
waited = 0.0
step = 0.5
while waited < seconds:
if self._stop.is_set():
return False
time.sleep(min(step, seconds - waited))
waited += step
return not self._stop.is_set()
class DouyuBatchRegistry:
"""管理运行中的斗鱼任务批次。"""
def __init__(self):
self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
self._batches[batch_id] = {
"log_queue": log_queue,
"loop": loop,
"runner": runner,
"finished": False,
"updated_at": time.time(),
}
def get(self, batch_id: str):
return self._batches.get(batch_id)
def pop(self, batch_id: str):
return self._batches.pop(batch_id, None)
def mark_finished(self, batch_id: str):
if batch_id in self._batches:
self._batches[batch_id]["finished"] = True
self._batches[batch_id]["updated_at"] = time.time()
def active_ids(self) -> set[str]:
return {
batch_id
for batch_id, info in self._batches.items()
if not info.get("finished")
}
douyu_batch_registry = DouyuBatchRegistry()
+234
View File
@@ -0,0 +1,234 @@
"""斗鱼任务执行器:送礼(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuTask
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
class DonateMixin:
"""送礼域:精英令/电竞任务礼物赠送与积分确认。"""
def _refresh_points_after_elite_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
client: DouyuActivityClient,
cookie: str,
ctn: str | None,
result: dict,
baseline_points: int | None,
gift_count: int,
) -> dict:
"""赠送精英令后短轮询积分;1 个精英令约等于 10 积分。"""
expected_gain = max(0, gift_count) * 10
target_points = baseline_points + expected_gain if baseline_points is not None else None
result["gift_points_baseline"] = baseline_points
result["gift_points_expected_gain"] = expected_gain
result["gift_points_target"] = target_points
last_points = None
refresh_result: dict = {}
for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1):
refresh_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
db.commit()
last_points = refresh_result["points"]
result.update(refresh_result)
result["gift_points_refresh_count"] = index
if target_points is None or (last_points is not None and last_points >= target_points):
result["gift_points_confirmed"] = target_points is None or last_points is not None
return refresh_result
if index < DOUYU_GIFT_POINTS_REFRESH_TIMES:
self._update_task_progress(
db,
task,
"running",
f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points}",
result,
)
if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL):
break
result["gift_points_confirmed"] = False
result["points"] = last_points
return refresh_result
def _execute_donate_esports_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
*,
gift_name: str,
config_gift_id_key: str,
config_skin_id_key: str,
):
"""赠送电竞手册任务礼物并刷新独立积分。"""
payload = self._task_payload(task)
try:
gift_count = max(1, int(payload.get("gift_count") or payload.get("count") or 1))
except (TypeError, ValueError):
self._mark_task(db, task, "failed", "赠送数量必须是正整数")
return
manual_id = str(config.get("esports_manual_id") or "").strip()
gift_id = str(payload.get("gift_id") or config.get(config_gift_id_key) or "").strip()
skin_id = str(payload.get("skin_id") or config.get(config_skin_id_key) or "").strip()
room_id = str(payload.get("room_id") or config.get("room_id") or "").strip()
if not manual_id or not gift_id or not skin_id or not room_id:
self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数")
return
client = self._client(cookie)
baseline_points = account.esports_points
try:
baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id)
baseline_points = baseline["esports_points"]
db.commit()
except Exception as exc:
self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}")
result = client.donate_esports_gift(
gift_name=gift_name,
gift_count=gift_count,
room_id=room_id,
gift_id=gift_id,
skin_id=skin_id,
)
result.update(
{
"gift_name": gift_name,
"gift_id": gift_id,
"skin_id": skin_id,
"gift_count": gift_count,
"esports_points_baseline": baseline_points,
}
)
refresh_errors = []
try:
result.update(self._refresh_account_gold_balance(client, account))
except Exception as exc:
refresh_errors.append(f"鱼翅余额: {exc}")
try:
points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
result.update(points_result)
result["esports_points_after_gift"] = points_result["esports_points"]
result["esports_points_changed"] = (
baseline_points is not None
and points_result["esports_points"] is not None
and points_result["esports_points"] != baseline_points
)
except Exception as exc:
refresh_errors.append(f"电竞积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.esports_bind_status = "esports_gift_donated"
account.updated_at = datetime.now(timezone.utc)
message = f"赠送{gift_name}成功: {gift_count}"
if account.gold_balance is not None:
message += f",鱼翅余额: {account.gold_balance}"
if account.esports_points is not None:
message += f",电竞积分: {account.esports_points}"
self._mark_task(db, task, "success", message, result)
def _execute_donate_esports_chicken_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""赠送冠军鸡腿。"""
self._execute_donate_esports_gift(
db,
task,
account,
cookie,
config,
gift_name="冠军鸡腿",
config_gift_id_key="esports_chicken_gift_id",
config_skin_id_key="esports_chicken_skin_id",
)
def _execute_donate_esports_firework_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""赠送冠军烟花。"""
self._execute_donate_esports_gift(
db,
task,
account,
cookie,
config,
gift_name="冠军烟花",
config_gift_id_key="esports_firework_gift_id",
config_skin_id_key="esports_firework_skin_id",
)
def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task)
gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
client = self._client(cookie)
ctn = None
baseline_points = account.points
try:
ctn = client.acf_ccn(refresh_subscribe=False)
baseline_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
db.commit()
baseline_points = baseline_result["points"]
except Exception as exc:
self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}")
result = client.donate_elite_gift(
gift_count=gift_count,
room_id=str(payload.get("room_id") or config["room_id"]),
gift_id=str(payload.get("gift_id") or config["gift_id"]),
skin_id=str(payload.get("skin_id") or config["skin_id"]),
)
result["gift_points_baseline"] = baseline_points
refresh_errors = []
try:
result.update(self._refresh_account_gold_balance(client, account))
except Exception as exc:
refresh_errors.append(f"鱼翅余额: {exc}")
try:
result.update(
self._refresh_points_after_elite_gift(
db,
task,
account,
client,
cookie,
ctn,
result,
baseline_points,
gift_count,
)
)
except Exception as exc:
refresh_errors.append(f"积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.bind_status = "gift_donated"
account.updated_at = datetime.now(timezone.utc)
message = f"赠送精英令成功: {gift_count}"
if account.gold_balance is not None:
message += f",鱼翅余额: {account.gold_balance}"
if account.points is not None:
message += f",积分: {account.points}"
if result.get("gift_points_target") is not None and not result.get("gift_points_confirmed"):
message += f"(未确认涨到 {result['gift_points_target']}"
self._mark_task(db, task, "success", message, result)
+365
View File
@@ -0,0 +1,365 @@
"""斗鱼任务执行器:鱼翅充值(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import re
import time
from decimal import Decimal
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError
from ..models import Account, DouyuTask
from .douyu_service import update_account_profile_from_cookie
class GoldMixin:
"""鱼翅充值域:扫码充值、供应商直充与到账轮询。"""
def _refresh_account_gold_balance(self, client: DouyuActivityClient, account: Account) -> dict:
"""刷新鱼翅和钱包兑换余额并写回账号表。"""
gold = client.gold_account()
exchange = client.exchange_balance()
account.gold_balance = self._to_int(gold.get("gold"))
account.exchange_balance = self._to_int(exchange.get("count"))
account.updated_at = datetime.now(timezone.utc)
return {
"gold_balance": account.gold_balance,
"exchange_balance": account.exchange_balance,
"gold": gold,
"exchange_balance_query": exchange,
}
def _wait_gold_balance_after_payment(
self,
db: Session,
task: DouyuTask,
account: Account,
client: DouyuActivityClient,
result: dict,
baseline_gold: int | None,
) -> bool:
"""等待鱼翅充值到账;余额变化后写回账号表。"""
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
result["payment_polling"] = True
result["baseline_gold_balance"] = baseline_gold
poll_count = 0
last_gold = baseline_gold
baseline_ready = baseline_gold is not None
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
balance_result = self._refresh_account_gold_balance(client, account)
db.commit()
poll_count += 1
last_gold = balance_result["gold_balance"]
result.update(balance_result)
result["payment_poll_count"] = poll_count
result["payment_polling"] = True
if not baseline_ready and last_gold is not None:
baseline_gold = last_gold
result["baseline_gold_balance"] = baseline_gold
baseline_ready = True
self._update_task_progress(
db,
task,
"running",
f"鱼翅支付码已生成,已记录当前余额 {last_gold},等待到账",
result,
)
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
break
continue
changed = last_gold is not None and (baseline_gold is None or last_gold != baseline_gold)
if changed:
result["payment_polling"] = False
result["gold_recharged"] = True
return True
self._update_task_progress(
db,
task,
"running",
f"鱼翅支付码已生成,等待到账(当前鱼翅 {last_gold if last_gold 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["gold_recharged"] = False
result["gold_balance"] = last_gold
return False
@staticmethod
def _supplier_value(payload: dict, *keys: str):
"""兼容供应商将订单字段放在响应根节点、data 或 result 节点。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
for source in (payload, data, result):
for key in keys:
if source.get(key) is not None:
return source[key]
return None
@classmethod
def _supplier_order_status(cls, payload: dict) -> int | None:
"""提取供应商订单状态,文档约定 0-4。"""
return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status"))
@classmethod
def _supplier_message(cls, payload: dict) -> str:
"""提取供应商可展示的业务消息。"""
value = cls._supplier_value(payload, "msg", "message", "error_msg")
return str(value or "")[:256]
@staticmethod
def _supplier_result(payload: dict) -> dict:
"""保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
result = {
key: value
for key, value in {**payload, **data, **response_result}.items()
if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"}
}
return result
@staticmethod
def _supplier_out_order_id(task: DouyuTask) -> str:
"""生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。"""
existing = str(task.supplier_out_order_id or "").strip()
if existing:
return existing
batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL"
return f"DYGF{batch_token}T{task.id}"
def _wait_supplier_gold_order(
self,
db: Session,
task: DouyuTask,
client: FishFinRechargeClient,
result: dict,
) -> int | None:
"""轮询供应商直充订单至结束状态。"""
order_no = str(result["out_order_id"])
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
poll_count = 0
result["payment_polling"] = True
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
# 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。
db.refresh(task)
if task.status in {"success", "failed"}:
callback_result = task.result if isinstance(task.result, dict) else result
result.update(callback_result)
result["payment_polling"] = False
return self._supplier_order_status(callback_result)
payload = client.query_order(order_no)
code = self._to_int(self._supplier_value(payload, "code"))
status = self._supplier_order_status(payload)
poll_count += 1
result.update({
"payment_poll_count": poll_count,
"supplier_code": code,
"supplier_order_status": status,
"supplier_order": self._supplier_result(payload),
})
if code != 200:
result["payment_polling"] = False
return status if status in {2, 3, 4} else 4
if status in {2, 3, 4}:
result["payment_polling"] = False
return status
self._update_task_progress(
db,
task,
"running",
f"供应商直充订单处理中(状态 {status if status is not None else '-'}",
result,
)
except FishFinRechargeError 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
return None
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)
channel = str(config.get("gold_recharge_channel") or "wechat_qr")
if channel == "supplier_api":
try:
self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount)
except FishFinRechargeError as exc:
self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"})
return
client = self._client(cookie)
baseline_gold = account.gold_balance
try:
baseline = self._refresh_account_gold_balance(client, account)
baseline_gold = baseline["gold_balance"]
db.commit()
except Exception as exc:
self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}")
result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"]))
account.bind_status = "gold_qr_created"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result)
recharged = self._wait_gold_balance_after_payment(db, task, account, client, result, baseline_gold)
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if recharged:
account.bind_status = "gold_recharged"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
f"鱼翅已到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
result,
)
return
self._mark_task(
db,
task,
"failed",
f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
result,
)
def _execute_create_gold_supplier_order(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
amount: int,
) -> None:
"""创建供应商鱼翅直充订单并轮询订单状态。"""
product_id = str(config.get("gold_api_product_id") or "").strip()
template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip()
if not product_id:
raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID")
# 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。
update_account_profile_from_cookie(account, cookie)
recharge_account = str(account.nickname or "").strip()
if not recharge_account:
raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充")
# 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。
order_no = self._supplier_out_order_id(task)
task.supplier_out_order_id = order_no
db.commit()
# pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。
pay_amount = Decimal(amount)
def trace(event: dict) -> None:
"""将脱敏供应商协议信息输出到任务日志,便于线上联调。"""
stage = event.get("stage")
if stage == "request":
params = event.get("params") or {}
self._push_log(
"info",
"供应商直充 | 下单 "
f"| 外部单号={params.get('out_order_id') or '-'} "
f"| 数量={params.get('buy_num') or '-'} "
f"| 金额={params.get('pay_amount') or '-'} "
f"| 商品={params.get('product_id') or '-'}",
)
if event.get("json_body"):
self._push_log(
"debug",
"供应商协议 | 请求 "
f"| {event.get('method')} {event.get('path')} "
f"| 签名摘要={event.get('sign_digest')} "
f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}",
)
elif stage == "response":
status = self._to_int(event.get("order_status"))
status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"}
status_text = status_labels.get(status, "-")
reason = str(event.get("fail_reason") or event.get("message") or "-")
self._push_log(
"info",
"供应商直充 | 响应 "
f"| HTTP={event.get('http_status') or '-'} "
f"| 业务码={event.get('code') or '-'} "
f"| 外部单号={event.get('out_order_id') or '-'} "
f"| 供应商单号={event.get('order_id') or '-'} "
f"| 状态={status_text} "
f"| 提示={reason}",
)
if event.get("response_body"):
self._push_log(
"debug",
"供应商协议 | 响应 "
f"| HTTP={event.get('http_status')} "
f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}",
)
client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace)
order_payload = client.create_order(
buy_num=amount,
pay_amount=pay_amount,
out_order_id=order_no,
product_id=product_id,
recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}],
order_type=0,
notify_url=client.config.notify_url,
)
code = self._to_int(self._supplier_value(order_payload, "code"))
status = self._supplier_order_status(order_payload)
result = {
"recharge_channel": "supplier_api",
"out_order_id": order_no,
"order_id": self._supplier_value(order_payload, "order_id", "orderId"),
"recharge_account": recharge_account,
"douyu_uid": str(account.uid or "").strip(),
"buy_num": amount,
"product_id": product_id,
"pay_amount": format(pay_amount.normalize(), "f"),
"order_type": 0,
"supplier_code": code,
"supplier_order_status": status,
"supplier_order": self._supplier_result(order_payload),
}
if code != 200:
self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result)
return
account.bind_status = "gold_api_order_created"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result)
if status not in {2, 3, 4}:
status = self._wait_supplier_gold_order(db, task, client, result)
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if status == 2:
account.bind_status = "gold_recharged"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", "供应商直充成功", result)
return
if status in {3, 4}:
self._mark_task(db, task, "failed", "供应商直充失败", result)
return
self._mark_task(db, task, "failed", "供应商直充订单查询超时", result)
def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
result = self._refresh_account_gold_balance(client, account)
account.bind_status = "gold_balance_queried"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}",
result,
)
+485
View File
@@ -0,0 +1,485 @@
"""斗鱼任务执行器:商城兑换(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import random
import time
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask
# 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打)
DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2)
DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE = (2.0, 4.0)
DOUYU_EXCHANGE_LOCK_TTL_SECONDS = 300
DOUYU_EXCHANGE_RATE_LIMIT_HINTS = ("火爆", "频繁", "稍后", "太热", "限流", "繁忙", "人多", "排队", "手慢")
DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS = (30, 60, 90)
DOUYU_EXCHANGE_GENERIC_BACKOFFS = (2, 5, 10, 20)
class GoodsMixin:
"""商城兑换域:商品刷新、锁单/支付/兑换、兑换节奏与退避重试。"""
def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"])
goods = result["goods"]
self._upsert_goods(db, goods)
account.bind_status = account.bind_status or "active"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", f"已刷新商品 {len(goods)}", {"goods_count": len(goods), "goods": goods})
def _execute_refresh_esports_goods(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""刷新电竞手册皮肤商城快照。"""
client = self._client(cookie)
result = client.list_esports_goods(
manual_id=str(config["esports_manual_id"]),
rid=str(config["room_id"]),
)
goods = result["goods"]
self._upsert_esports_goods(db, goods)
account.esports_bind_status = "esports_goods_refreshed"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
"success",
f"已刷新电竞皮肤 {len(goods)}",
{"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods},
)
def _execute_lock_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task)
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not commodity_id:
self._mark_task(db, task, "failed", "请选择锁定商品")
return
try:
num = int(payload.get("num") or 1)
except (TypeError, ValueError):
num = 1
client = self._client(cookie)
result = client.create_exchange_order(
manual_id=str(config["manual_id"]),
rid=str(config["rid"]),
commodity_id=commodity_id,
num=max(1, num),
)
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
account.bind_status = "goods_locked"
account.updated_at = datetime.now(timezone.utc)
expire_seconds = result.get("expire_seconds")
expire_text = f"{expire_seconds} 秒内有效" if expire_seconds else ""
self._mark_task(
db,
task,
"success",
f"锁单成功: {(goods.name if goods else '') or commodity_id}(订单 {result['order_id']}{expire_text}",
{
"goods": goods.raw if goods else None,
"game_name": account.game_name or "",
"game_channel": account.game_channel or "",
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
**result,
},
)
def _execute_pay_locked_order(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
payload = self._task_payload(task)
order_id = str(payload.get("order_id") or payload.get("orderId") or "").strip()
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not order_id:
self._mark_task(db, task, "failed", "锁单订单号不能为空")
return
client = self._client(cookie)
payment = client.pay_exchange_order(
manual_id=str(config["manual_id"]),
order_id=order_id,
rid=str(config.get("rid") or ""),
)
goods = None
if commodity_id:
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
account.bind_status = "goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
result = {
"commodity_id": commodity_id,
"order_id": order_id,
"exchange_id": payment["exchange_id"],
"commodity_image": payment["commodity_image"],
"exchange_num": payment["exchange_num"],
"payment": payment,
"goods": goods.raw if goods else None,
"game_name": account.game_name or "",
"game_channel": account.game_channel or "",
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
}
try:
ctn = client.acf_ccn(refresh_subscribe=False)
result.update(self._refresh_account_points(client, account, cookie, ctn=ctn))
except Exception as exc:
self._push_log("warning", f"支付锁单后刷新积分失败: {exc}")
self._mark_task(
db,
task,
"success",
f"锁单支付成功: {(goods.name if goods else '') or commodity_id or order_id}",
result,
)
@staticmethod
def _is_rate_limited_error(message: str) -> bool:
"""识别频控/火爆类错误文案,命中时按长退避重试。"""
return any(hint in message for hint in DOUYU_EXCHANGE_RATE_LIMIT_HINTS)
def _pay_exchange_with_backoff(
self,
client: DouyuActivityClient,
*,
manual_id: str,
order_id: str,
rid: str,
locked_at: float,
) -> dict | None:
"""支付锁单,按错误类型退避重试(不再 0.3s 无脑连打)。
频控/火爆类错误:30/60/90 秒退避,最多 4 次尝试且不超出锁单有效期;
其他错误:2/5/10/20 秒退避,最多 5 次尝试。
"""
for attempt in range(6):
if self._stop.is_set():
return None
try:
return client.pay_exchange_order(
manual_id=manual_id,
order_id=order_id,
rid=rid,
)
except DouyuActivityError as exc:
last_error = str(exc)
if self._is_rate_limited_error(last_error):
if attempt >= len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS):
raise DouyuActivityError(
f"锁单 {order_id} 支付被频控拦截(已退避重试{attempt}次): {last_error}"
) from exc
backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[attempt]
else:
if attempt >= len(DOUYU_EXCHANGE_GENERIC_BACKOFFS):
raise DouyuActivityError(
f"锁单 {order_id} 支付失败(已重试{attempt}次): {last_error}"
) from exc
backoff = DOUYU_EXCHANGE_GENERIC_BACKOFFS[attempt]
# 超出锁单有效期(服务端 300s)前留 15s 余量,放弃继续重试
remaining = locked_at + DOUYU_EXCHANGE_LOCK_TTL_SECONDS - time.time()
if remaining - backoff < 15:
raise DouyuActivityError(
f"锁单 {order_id} 支付失败且临近过期({int(remaining)}s): {last_error}"
) from exc
kind = "频控" if self._is_rate_limited_error(last_error) else "错误"
self._push_log(
"info",
f" 锁单 {order_id} 支付{kind}退避 {backoff:.0f}s 后重试 {attempt + 1}/"
f"{len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS) if self._is_rate_limited_error(last_error) else len(DOUYU_EXCHANGE_GENERIC_BACKOFFS) + 1}: {last_error}",
)
if not self._sleep_interruptible(backoff):
return None
return None
def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task)
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not commodity_id:
self._mark_task(db, task, "failed", "请选择兑换商品")
return
client = self._client(cookie)
manual_id = str(config["manual_id"])
rid = str(config.get("rid") or "")
result: dict = {"commodity_id": commodity_id}
# ---- 对齐浏览器:先查手册状态 + 商品详情,按页面状态机选兑换链路 ----
manual_type = None
try:
user_info = client.elite_user_info(manual_id=manual_id, rid=rid)
manual_type = user_info.get("manual_type")
except DouyuActivityError as exc:
self._push_log("warning", f"查询手册状态失败,按经典流程继续: {exc}")
detail = None
try:
detail = client.query_goods_detail(manual_id=manual_id, commodity_id=commodity_id, rid=rid)["detail"]
except DouyuActivityError as exc:
self._push_log("warning", f"查询商品详情失败,按经典流程继续: {exc}")
batch_num_limit = None
if detail and int(detail.get("batchExchange") or 0) > 0:
try:
batch_num_limit = client.batch_exchange_limit(
manual_id=manual_id, commodity_id=commodity_id, rid=rid
)["limit"]
except DouyuActivityError as exc:
self._push_log("warning", f"查询批量兑换上限失败: {exc}")
plan = DouyuActivityClient.resolve_exchange_plan(
detail or {}, manual_type=manual_type, batch_num_limit=batch_num_limit
)
result["plan"] = plan
if detail:
result["detail"] = detail
action = plan["action"]
def finish_failed(message: str) -> None:
self._mark_task(
db, task, "failed", message,
{"commodity_id": commodity_id, "plan": plan, "detail": detail},
)
if action in ("blocked", "wait"):
finish_failed(f"兑换失败: {plan['text']}")
return
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
name = (goods.name if goods else "") or commodity_id
if action == "subscribe":
try:
client.subscribe_commodity(manual_id=manual_id, commodity_id=commodity_id)
except DouyuActivityError as exc:
finish_failed(f"预约到货失败: {exc}")
return
account.bind_status = "goods_subscribed"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db, task, "success", f"已预约到货: {name} ({plan['text']})",
{"commodity_id": commodity_id, "plan": plan, "detail": detail},
)
return
if action == "pre_exchange":
try:
check = client.pre_exchange_check(manual_id=manual_id, commodity_id=commodity_id)
user_status = int((check.get("data") or {}).get("userStatus") or 0)
if user_status == 1 and not plan["pre_exchange"]:
self._mark_task(
db, task, "success", f"已预兑: {name},等待开放后继续兑换",
{"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check},
)
return
confirm = client.confirm_pre_exchange(manual_id=manual_id, commodity_id=commodity_id, rid=rid)
except DouyuActivityError as exc:
finish_failed(f"预兑失败: {exc}")
return
account.bind_status = "goods_pre_exchanged"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db, task, "success", f"预兑成功: {name},开放后可继续兑换",
{"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check, "confirm": confirm},
)
return
# ---- 经典链路:锁单 -> 人类节奏等待 -> 支付(错误分类退避重试)----
try:
num = max(1, int(payload.get("num") or 1))
except (TypeError, ValueError):
num = 1
num = min(num, int(plan.get("max_num") or 1))
# 少量随机抖动(浏览器点击商品到锁单之间有人工间隔)
self._push_log("info", f" 兑换 {name}: {plan['text']}")
if not self._sleep_interruptible(random.uniform(*DOUYU_EXCHANGE_PRE_CREATE_JITTER)):
self._mark_task(db, task, "stopped", "任务已停止", result)
return
try:
locked = client.create_exchange_order(
manual_id=manual_id,
rid=rid,
commodity_id=commodity_id,
num=num,
)
except DouyuActivityError as exc:
# 锁单本身被频控时做一次长退避重试,避免无脑连打
backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[0] if self._is_rate_limited_error(str(exc)) else 3
self._push_log("info", f" 锁定兑换商品失败({exc}){backoff:.0f}s 后重试一次")
if not self._sleep_interruptible(backoff):
self._mark_task(db, task, "stopped", "任务已停止", result)
return
try:
locked = client.create_exchange_order(
manual_id=manual_id,
rid=rid,
commodity_id=commodity_id,
num=num,
)
except DouyuActivityError as exc2:
finish_failed(f"锁定兑换商品失败: {exc2}")
return
result.update({**locked, "lock_order": locked, "goods": goods.raw if goods else None})
# 锁单成功后按浏览器节奏(抓包实测 2.24s~4.2s)等待再支付
delay = random.uniform(*DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE)
self._push_log("info", f" 锁单成功(订单 {locked['order_id']}){delay:.1f}s 后支付")
if not self._sleep_interruptible(delay):
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
return
try:
payment = self._pay_exchange_with_backoff(
client,
manual_id=manual_id,
order_id=locked["order_id"],
rid=rid,
locked_at=locked["locked_at"],
)
except DouyuActivityError as exc:
finish_failed(str(exc))
return
if payment is None:
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
return
result.update({
"order_id": locked["order_id"],
"exchange_id": payment["exchange_id"],
"commodity_image": payment["commodity_image"] or locked["commodity_image"],
"exchange_num": payment["exchange_num"],
"payment": payment,
})
account.bind_status = "goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
points_refresh = None
try:
ctn = client.acf_ccn(refresh_subscribe=False)
points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn)
except Exception as exc:
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
self._mark_task(
db,
task,
"success",
f"兑换成功: {name}",
{
"goods": goods.raw if goods else None,
"game_name": account.game_name or "",
"game_channel": account.game_channel or "",
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
**result,
**(points_refresh or {}),
},
)
def _execute_exchange_esports_goods(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""兑换电竞手册皮肤,并同步电竞积分。"""
payload = self._task_payload(task)
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not commodity_id:
self._mark_task(db, task, "failed", "请选择电竞皮肤")
return
try:
quantity = max(1, int(payload.get("quantity") or payload.get("num") or 1))
except (TypeError, ValueError):
self._mark_task(db, task, "failed", "兑换数量必须是正整数")
return
manual_id = str(config.get("esports_manual_id") or "").strip()
room_id = str(config.get("room_id") or "").strip()
if not manual_id or not room_id:
self._mark_task(db, task, "failed", "请先配置电竞手册 manualID 和房间 ID")
return
client = self._client(cookie)
baseline_points = account.esports_points
try:
baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id)
baseline_points = baseline["esports_points"]
db.commit()
except Exception as exc:
self._push_log("warning", f"兑换电竞皮肤前刷新积分失败: {exc}")
result = client.exchange_esports_goods(
manual_id=manual_id,
rid=room_id,
commodity_id=commodity_id,
quantity=quantity,
)
goods = (
db.query(DouyuEsportsGoodsSnapshot)
.filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id)
.first()
)
result["goods"] = goods.raw if goods else None
result["esports_points_baseline"] = baseline_points
try:
points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
result.update(points_result)
result["esports_points_after_exchange"] = points_result["esports_points"]
except Exception as exc:
result["esports_points_refresh_error"] = str(exc)
account.esports_bind_status = "esports_goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
name = (goods.name if goods else "") or commodity_id
message = f"兑换电竞皮肤成功: {name}"
if quantity > 1:
message += f" x{quantity}"
if account.esports_points is not None:
message += f",电竞积分: {account.esports_points}"
result["game_name"] = account.esports_game_name or ""
result["game_channel"] = account.esports_game_channel or ""
result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}"
self._mark_task(db, task, "success", message, result)
def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"]))
limited = result["limited_goods"]
names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")]
message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}"
account.bind_status = "limited_goods_queried"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited})
def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
result = client.exchange_records(manual_id=str(config["manual_id"]))
records = result["records"]
account.bind_status = "exchange_records_queried"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", f"兑换记录 {len(records)}" if records else "暂无兑换记录", result)
def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
token = client.csrf_token()
account.bind_status = "csrf_token_ready"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie})
+326
View File
@@ -0,0 +1,326 @@
"""斗鱼任务执行器:手册开通与积分(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuTask
from .douyu_service import account_uid, update_account_profile_from_cookie
class ManualMixin:
"""手册域:精英/电竞手册开通支付、积分查询与到账轮询。"""
def _refresh_account_points(
self,
client: DouyuActivityClient,
account: Account,
cookie: str,
*,
ctn: str | None = None,
) -> dict:
"""刷新账号积分并写回账号表。"""
uid = account_uid(account, cookie)
if not uid:
raise DouyuActivityError("Cookie 中没有 acf_uid,无法查询积分")
ctn_value = ctn or client.acf_ccn(refresh_subscribe=False)
result = client.query_points(uid=uid, ctn=ctn_value)
points = self._to_int(result.get("points"))
account.uid = uid
account.points = points
update_account_profile_from_cookie(account, cookie)
account.updated_at = datetime.now(timezone.utc)
return {"points": points, "points_query": result}
def _wait_points_after_payment(
self,
db: Session,
task: DouyuTask,
account: Account,
client: DouyuActivityClient,
cookie: str,
ctn: str,
result: dict,
) -> bool:
"""等待宝典支付到账;积分达到 300 视为开通成功。"""
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
result["payment_polling"] = True
result["payment_target_points"] = 300
poll_count = 0
last_points = None
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
points_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
db.commit()
poll_count += 1
last_points = points_result["points"]
result.update(points_result)
result["payment_poll_count"] = poll_count
result["payment_polling"] = True
if last_points is not None and last_points >= 300:
result["payment_polling"] = False
result["elite_opened"] = True
return True
self._update_task_progress(
db,
task,
"running",
f"精英宝典支付码已生成,等待开通到账(当前积分 {last_points if last_points 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["elite_opened"] = False
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 _execute_create_elite_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 = self._confirm_act_alias(config) or self._bind_qr_act_alias(config)
if not act_alias:
self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias")
return
result = client.create_elite_qr(
ctn=ctn,
act_alias=act_alias,
amount=int(config["elite_amount"]),
room_id=str(config["room_id"]),
)
account.bind_status = "elite_qr_created"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(db, task, "running", "精英宝典支付码已生成,等待开通到账", result)
opened = self._wait_points_after_payment(db, task, account, client, cookie, ctn, result)
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if opened:
account.bind_status = "elite_opened"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", f"精英宝典已开通,积分: {account.points}", result)
return
self._mark_task(
db,
task,
"failed",
f"未检测到精英宝典开通到账,当前积分: {account.points if account.points is not None else '-'}",
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_query_esports_points(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询电竞手册积分。"""
manual_id = str(config.get("esports_manual_id") or "").strip()
if not manual_id:
self._mark_task(db, task, "failed", "请先配置电竞手册 manualID")
return
client = self._client(cookie)
result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
account.esports_bind_status = "esports_points_queried"
account.updated_at = datetime.now(timezone.utc)
points = result["esports_points"]
self._mark_task(db, task, "success", f"电竞积分: {points if points is not None else '-'}", result)
def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
client = self._client(cookie)
ctn = client.acf_ccn(refresh_subscribe=False)
result = self._refresh_account_points(client, account, cookie, ctn=ctn)
account.bind_status = "points_queried"
account.updated_at = datetime.now(timezone.utc)
points = result["points"]
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
+528
View File
@@ -0,0 +1,528 @@
"""斗鱼任务执行器:和平小店(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuTask, DouyuXpdGoodsSnapshot
DOUYU_XPD_BIND_POLL_SECONDS = 300
DOUYU_XPD_BIND_POLL_INTERVAL = 5
class XpdMixin:
"""和平小店域:绑定、商品、余额、碎片与兑换。"""
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,未知回退已存值。"""
raw_area = role.get("area")
if raw_area not in (None, ""):
try:
area_id = int(raw_area)
except (TypeError, ValueError):
area_id = 0
if area_id > 0:
return area_id
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_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()
# 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功
try:
before = client.xpd_bind_info(act_alias=act_alias)
result["before_bound"] = bool(before.get("bind_role"))
result["before_role_name"] = str(before.get("role_name") or "")
result["before_area_name"] = str(before.get("area_name") or "")
result["before_plat_name"] = str(before.get("plat_name") or "")
except Exception:
result["before_bound"] = False
result["before_role_name"] = ""
result["bind_polling"] = True
self._update_task_progress(
db,
task,
"running",
"二维码已生成,请微信扫码在小程序中绑定角色",
result,
)
state = self._wait_xpd_bind(db, task, client, act_alias, result)
result["bind_polling"] = False
if state == "stopped":
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if state == "pending":
role_text = str(result.get("role_name") or "-")
self._mark_task(db, task, "success", f"已识别角色: {role_text},待确认绑定", result)
return
self._mark_task(
db,
task,
"failed",
"未检测到小店绑定(二维码仍有效,可再次生成后扫码)",
result,
)
def _wait_xpd_bind(
self,
db: Session,
task: DouyuTask,
client: DouyuActivityClient,
act_alias: str,
result: dict,
) -> str:
"""轮询 bindInfo 检测绑定/换绑角色,识别到后停在"待确认",不自动回写账号。
- 绑定前未绑定:检测到 bind_role=1 即识别到待确认角色
- 绑定前已绑定(换绑):检测到角色名变化才算换绑完成,角色不变继续等
返回 "pending"=已识别待确认角色, "stopped"=任务停止, "timeout"=超时未识别。
"""
before_bound = bool(result.get("before_bound"))
before_role_name = str(result.get("before_role_name") or "")
deadline = time.monotonic() + DOUYU_XPD_BIND_POLL_SECONDS
poll_count = 0
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
info = client.xpd_bind_info(act_alias=act_alias)
poll_count += 1
result["bind_poll_count"] = poll_count
result["bind_polling"] = True
role_name = str(info.get("role_name") or "")
bound_now = bool(info.get("bind_role"))
changed = before_bound and bool(role_name) and role_name != before_role_name
if (not before_bound and bound_now and role_name) or changed:
result.update({key: value for key, value in info.items() if key != "raw"})
result["bind_polling"] = False
result["xpd_pending_confirm"] = True
return "pending"
self._update_task_progress(
db,
task,
"running",
f"等待扫码绑定(第 {poll_count} 次)",
result,
)
except Exception as exc:
poll_count += 1
result["bind_poll_count"] = poll_count
result["bind_poll_error"] = str(exc)
self._update_task_progress(
db,
task,
"running",
f"等待扫码绑定: {exc}",
result,
)
if self._stop.wait(DOUYU_XPD_BIND_POLL_INTERVAL):
break
result["bind_polling"] = False
return "stopped" if self._stop.is_set() else "timeout"
def _execute_confirm_xpd_bind(
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 not result.get("bind_role") or not role_name:
account.xpd_bind_status = "xpd_not_bound"
account.updated_at = datetime.now(timezone.utc)
db.commit()
self._mark_task(db, task, "failed", "尚未检测到小店绑定角色,请先扫码绑定", result)
return
# 优先用完整角色信息回写(与查询角色一致),失败时回退 bindInfo 角色名
try:
ctx = self._xpd_role_context(client, config)
role = ctx["role"]
if role.get("role_id"):
self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account))
else:
account.xpd_game_name = role_name
account.updated_at = datetime.now(timezone.utc)
except Exception:
account.xpd_game_name = role_name
account.updated_at = datetime.now(timezone.utc)
account.xpd_bind_status = "xpd_bound"
account.updated_at = datetime.now(timezone.utc)
db.commit()
result["xpd_pending_confirm"] = False
result["xpd_bound"] = True
self._mark_task(db, task, "success", f"小店绑定成功: {role_name}", result)
def _execute_query_xpd_bind_info(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询和平小店绑定信息(bindInfo)。
仅查询展示,不回写账号;确认绑定由 confirm_xpd_bind 任务完成。
"""
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)
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(
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)
role_plat = role.get("plat_id")
plat = str(role_plat) if role_plat not in (None, "") else "1"
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=plat,
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_query_xpd_fragments(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询和平小店扭蛋碎片数量。
优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色,
保证已绑定账号仍可查询。
"""
client = self._client(cookie)
act_id = str(config["xpd_act_id"])
embed_query: dict = {}
openid = str(account.xpd_openid or "")
roleid = str(account.xpd_role_id or "")
stored_plat = account.xpd_plat_id
plat = str(stored_plat) if stored_plat is not None else "1"
areaid = str(account.xpd_area_id or 1)
role: dict = {}
try:
ctx = self._xpd_role_context(client, config)
embed_query = ctx["embed"]["query"]
role = ctx["role"] if isinstance(ctx.get("role"), dict) else {}
if role.get("role_id"):
role_area = self._xpd_area_id(role, account)
openid = str(role.get("game_open_id") or "") or openid
roleid = str(role.get("role_id") or "") or roleid
role_plat = role.get("plat_id")
plat = str(role_plat) if role_plat not in (None, "") else plat
areaid = str(role_area) or areaid
self._apply_xpd_role_to_account(account, role, role_area)
except Exception:
pass
if not openid or not roleid:
self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定")
return
result = client.xpd_fragments(
embed_query=embed_query,
act_id=act_id,
openid=openid,
roleid=roleid,
plat=plat,
areaid=areaid,
)
fragments = result.get("fragments")
account.xpd_fragments = fragments
account.xpd_bind_status = "xpd_fragments_queried"
account.updated_at = datetime.now(timezone.utc)
db.commit()
if fragments is None:
self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量")
return
self._mark_task(
db,
task,
"success",
f"小店扭蛋碎片: {fragments}",
{"fragments": fragments, "role": role, "area_id": int(areaid)},
)
def _execute_query_xpd_purchase_records(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""查询和平小店道聚城购买记录。"""
client = self._client(cookie)
embed = client.xpd_embed_query(
act_alias=str(config["xpd_act_alias"]),
rid=str(config["xpd_rid"]),
)
result = client.xpd_purchase_records(
embed_query=embed["query"],
act_id=str(config["xpd_act_id"]),
)
account.xpd_bind_status = "xpd_purchase_records_queried"
account.updated_at = datetime.now(timezone.utc)
total = result.get("total") or len(result.get("records") or [])
self._mark_task(
db,
task,
"success",
f"小店兑换记录 {total}" if total else "暂无小店兑换记录",
result,
)
def _execute_exchange_xpd_goods(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""兑换和平小店商品,使用本次签发的道聚城短时授权。"""
payload = self._task_payload(task)
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not commodity_id:
self._mark_task(db, task, "failed", "请选择小店商品")
return
try:
pay_type = int(payload.get("pay_type") or 1)
except (TypeError, ValueError):
self._mark_task(db, task, "failed", "兑换货币参数无效")
return
if pay_type not in (1, 5):
self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片")
return
goods = (
db.query(DouyuXpdGoodsSnapshot)
.filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id)
.first()
)
if not goods:
self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表")
return
goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {}
goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot
price_key = "iPrice" if pay_type == 1 else "iJb2Price"
price = self._to_int(goods_raw.get(price_key))
if price is None:
price = goods.price if pay_type == 1 else None
if price is None or price <= 0:
currency = "点券" if pay_type == 1 else "扭蛋碎片"
self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换")
return
# iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。
if goods.goods_left == 0:
self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试")
return
client = self._client(cookie)
embed = client.xpd_embed_query(
act_alias=str(config["xpd_act_alias"]),
rid=str(config["xpd_rid"]),
)
role: dict = {}
try:
role = client.xpd_get_role(
embed_query=embed["query"],
act_id=str(config["xpd_act_id"]),
rid=str(config["xpd_rid"]),
)
if role.get("role_id"):
self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account))
except DouyuActivityError as exc:
self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}")
if not role.get("role_id") and not account.xpd_role_id:
self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定")
return
result = client.xpd_exchange_goods(
embed_query=embed["query"],
act_id=str(config["xpd_act_id"]),
rid=str(config["xpd_rid"]),
commodity_id=commodity_id,
price=price,
picture=str(goods_raw.get("sGoodsPic") or ""),
pay_type=pay_type,
action_id=str(goods_raw.get("iActionId") or ""),
)
if pay_type == 1 and result.get("new_balance") is not None:
account.xpd_balance = result["new_balance"]
if pay_type == 5 and result.get("new_balance") is not None:
account.xpd_fragments = result["new_balance"]
account.xpd_bind_status = "xpd_goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
db.commit()
currency = "点券" if pay_type == 1 else "扭蛋碎片"
display_role = role.get("role_name") or account.xpd_game_name or ""
channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q"
result.update({
"goods": {**goods_raw, "commodityName": goods.name or ""},
"game_name": display_role,
"game_channel": channel,
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
"currency": currency,
})
self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}{price}{currency}", result)