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`,标记被清、重跑不再重复

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

| 方向 | 覆盖 |
| --- | --- |
| 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ |
| 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** |
| 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
This commit is contained in:
yml2213
2026-10-04 18:10:46 +08:00
parent 4c1fe0c84f
commit 0b1fc1dee0
10 changed files with 548 additions and 8 deletions
+6
View File
@@ -104,6 +104,12 @@ class Account(Base):
role_bind_status: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown"
)
# 积分**到期时刻**(斗鱼在积分查询里给 `willExpireTime`,只有送礼后的那次查询才有)。
# 积分到期是账号资产的真实减少,但斗鱼不会「通知」你 —— 所以这里记下到期时刻,
# 由作业在到期后补记一条 `points_expire` 事件(否则这个减少在时间线上是空白)。
points_expire_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# 电竞手册是否已开通(与 elite_opened 对称)。
# 以前这个状态**只寄生在 `esports_bind_status` 里**(取值 `esports_opened`)——
# 那是「一列多义」的典型:同一个列既表示开通、又表示角色绑定、又表示查询痕迹。