Files
live-hub-py/web/backend/services/douyu_runner_goods.py
T
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。

## 收敛成四值 + 三列一义

`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:

| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |

- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
  传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
  (动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。

## 回填不猜(设计文档 §5.5)

只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。

## ⚠️ 顺带救回一个差点被我自己丢掉的状态

「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。

## 前端不再从任务日志反推账号状态

工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。

## 踩到并修掉的东西

- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
  测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
  归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
  已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
  改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
  干脆改成源码级解析比对(不造数据)。

## 验证

- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
  12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

703 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼任务执行器:商城兑换(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import random
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask
from .business_events import record_exchange
# 兑换节奏与重试(对齐 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:
"""商城兑换域:商品刷新、锁单/支付/兑换、兑换节奏与退避重试。"""
if TYPE_CHECKING:
_stop: Any
def _client(self, cookie: str) -> DouyuActivityClient: ...
def _task_payload(self, task: DouyuTask) -> dict: ...
def _upsert_goods(self, db: Session, goods: list[dict]) -> None: ...
def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None: ...
def _refresh_account_points(
self,
client: DouyuActivityClient,
account: Account,
cookie: str,
*,
ctn: str = "",
) -> dict: ...
def _refresh_esports_handbook(
self, client: DouyuActivityClient, account: Account, *, manual_id: str
) -> dict: ...
def _sleep_interruptible(self, seconds: float) -> bool: ...
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
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.updated_at = datetime.now(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.updated_at = datetime.now(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.updated_at = datetime.now(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.updated_at = datetime.now(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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"支付锁单后刷新积分失败: {exc}")
# 两步式兑换(lock_goods → pay_locked_order)的**支付**这一步才是真兑换。
# 这一步此前从不发事件,时间线上只有「锁单」没有「支付」。
# `pay_exchange_order` 的响应里没有 score,用商品快照上的标价。
score = None
if goods is not None and isinstance(goods.score, (int, float)):
num = payment.get("exchange_num")
score = int(goods.score) * (
int(num) if isinstance(num, (int, float)) else 1
)
record_exchange(
db,
account,
task_id=task.id,
name=(goods.name if goods else "") or commodity_id or order_id,
score=score,
commodity_id=commodity_id,
order_id=order_id,
exchange_id=str(payment.get("exchange_id") or ""),
game_name=account.game_name or "",
game_channel=account.game_channel or "",
points_after=account.points,
scope="elite",
)
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.updated_at = datetime.now(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.updated_at = datetime.now(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.updated_at = datetime.now(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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
# ⚠️ **只有这一条 success 是真的兑换**。同一个 handler 里还有三条
# 「预约到货 / 已预兑 / 预兑确认」的 success —— 它们不消耗积分、也没换到东西,
# 所以**不发事件**。旧收集器只按 status=="success" 收集,导致每预约一次
# 就生成一条金额为空的 exchange,把 accounts 的 exchange_count 灌大。
record_exchange(
db,
account,
task_id=task.id,
name=name,
score=locked.get("score"),
commodity_id=commodity_id,
order_id=str(locked.get("order_id") or ""),
exchange_id=str(payment.get("exchange_id") or ""),
game_name=account.game_name or "",
game_channel=account.game_channel or "",
points_after=(points_refresh or {}).get("points"),
scope="elite",
)
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
result["esports_points_refresh_error"] = str(exc)
account.updated_at = datetime.now(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}"
)
# 电竞皮肤兑换 —— **此前从不发事件**(不在旧收集器的 allowlist 里),
# 时间线上一直是空白。
# 消耗的电竞积分用「兑换前后积分差」实测,而不是读猜测的字段。
after_points = result.get("esports_points_after_exchange")
spent = (
int(baseline_points) - int(after_points)
if isinstance(baseline_points, (int, float))
and isinstance(after_points, (int, float))
else None
)
record_exchange(
db,
account,
task_id=task.id,
name=name,
score=spent if spent and spent > 0 else None,
commodity_id=commodity_id,
game_name=account.esports_game_name or "",
game_channel=account.esports_game_channel or "",
points_after=account.esports_points,
scope="esports",
)
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.updated_at = datetime.now(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.updated_at = datetime.now(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.updated_at = datetime.now(UTC)
self._mark_task(
db,
task,
"success",
"获取 csrf_token 成功",
{"csrf_token": token, "cookie": client.cookie},
)