Files
live-hub-py/web/backend/services/business_events.py
T
yml2213 62d9053e8a feat(account): 官方兑换记录补成账号事件(三作用域)+ 一键查询结果落到账号
## 官方兑换记录 → 真正的账号事件

关键发现:斗鱼官方兑换记录**带真实兑换时间**(`time` 字段),所以可以直接
补成正常的历史事件,时间线因此变完整(这就是「账号全生命周期」该有的样子),
列表的兑换聚合也随之准确 —— 上线前的兑换不再是一片空白。

- **三个作用域都拉**:精英宝典 / 电竞手册(共用 `exchange_records`,只有 manual_id
  不同)/ 和平小店(`xpd_embed_query` → `xpd_purchase_records`)
- 幂等键含作用域:`official_exchange:{scope}:{account}:{commodity}:{time}`
- `detail.scope` 标作用域;`source=official_sync` 与"当场记的"分得开
- 任一作用域失败只记进 `errors`,不影响其余(小店未开通会报
  `Livelink登录态校验失败`,属正常)

## 一键查询结果落到账号(不再只留在任务里)

- 新增 `accounts.high_value_owned`(迁移 0048):**已兑换过的高价值套装名**,
  供列表展示。单开一列而不是让列表读 `overview_snapshot` —— 那份 JSON 有 1~2KB,
  15000 个账号各带一份会把响应放大到几十 MB。
- 迁移**可安全重跑**(启动时会自动跑):加列前查 `information_schema`。
  ⚠️ 回填里**不要** `op.get_bind().commit()` —— MySQL 的 DDL 自动提交而 commit
  会打乱版本记账,表现为"列加上了、版本号没更新",下次启动重复 ADD COLUMN
  直接把服务起不来(生产上就是启动即挂)。
- 迁移里从旧快照**回填** `high_value_owned`,已查过的账号不必重查。

## 两个 bug

1. **「最近一次兑换」按插入 id 排**(应为事件时间):补录的历史事件 id 最大、
   时间却最老,会把几个月前的老兑换显示成"最近一次"。改为 `occurred_at DESC, id DESC`。
2. **手写的 `@model_serializer` 没列新字段** → 属性有值、JSON 里没有。
   这是本项目第三次踩 Pydantic 静默丢字段;已补,并把断言改为看 `model_dump()`。

## 列表「兑换记录」口径

只聚合**官方**记录(`source=official_sync`):用户要求列表看官方的权威口径,
我们当场记的那些留给详情页时间线。另加 `GET /accounts/{id}/exchange-records`
供列表点击预览(按需拉取,不进列表响应)。
2026-10-04 20:03:42 +08:00

399 lines
14 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,
OFFICIAL_SYNC_SOURCE,
emit,
to_naive_utc,
)
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),
)
def record_official_exchange(
db: Session,
account: Account,
*,
commodity_id: str,
commodity_name: str,
score: int | None,
occurred_at: datetime | None,
show_name: str = "",
exchange_num: int | None = None,
scope: str = "elite",
) -> bool:
"""把斗鱼官方兑换记录写成账号事件。
## 为什么要写进事件流
列表的「兑换记录」列一直来自我们自己的 `account_events`,而事件是**上线后**
才开始记的 —— 上线前的兑换在界面上就是空白,看着像"从没兑换过"
(用户报过:账号明明有官方兑换记录,列表却显示「未兑换」)。
官方记录**带真实兑换时间**(`time` 字段),所以可以直接补成正常的历史事件,
时间线因此变完整(这就是"账号全生命周期"该有的样子),
列表的聚合也随之自动准确 —— 不需要再单独维护一套"官方条数"的旁路。
## 幂等
`source_ref` 用 `官方:账号:商品:时刻`,同一条记录查多少次都只落一条。
这也是必须的:一键查询会被反复执行。
"""
if not commodity_id:
return False
return emit(
db,
account_id=account.id,
event_type=EVENT_EXCHANGE,
title=f"兑换 {commodity_name}" if commodity_name else "兑换",
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(score) if score else None,
detail={
"commodity_id": commodity_id,
"commodity_name": commodity_name,
"score": score,
"exchange_num": exchange_num,
"show_name": show_name,
# 标明来源:这条是**从官方记录补录**的,不是我们亲眼看着兑换的
"source": OFFICIAL_SYNC_SOURCE,
# 哪个作用域的官方记录(精英宝典 / 电竞手册 / 和平小店)
"scope": scope,
},
source=OFFICIAL_SYNC_SOURCE,
subject_type="goods",
source_ref=(
f"official_exchange:{scope}:{account.id}:{commodity_id}:{_stamp(occurred_at)}"
),
)
def _stamp(value: datetime | None) -> str:
"""幂等键里的时间部分;没有时间就用固定标记(同一商品仍只落一条)。"""
return to_naive_utc(value).strftime("%Y%m%d%H%M%S") if value else "unknown"