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

245 lines
9.8 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.
"""账号事件流:写入与查询。
## 定位
`accounts` 存**当前状态**(积分、余额、开通标记),这张表存**发生过什么**。
账号详情页 = 状态卡(读 `accounts`)+ 时间线(读本表)。
## 唯一写入路径:直接发射
业务事实**在它发生的那一刻**由业务代码发射(`emit` 或 `business_events` 里的命名
构造函数)。曾经有过第二条路径 —— 一个作业事后扫 `douyu_tasks.result` 的 JSON、
猜字段路径反推事件。那条路已经删掉,原因是它必然漏数据:
- 靠 allowlist 认业务类型 → 新增类型没人想起维护,**静默丢**(电竞兑换、和平小店
兑换、锁单支付、电竞礼物积分因此从来没进过时间线);
- 「任务 success」不等于「业务真的发生了」(预约、已绑定、未确认到账都会被标 success);
- 作业默认关闭,生产上它一直没跑。
历史**不从 `douyu_tasks` 回填**。需要历史时按斗鱼侧权威接口同步
(`query_exchange_records` / `query_xpd_purchase_records` / `query_points` 都是现成任务类型)
—— 宁可少,不可假。
## 两条硬约束
- **发射是旁路**:`emit` 任何失败都不能影响登录/兑换主流程(内部吞异常 + 记日志,
并用 SAVEPOINT 隔离,避免污染调用方的事务);
- **幂等**:`source_ref` 唯一键兜住「收集器重跑」「事件重复发射」。没有它,重跑一次
时间线就多一份重复记录。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from sqlalchemy import text
from sqlalchemy.orm import Session
# ── 事件类型 ──────────────────────────────────────────────
# 命名保持「名词_状态」风格,便于前端归类与筛选。
EVENT_IMPORTED = "imported" # 账号导入(读 accounts.created_at,一般不落表)
EVENT_LOGIN_OK = "login_ok" # 登录成功(含新 CK)
EVENT_CK_INVALID = "ck_invalid" # CK 判定失效
EVENT_CK_CLEARED = "ck_cleared" # CK 被清除(人工清除 / 账号删除前的清理)
EVENT_RELOGIN_OK = "relogin_ok" # 自动重登成功
EVENT_RELOGIN_FAILED = "relogin_failed" # 自动重登失败(转人工/退避)
EVENT_BIND_OK = "bind_ok" # 绑定/换绑
EVENT_RECHARGE = "recharge" # 充值到账
#: 事件来源:从斗鱼**官方兑换记录**补录(区别于任务执行时当场记的)
OFFICIAL_SYNC_SOURCE = "official_sync"
EVENT_EXCHANGE = "exchange" # 兑换(含消耗积分)
EVENT_POINTS_CREDIT = "points_credit" # 积分到账(含到期时间)
EVENT_POINTS_EXPIRE = "points_expire" # 积分到期(资产减少)
EVENT_GOLD_SPENT = "gold_spent" # 鱼翅消耗(送礼等)
EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通
EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通
EVENT_ASSIGNED = "assigned" # 分配客服
EVENT_TAG_CHANGED = "tag_changed" # 标签变更
EVENT_REMARK_CHANGED = "remark_changed" # 备注变更
EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…)
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
# 充值为正、兑换为负,其余不计入金额统计。
INCOME_TYPES = (EVENT_RECHARGE,)
OUTCOME_TYPES = (EVENT_EXCHANGE,)
# 粗分类:决定时间线怎么分组、以及哪些事件算「业务台账」。
CATEGORY_LIFECYCLE = "lifecycle" # 生命周期状态变迁
CATEGORY_AUTH = "auth" # 登录与 CK
CATEGORY_BUSINESS = "business" # 充值 / 兑换 / 积分 / 开通
CATEGORY_OPS = "ops" # 人工操作(分配 / 标签 / 停用)
def to_naive_utc(value: datetime) -> datetime:
"""把时间归一成 **naive UTC**(本服务所有时间列的落库形态)。
约定与 `database.UTCDateTime` 一致,也是全仓通行写法:
- **naive → 视为「已经是 UTC」,原样使用**;
- aware → 换算到 UTC 后去掉 tzinfo。
⚠️ 绝对不能直接写 `value.astimezone(UTC)`:对 naive 值,Python 会把它当作
**服务器本地时间**再换算。本机 UTC+8 时事件时间会被整整扣掉 8 小时,
而 naive 值的来源很常见 —— 用**裸 SQL** 读 DATETIME 列(如收集器读
`douyu_tasks.finished_at`)拿到的就是 naive,因为它绕过了 `UTCDateTime`
装饰器。仓库里 `scheduler._as_utc` / `yyb_service._as_utc` / `audit.py`
都遵守了这条约定,本函数与它们同口径。
"""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
def _now() -> datetime:
return datetime.now(UTC)
_INSERT_SQL = text(
"""
INSERT INTO account_events (
account_id, event_type, category, occurred_at, title,
amount, balance_after, subject_type, subject_key,
detail, source, actor_type, actor_id, source_ref, created_at
) VALUES (
:account_id, :event_type, :category, :occurred_at, :title,
:amount, :balance_after, :subject_type, :subject_key,
:detail, :source, :actor_type, :actor_id, :source_ref, :created_at
)
"""
)
def _event_params(
*,
account_id: int,
event_type: str,
title: str,
occurred_at: datetime | None = None,
amount: int | None = None,
balance_after: int | None = None,
detail: dict[str, Any] | None = None,
source: str = "manual",
source_ref: str | None = None,
category: str = "",
subject_type: str = "",
subject_key: str = "",
actor_type: str = "",
actor_id: int | None = None,
) -> dict[str, Any]:
"""把一次调用归一成落库参数(`emit` 与 `emit_bulk` 共用,避免 SQL 写两遍)。"""
return {
"account_id": account_id,
"event_type": event_type,
"category": (category or "")[:16],
"occurred_at": to_naive_utc(occurred_at or _now()),
"title": (title or "")[:256],
"amount": amount,
"balance_after": balance_after,
"subject_type": (subject_type or "")[:16],
"subject_key": (subject_key or "")[:96],
"detail": json.dumps(detail, ensure_ascii=False, default=str)
if detail
else None,
"source": source[:24],
"actor_type": (actor_type or "")[:16],
"actor_id": actor_id,
"source_ref": (source_ref or None),
"created_at": to_naive_utc(_now()),
}
def emit(
db: Session,
*,
account_id: int,
event_type: str,
title: str,
occurred_at: datetime | None = None,
amount: int | None = None,
balance_after: int | None = None,
detail: dict[str, Any] | None = None,
source: str = "manual",
source_ref: str | None = None,
category: str = "",
subject_type: str = "",
subject_key: str = "",
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""记录一条账号事件。返回是否真的写入(幂等命中/异常时为 False)。
**绝不抛异常**:事件是旁路,不能因为记不上一条时间线就让登录或兑换失败。
用 SAVEPOINT 包住,失败只回滚这一条,不影响调用方已经做过的事。
`occurred_at` 是**业务发生时间**,可以是 aware,也可以是 naive
(naive 按约定视为已是 UTC,见 `to_naive_utc`)。
`category` / `subject_*` / `actor_*` 支撑台账检索:
例如「该账号该商品最近一次兑换」走
`(account_id, event_type, subject_key, occurred_at)` 复合索引。
"""
if not account_id or not event_type:
return False
params = _event_params(
account_id=account_id,
event_type=event_type,
title=title,
occurred_at=occurred_at,
amount=amount,
balance_after=balance_after,
detail=detail,
source=source,
source_ref=source_ref,
category=category,
subject_type=subject_type,
subject_key=subject_key,
actor_type=actor_type,
actor_id=actor_id,
)
try:
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
with db.begin_nested():
db.execute(_INSERT_SQL, params)
return True
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
return False
def emit_bulk(db: Session, events: list[dict[str, Any]]) -> int:
"""一次写多条事件,返回实际写入条数。
用于**批量账号操作**(批量分配 / 批量改标签):一次操作可能覆盖几千个账号,
每个账号的时间线都该各留一条「谁在什么时候改了什么」。逐条走 `emit()` 会给
每条开一个 SAVEPOINT,几千条时请求明显变慢 —— 这里改成一次 executemany,
整批包在**一个** SAVEPOINT 里。
与 `emit()` 一样**绝不抛异常**:整批失败就整批丢弃(记 warning),
不会污染调用方的事务,也不会让「批量分配」因为记不上时间线而失败。
每个 dict 的键与 `emit()` 的关键字参数同名(`account_id` / `event_type` /
`title` 必填)。
"""
rows = [
_event_params(**{**item, "occurred_at": item.get("occurred_at")})
for item in events
if item.get("account_id") and item.get("event_type")
]
if not rows:
return 0
try:
with db.begin_nested():
db.execute(_INSERT_SQL, rows)
return len(rows)
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"批量写入账号事件失败({len(rows)} 条):{exc}")
return 0