Files
live-hub-py/web/backend/services/douyu_runner_donate.py
T
yml2213 0b1fc1dee0 feat(events): 补齐资产【出项】—— 开通金额、鱼翅消耗、积分到期(C 最全)
审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** ——
充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记,
于是「一共充了多少」算得出来,「花哪儿了」算不出来。

按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。

## A. 开通类事件补金额

`elite_opened` / `esports_opened` 以前只记「开通后的积分余额」,不记**花了多少**。
现在补 `amount = -(开通前后积分差)`,并用**实测余额差**而不是"开通要多少分"的常量
(活动价、折扣会让实际扣减不同)。任一侧拿不到就不写金额,不编数字。

## B. 鱼翅消耗事件(新 `gold_spent`)

送礼以前只发「积分到账」,**花掉的鱼翅没有任何记录**。现在:
- 送礼**前后各刷一次**鱼翅(原来只在送礼后刷),取**实测余额差**;
- 发 `gold_spent`,`amount` 用负数(与 `exchange` 一致);
- 余额没减少或拿不到 → **不发**:宁可少一条,也不要记一笔没发生的消耗。

## C. 积分到期(新 `points_expire`)

积分到期是资产真实减少,但**斗鱼不会通知** —— 只在积分查询里给 `willExpireTime`。
所以:
- 新增 `accounts.points_expire_at`(迁移 `0046`),送礼流程学到就写下来;
- 新增作业 `points_expire_check`:扫「已到期且积分未归零」的账号,补写事件并清标记
  (不重复记);积分已为 0 则只清标记、不发事件(避免 `-0` 噪声)。

⚠️ **金额的诚实边界**:到期那一刻**到底扣了多少,我们没亲眼看到**。
所以默认 `confirmed=False`、标题带「(预计)」,记的是「当时余额」这个**预计要归零**的数量,
**不假装它是确认过的扣减**。作业刻意不发网络请求(纯 DB 可跑、不需要 CK),
要变成已确认只能由「到期后重查、真看到余额下降」的调用方补。

## 顺带修掉一个会静默丢事件的坑

同一个任务可能产生**两条事件**(送礼:既「积分到账」又「消耗鱼翅」)。
`source_ref` 是唯一键 —— 共用 `task_ref(task_id)` 会让**后写的那条被静默丢弃**。
已让副事件带后缀(`douyu_task:{id}:gold`),主事件保持原形态(与旧收集器一致)。
有用例专门钉住「一个任务两条事件都不丢」。

## 验证

- `TZ=Asia/Shanghai`:**550 passed / 6 skipped**(+18 条)
- `TZ=UTC`(事件 + 迁移 + 绑定)95 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移 `0046` 已应用;作业烟测 → 扫到 1 个到期账号,
  写入「积分到期 -500(预计)」`confirmed=False`,标记被清、重跑不再重复

## 现在的资产流水(斗鱼侧)

| 方向 | 覆盖 |
| --- | --- |
| 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ |
| 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** |
| 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
2026-10-04 18:10:46 +08:00

