Files
live-hub-py/web/backend/services/douyu_runner_gold.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

478 lines
20 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 re
import time
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any, cast
from sqlalchemy.orm import Session
from core.douyu import (
DouyuActivityClient,
FishFinRechargeClient,
FishFinRechargeConfig,
FishFinRechargeError,
)
from ..models import Account, DouyuTask
from .business_events import record_recharge
from .douyu_runner_core import DOUYU_PAYMENT_POLL_INTERVAL, DOUYU_PAYMENT_POLL_SECONDS
from .douyu_service import update_account_profile_from_cookie
class GoldMixin:
"""鱼翅充值域:扫码充值、供应商直充与到账轮询。"""
if TYPE_CHECKING:
_stop: Any
@staticmethod
def _to_int(value: Any) -> int | None: ...
def _client(self, cookie: str) -> DouyuActivityClient: ...
def _task_payload(self, task: DouyuTask) -> dict: ...
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
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(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
# ⚠️ 判据必须是 `>`(余额**增加**)而不是 `!=`。
# 原来用 `!=`,于是余额**下降**也算「到账」:批内并发(concurrency
# 最高 10)时同账号的送礼任务会消耗鱼翅,余额一变就被这个充值任务
# 读成「鱼翅已到账」,往台账里记一笔根本没发生的充值。
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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_raw = payload.get("data")
data = cast(dict[str, Any], data_raw) if isinstance(data_raw, dict) else {}
result_raw = payload.get("result")
result = (
cast(dict[str, Any], result_raw) if isinstance(result_raw, 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_raw = payload.get("data")
data = cast(dict[str, Any], data_raw) if isinstance(data_raw, dict) else {}
result_raw = payload.get("result")
response_result = (
cast(dict[str, Any], result_raw) if isinstance(result_raw, 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}")
result = client.create_gold_qr(
amount=amount, pay_type=int(config["gold_pay_type"])
)
account.updated_at = datetime.now(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.updated_at = datetime.now(UTC)
# 金额用**本地变量 amount**:二维码链路的 result 里没有金额
# (create_gold_qr 只返回 pay_url/token/raw),旧收集器在这里恒写
# amount=None,导致 accounts.recharge_total 系统性少计。
record_recharge(
db,
account,
task_id=task.id,
amount=amount,
channel=channel,
balance_after=account.gold_balance,
confirmed=True,
)
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 if status is not None else -1, "-"
)
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.updated_at = datetime.now(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.updated_at = datetime.now(UTC)
# 幂等键与**供应商回调**(routers/douyu.py)完全一致 —— 两条路径都可能
# 观测到同一笔成功(_wait_supplier_gold_order 会采纳回调结果再 mark 一次),
# 同一个 source_ref 天然去重。
record_recharge(
db,
account,
task_id=task.id,
amount=amount,
order_id=str(result.get("out_order_id") or ""),
supplier_order_id=str(result.get("order_id") or ""),
channel="supplier_api",
douyu_uid=str(result.get("douyu_uid") or ""),
balance_after=account.gold_balance,
# 供应商终态成功 ≠ 斗鱼余额已变,如实标注
confirmed=False,
)
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.updated_at = datetime.now(UTC)
self._mark_task(
db,
task,
"success",
f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}",
result,
)