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

334 lines
12 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.
"""业务事实的**命名构造**:充值 / 兑换 / 积分到账 / 绑定。
## 为什么单独一个模块
`emit()` 只负责「往时间线写一条」,它不知道「一次兑换」到底该长什么样。
以前这件事由收集器从 `douyu_tasks.result` 的 JSON 里**反推**(猜字段路径),
结果是:
- 新增业务类型没人想起改 allowlist → **静默丢数据**
(电竞兑换、和平小店兑换、锁单支付、电竞礼物积分从来没进过时间线);
- 同一件事的字段口径散在 runner 与收集器两处,容易分叉;
- `success` 不等于「业务真的发生了」时,把假成功也记成事实。
现在反过来:**业务代码在动作成功的那一刻,用这里的构造函数声明事实**。
每类事实的字段、标题、金额符号只定义一次。
## 两条硬规则
1. **必须紧贴 `_mark_task(..., "success", ...)` 之前调用。**
`_mark_task` 内部 `db.commit()`,而 `emit()` 用 `db.begin_nested()` 只 RELEASE
SAVEPOINT、不提交外层事务;`douyu_runner` 的 `finally` 只 `close()`(未提交即回滚)。
在 `_mark_task` **之后**发事件会被静默回滚 —— 而且 `emit()` 返回 True、无任何日志。
放在之前还有个好处:事件与任务状态**同一次提交**,原子。
2. **幂等键用 `task_ref(task.id)`**,与旧收集器的 key 完全一致。
于是迁移窗口内「直发」与「仍在跑的收集器」会自动去重,可以安全并存后再删作业。
⚠️ 不要用秒级时间戳当幂等键:撞唯一键会让 `emit()` 静默吞掉事件
(见 `account_lifecycle` 里那段复盘)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from ..models import Account
from .account_events_service import (
CATEGORY_BUSINESS,
EVENT_BIND_OK,
EVENT_EXCHANGE,
EVENT_GOLD_SPENT,
EVENT_POINTS_CREDIT,
EVENT_POINTS_EXPIRE,
EVENT_RECHARGE,
emit,
)
def task_ref(task_id: int, kind: str = "") -> str:
"""业务事件的幂等键:一条任务行对应一条事实。
与旧收集器(`f"douyu_task:{row['id']}"`)**完全一致**,所以直发与收集器
同时观测到同一笔业务时只会写一条 —— 这让「先直发、观察一段、再删收集器」
成为安全的迁移路径。
"""
base = f"douyu_task:{int(task_id)}"
# 同一个任务可能产生**多条**事件(例如送礼:既「积分到账」又「消耗鱼翅」)。
# 它们共用 task_ref 会撞唯一键、后写的那条被静默丢弃 —— 所以副事件带一个后缀。
# 不带后缀的形态与旧收集器一致,主事件继续沿用它。
return f"{base}:{kind}" if kind else base
def record_recharge(
db: Session,
account: Account,
*,
task_id: int,
amount: int | None,
occurred_at: datetime | None = None,
order_id: str = "",
supplier_order_id: str = "",
channel: str = "",
douyu_uid: str = "",
balance_after: int | None = None,
confirmed: bool = True,
) -> bool:
"""充值鱼翅到账。
`amount` 用**本地已知的充值金额**,不要从 `result` 里找 —— 二维码链路的
`result` 里根本没有金额(`create_gold_qr` 只返回 `pay_url/token/raw`),
旧收集器因此在二维码充值上恒写 `amount=None`,导致 `accounts.recharge_total`
系统性少计。
`confirmed=False` 表示只有供应商的终态回执、没有核对斗鱼余额变化。
"""
title = f"充值 {amount}" if amount is not None else "充值到账"
if not confirmed:
title += "(供应商回执)"
return emit(
db,
account_id=account.id,
event_type=EVENT_RECHARGE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=amount,
balance_after=balance_after,
detail={
"order_id": order_id,
"supplier_order_id": supplier_order_id,
"channel": channel,
"douyu_uid": douyu_uid,
"confirmed": confirmed,
},
source="douyu_task",
subject_type="recharge",
subject_key=order_id or supplier_order_id or "",
source_ref=task_ref(task_id),
)
def record_exchange(
db: Session,
account: Account,
*,
task_id: int,
name: str,
score: int | None,
occurred_at: datetime | None = None,
commodity_id: str = "",
order_id: str = "",
exchange_id: str = "",
game_name: str = "",
game_channel: str = "",
points_after: int | None = None,
scope: str = "elite",
) -> bool:
"""商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。
注意「预约到货 / 已预兑 / 预兑确认」这三类也把任务标成 `success`,但它们
**不消耗积分、也没兑换到东西**,绝不能走这里(旧收集器只按 `status=="success"`
过滤,于是每预约一次就生成一条金额为空的 `exchange`,把 `exchange_count` 灌大)。
`score` 为负写入 `amount`(消耗),`points_after` 是兑换后的积分余额。
"""
return emit(
db,
account_id=account.id,
event_type=EVENT_EXCHANGE,
title=f"兑换 {name}" if name else "兑换",
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(score) if isinstance(score, (int, float)) else None,
balance_after=points_after,
detail={
"commodity_id": commodity_id,
"commodity_name": name,
"order_id": order_id,
"exchange_id": exchange_id,
"game_name": game_name,
"game_channel": game_channel,
"score": score,
"scope": scope,
},
source="douyu_task",
subject_type="commodity",
subject_key=commodity_id or name or "",
source_ref=task_ref(task_id),
)
def record_gold_spent(
db: Session,
account: Account,
*,
task_id: int,
amount: int | None,
occurred_at: datetime | None = None,
reason: str = "",
source_label: str = "",
balance_after: int | None = None,
confirmed: bool = True,
) -> bool:
"""鱼翅消耗(送礼等)。
`amount` 传**正数**,这里写成负号(与 `exchange` 一样,出项用负数)。
金额取**实测的余额差**(送礼前后各刷一次鱼翅),不是按礼物单价推算 ——
单价会变、活动会打折,只有观测到的差是真的。
余额差拿不到(或没有减少)时**不发事件**:宁可少一条,也不要记一笔没发生的消耗。
"""
if not amount or int(amount) <= 0:
return False
spent = int(amount)
title = f"{source_label}消耗鱼翅 {spent}" if source_label else f"消耗鱼翅 {spent}"
if not confirmed:
title += "(未确认扣减)"
return emit(
db,
account_id=account.id,
event_type=EVENT_GOLD_SPENT,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-spent,
balance_after=balance_after,
detail={"reason": reason, "source": source_label, "confirmed": confirmed},
source="douyu_task",
subject_type="gold",
# 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键
source_ref=task_ref(task_id, "gold"),
)
def record_points_expire(
db: Session,
account: Account,
*,
amount: int | None,
occurred_at: datetime | None = None,
expires_at: Any = None,
confirmed: bool = False,
) -> bool:
"""积分到期(资产减少)。
⚠️ **默认 `confirmed=False`**:斗鱼只在积分查询里给一个 `willExpireTime`
(到期时刻),到期**实际扣了多少分**我们并没有亲眼看到。
所以默认只记「预计到期」,并在 detail 里标清楚;要记成已确认,
必须由重新查询后真正观测到余额下降的调用方显式传 `confirmed=True`。
"""
title = f"积分到期 -{amount}" if amount else "积分到期"
if not confirmed:
title += "(预计)"
return emit(
db,
account_id=account.id,
event_type=EVENT_POINTS_EXPIRE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(amount) if amount else None,
detail={"confirmed": confirmed, "expires_at": expires_at},
source="points_expire",
subject_type="points",
)
def record_points_credit(
db: Session,
account: Account,
*,
task_id: int,
gain: int | None,
occurred_at: datetime | None = None,
points_after: int | None = None,
expire_at: Any = None,
confirmed: bool = True,
source_label: str = "精英令",
) -> bool:
"""积分到账(送礼/任务发放)。
`confirmed` 必须如实反映「有没有真的看到积分涨上去」。送礼任务在**没确认**
到账时也会把任务标成 `success`(消息里加一句「(未确认涨到 X)」),旧收集器
照样把 `gift_points_expected_gain` 当成事实写进台账 —— 把「预期」记成了「已到账」。
这里保留事件(延迟到账也是真实发生的发放),但**把 `confirmed` 明确落进
detail**:宁可标清楚是未确认,也不假装它是确认过的数字。
"""
title = f"积分到账 +{gain}" if gain is not None else "积分到账"
title = f"{source_label}{title}"
if not confirmed:
title += "(未确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_POINTS_CREDIT,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=int(gain) if isinstance(gain, (int, float)) else None,
balance_after=points_after,
detail={
"confirmed": confirmed,
"expire_at": expire_at,
"source": source_label,
},
source="douyu_task",
subject_type="points",
source_ref=task_ref(task_id),
)
def record_bind(
db: Session,
account: Account,
*,
task_id: int,
scope: str,
role_name: str = "",
occurred_at: datetime | None = None,
act_alias: str = "",
rebind: bool = False,
weak: bool = False,
) -> bool:
"""角色绑定 / 换绑**真的发生了**。
⚠️ 只在「确认接口成功」时调用。`confirm_bind` 里还有一条分支是
「检测到账号**本来就已经绑定**」——它什么都没做也把任务标成 `success`,
旧收集器把每次重复点「确认绑定」都记成一条 `bind_ok`。
`weak=True` 表示确认接口成功、但回查拿不到结果(没能亲眼确认生效),
仍然记,但标出来。
"""
label = "换绑" if rebind else "绑定"
scope_label = {"elite": "精英宝典", "esports": "电竞手册", "peace": "和平小店"}.get(
scope, scope
)
title = f"{scope_label}{label}"
if role_name:
title += f":{role_name}"
if weak:
title += "(未回查确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_BIND_OK,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
detail={
"scope": scope,
"role_name": role_name,
"act_alias": act_alias,
"rebind": rebind,
"confirmed": not weak,
},
source="douyu_task",
subject_type=f"{scope}_role",
subject_key=role_name or "",
source_ref=task_ref(task_id),
)