428 lines
17 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
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuTask
from .business_events import record_gold_spent, record_points_credit
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
class DonateMixin:
"""送礼域:精英令/电竞任务礼物赠送与积分确认。"""
if TYPE_CHECKING:
_stop: Any
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_points(
self,
client: DouyuActivityClient,
account: Account,
cookie: str,
*,
ctn: str | None = None,
) -> dict: ...
def _refresh_account_gold_balance(
self, client: DouyuActivityClient, account: Account
) -> dict: ...
def _refresh_esports_handbook(
self, client: DouyuActivityClient, account: Account, *, manual_id: str
) -> dict: ...
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}")
baseline_gold = account.gold_balance
try:
gold_before = self._refresh_account_gold_balance(client, account)
db.commit()
baseline_gold = gold_before["gold_balance"]
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"电竞积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.updated_at = datetime.now(UTC)
confirmed = bool(result.get("esports_points_changed"))
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}"
# 电竞礼物同样产生积分到账 —— 这一类此前**从来不在收集范围**
# (不在 COLLECTED_TASK_TYPES 里),时间线上一直是空白。
record_points_credit(
db,
account,
task_id=task.id,
gain=_esports_gift_points(result, baseline_points),
points_after=account.esports_points,
confirmed=confirmed,
source_label=gift_name,
)
record_gold_spent(
db,
account,
task_id=task.id,
amount=_gold_spent(baseline_gold, account.gold_balance),
reason=f"赠送{gift_name} x{gift_count}",
source_label=f"赠送{gift_name}",
balance_after=account.gold_balance,
)
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}")
# 鱼翅基线:送礼会**消耗鱼翅**,要记出项就必须有送礼前的余额。
# 直接读 account.gold_balance 是上次刷新的旧值,算出来的差会偏。
baseline_gold = account.gold_balance
try:
gold_before = self._refresh_account_gold_balance(client, account)
db.commit()
baseline_gold = gold_before["gold_balance"]
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.updated_at = datetime.now(UTC)
message = f"赠送精英令成功: {gift_count}"
if account.gold_balance is not None:
message += f",鱼翅余额: {account.gold_balance}"
confirmed = bool(result.get("gift_points_confirmed"))
# 顺手记下积分到期时刻:斗鱼只在这次查询里给,不记下来以后就没机会了。
# 到期是资产减少,但斗鱼不会通知 —— 由作业在到期后补写事件。
expire_at = _points_expire_at(result)
expire_dt = _parse_expire_at(expire_at)
if expire_dt is not None:
account.points_expire_at = expire_dt
if account.points is not None:
message += f",积分: {account.points}"
if result.get("gift_points_target") is not None and not confirmed:
message += f"(未确认涨到 {result['gift_points_target']})"
# 积分到账是业务事实。**未确认也记,但把 confirmed 明确写进 detail** ——
# 旧收集器把「预期涨幅」当既成事实写进台账(未确认时也记 +980),
# 那是把预期记成到账。这里宁可标清楚,也不假装它是确认过的数字。
# 金额用本地可等价重算的值(1 个精英令 ≈ 10 分),不再依赖 result 里的字段。
record_points_credit(
db,
account,
task_id=task.id,
gain=max(0, gift_count) * 10,
points_after=account.points,
expire_at=expire_at,
confirmed=confirmed,
source_label="精英令",
)
# 鱼翅出项:以前只记「积分到账」,花掉的鱼翅没有任何记录 ——
# 充值有账、消耗没账,对不上。金额取实测余额差。
record_gold_spent(
db,
account,
task_id=task.id,
amount=_gold_spent(baseline_gold, account.gold_balance),
reason=f"赠送精英令 x{gift_count}",
source_label="赠送精英令",
balance_after=account.gold_balance,
)
self._mark_task(db, task, "success", message, result)
def _points_expire_at(result: dict) -> object:
"""从积分查询结果里取出到期时间(同样是为了给时间线留全信息)。
旧收集器走的是 `result["points_query"]["raw"]["data"]["willExpireTime"]` 这条
「猜出来」的路径;这里收敛成一个具名函数,只有它知道那段结构。
"""
query = result.get("points_query")
if not isinstance(query, dict):
return None
raw = query.get("raw")
data = raw.get("data") if isinstance(raw, dict) else None
return data.get("willExpireTime") if isinstance(data, dict) else None
def _esports_gift_points(result: dict, baseline_points: int | None) -> int | None:
"""电竞礼物带来的积分增量。
以**实际观测到的积分变化**为准(`esports_points_after_gift` - 基线),
而不是像旧收集器那样去读一个「预期涨幅」。观测不到就返回 None ——
金额宁可空着,也不要写一个猜出来的数字进台账。
"""
after = result.get("esports_points_after_gift")
if not isinstance(after, (int, float)) or not isinstance(
baseline_points, (int, float)
):
return None
delta = int(after) - int(baseline_points)
return delta if delta > 0 else None
def _gold_spent(before: int | None, after: int | None) -> int | None:
"""送礼前后鱼翅余额的差(正数 = 花掉的)。
用**实测余额差**而不是礼物单价:单价会变、活动会打折,只有观测到的差是真的。
任一侧拿不到、或余额没减少(甚至变多,例如同时另一笔充值到账)→ 返回 None,
事件就不写金额 —— 宁可少记,不要编一个数进账。
"""
if not isinstance(before, (int, float)) or not isinstance(after, (int, float)):
return None
delta = int(before) - int(after)
return delta if delta > 0 else None
def _parse_expire_at(value: object) -> datetime | None:
"""把斗鱼给的到期时间解析成 datetime。
它可能是秒级时间戳(int/str),也可能是 `YYYY-MM-DD HH:MM:SS` 字符串 ——
两种都见过,所以都兜住;认不出来就返回 None(宁可不记,也不要猜一个时刻)。
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
seconds = float(value)
else:
text = str(value).strip()
if not text:
return None
if text.isdigit():
seconds = float(text)
else:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=UTC)
except ValueError:
continue
return None
# 毫秒时间戳也兜一下
if seconds > 10_000_000_000:
seconds /= 1000
try:
return datetime.fromtimestamp(seconds, tz=UTC)
except (OverflowError, OSError, ValueError):
return None