Files
live-hub-py/web/backend/migrations/versions/20261004_0045_source_ref_nullable.py
T
yml2213 5ae5d92978 feat(account): 兑换记录列 + 修掉 source_ref NOT NULL 导致的事件静默丢失
三件事合成一次提交(按反馈不再一改一提交)。

## ① 账号列表加「兑换记录」列

次数 + 最近一次(商品名 + 日期),填上列表右侧的空档。

数据源是**账号自己的事件流**(`account_events` 的 `exchange`),不是 `douyu_tasks`
的 JSON —— 后者是执行日志,S5 起业务事实已经在事件里了。

性能上刻意**每页只跑两条聚合查询**(不是每行一条 N+1):
一条分组算 `count` + `max(occurred_at)`(走 `(account_id, event_type, occurred_at)`
复合索引),一条用窗口函数 `ROW_NUMBER()` 取每个账号最新那条的商品名。
实测 6 个账号 3 条兑换事件,聚合与「最近」都正确。

「兑换 」前缀在前端剥掉(列头已经叫「兑换记录」,再带前缀是重复)。

## ② 修掉一个静默丢事件的 bug(排查中发现的)

**现象**:开发库上「状态变迁 / 分配 / 标签 / 备注 / CK 清除」这些事件**一条都没有**,
而带幂等键的事件(业务事实、`ck_invalid`、`relogin_*`)照常写入 ——
时间线看起来有数据,只是恰好缺了某一类,**极难发现**。

**根因**:`source_ref` 是**可选**幂等键(模型与迁移文件都是 `nullable=True`,
`emit()` 对没有天然幂等键的事件就传 `None`)。但早期那版迁移
(重编号成 `0041` 之前的 `0040`)建的是 **NOT NULL**,而我后来只改了
**迁移文件**、没管**已建好的库** —— Alembic 只记版本号,不会重放已应用的脚本。
`__pycache__` 里那个 `20261002_0040_*.pyc` 就是旧版留下的痕迹。

**为什么静默**:`emit()` 按设计吞掉任何异常(事件是旁路,不能影响主流程),
只记一条 warning,调用方也从不检查返回值。已实测复现:
`emit(...)` 返回 `False` + warning,实际落库 `0` 条。

**修法**:迁移 `20261004_0045` 改回可空(`MODIFY`,**唯一索引保留**,幂等,
生产若跑过旧版也一并修好)。修复后实测:`emit` 返回 `True`、落库 1 条。

## ③ 补一条能防「未来」同类问题的守卫

`tests/test_migrations.py` 原本只比对「表/列/索引在不在」,**不比对可空性**。
已加:迁移建出的列**不允许比模型更严**(模型说可空、库里 NOT NULL)。

只查这个**危险方向**:反方向(模型 NOT NULL、库里允许 NULL)是无害的宽松,
历史遗留里有 28 处,一并报出来只会制造噪声、逼人修没收益的东西。

> 这条守卫**有边界**,已在代码注释与设计文档 §3.5.1 写明:
> 它在**全新库**上跑整链,只能防「未来的迁移建错」,**防不了已有库的历史漂移**。
> 已验证:临时禁用 `0045`,该用例仍通过(全新链本来就是对的)。

## 验证

- `TZ=Asia/Shanghai`:**503 passed / 6 skipped**(+3 条兑换列用例)
- `TZ=UTC`(迁移 + 列表相关)45 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移已应用(head `20261004_0045`),
  `source_ref` 现在 `IS_NULLABLE=YES` 且唯一索引仍在;
  不带幂等键的事件确实能落库;兑换聚合返回值正确

注:验证时在开发库那个有 CK 的账号上留了 3 条 `exchange` 演示事件
(兔兔白日梦 / 冠军鸡腿 / 烟花),是为了让列有数据可看,随时可删。
2026-10-04 17:25:40 +08:00

56 lines
2.2 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.
"""修 `account_events.source_ref` 的可空性(模型与迁移文件早已是 NULL,但库里不是)
## 问题
`source_ref` 是**可选**的幂等键。设计上明确可空(模型与迁移 `0041` 都写着
`nullable=True`,注释也说「MySQL 允许多个 NULL,便于『没有幂等键』的事件共存」)。
`emit()` 对**没有天然幂等键**的事件(状态变迁 / 分配 / 标签 / 备注 / CK 清除)
就是传 `None` 的。
但有的库里这一列是 **NOT NULL** —— 原因是我早期把迁移从 `20261002_0040` 重编号成
`20261003_0041` 并顺手修正了可空性,而**已经用旧版本建过表的库不会跟着改**
(Alembic 只记录版本号,不会重放已应用过的脚本)。
## 危害:静默丢事件
`emit()` 按设计**吞掉任何异常**(事件是旁路,不能影响主流程),只记一条 warning。
于是这些事件在那种库上**一条都写不进去**,而业务完全正常、没有任何报错:
- `status_changed`(S3 生命周期状态变迁)
- `assigned` / `tag_changed` / `remark_changed`(S4 人工操作)
- `ck_cleared`(S4 清 CK)
而 `ck_invalid` / `relogin_*` / 业务事实等**带幂等键**的事件照常写入 ——
所以时间线看起来「有数据」,只是**恰好缺了没有幂等键的那几类**,极难发现。
## 修法
改成可空。用 `MODIFY` 而不是重建列,**唯一索引会保留**(索引与列定义是两回事),
而且重复执行是幂等的 —— 无论那种库此前是什么状态,跑完都对齐模型。
`downgrade` 刻意什么都不做:把可空改回 NOT NULL 会让上述事件重新丢失,
而这不是「回退」该有的行为。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261004_0045"
down_revision: str | None = "20261004_0044"
branch_labels: str | None = None
depends_on: str | None = None
TABLE = "account_events"
COLUMN = "source_ref"
def upgrade() -> None:
op.execute(sa.text(f"ALTER TABLE {TABLE} MODIFY {COLUMN} VARCHAR(96) NULL"))
def downgrade() -> None:
# 见模块 docstring:改回 NOT NULL 会重新丢事件,不做。
pass