Files
live-hub-py/docs/设计-账号详情页与事件历史.md
yml2213 44b762ea8c fix(overview): 锁定核心高价值套装并修复未查询空值误判
- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰
- 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换
- 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示
- 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
2026-10-07 15:27:30 +08:00

26 KiB
Raw Permalink Blame History

设计:账号详情页与账号事件历史

状态:后端已完成、前端已完成,本地验证通过(2026-10-03) 已提交在 dev(834bfdd);迁移编号经合并后为 20261003_0041(原 20261002_0040, 因生产索引优化先落地占了 20261003_0040)。尚未合入 main、未上生产。 关联:登录任务与 CK 解耦 —— 两者的写入点高度重合,建议同一工作流推进(见 §8)。

范围(2026-10-03 已定):不做历史回填。既有数据(douyu_tasks 里的兑换/充值/积分记录) 不导入事件表,事件从上线那一刻起往后累积。账号的历史明细仍可去既有的任务页查。 这样本需求从「一次数据搬运工程」缩小为「建表 + 几处发事件 + 一个页面」。

1. 需求

要一个「账号相关」的界面,围绕单个账号把下面这些都记下来、看得见:

# 需求项 用户原话
1 导入时间 「这个账号 导入时间」
2 CK 登录记录 「ck登录记录」
3 CK 失效时间 「失效时间」
4 兑换记录 「兑换记录」
5 积分多少 「积分多少」
6 消耗记录 「消耗记录」
7 充值记录 「充值记录」
8 精英宝典开通记录 「精英宝典开通记录 时间等等」
9 电竞手册开通时间 「还有电竞手册开通时间 啥的」
10 兜底原则 「只要跟这个账号有关系的 都记录下来」

2. 现状盘点:每条需求今天有没有数据

上机实测(2026-10-03)—— 结论是大部分业务数据已经存在,但两点不足: 散在 687MB 的噪声表里、以及状态变更没有历史(覆盖式)。本设计不回填这些历史, 只保证「从现在起不再丢」。

# 需求 今天的数据在哪 本设计怎么处理
1 导入时间 accounts.created_at ✅ 直接读,无需事件
2 CK 登录记录 login_tasks;解耦后状态在 accounts ✅ 读现有 + 新事件补「失效/重登」
3 CK 失效时间 只有 ck_check_status+ck_checked_at,每次检测覆盖 🆕 新增事件(覆盖前记一笔)
4 兑换记录 douyu_tasks exchange_goods(10,009 行) 🔸 不回填;上线后用收集器增量提取
5 积分多少 accounts.points(当前值) ✅ 状态卡直接读
6 消耗记录 兑换任务的 result.score 🔸 同 #4(增量)
7 充值记录 douyu_tasks create_gold_qr(13,867 行) 🔸 同 #4(增量)
8 精英宝典开通 只有 elite_opened(bool),覆盖式 🆕 新增事件
9 电竞手册开通 只有 esports_bind_status 字符串,覆盖式 🆕 新增事件
10 兜底(人工操作) audit_logs ⚠️ 关联不到账号,需补 account_id(§7)

🔸 = 既有数据不回填,从上线起增量收录。账号的历史明细仍可在既有的 /douyu/elite、/douyu/esports、/login-tasks 等页面查到,本需求不复制它们的历史。

2.1 为什么用事件表、而不是详情页实时聚合

douyu_tasks 现状 222,679 行 / 687MB,每天在写。若详情页实时聚合:

  • 每次开页要扫大表 + 解析巨型 JSON(单条兑换的 result ≈ 8KB);
  • 噪声占绝大多数(query_limited_goods 6.7 万行、query_change_bind_time 3 万行是例行轮询);
  • douyu_tasks 将来若做保留策略(规划 §11.4),账号历史会跟着消失。

所以事件表精简、独立、可长期保留。反过来说:不回填也就不必去核对历史 JSON 的字段路径, 风险与工作量都大幅下降。

3. 目标模型:account_events(账号事件流)

一张 append-only 的事件表,账号的时间线 = 这张表按时间倒序读。

class AccountEvent(Base):
    __tablename__ = "account_events"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), index=True)
    event_type: Mapped[str] = mapped_column(String(32), index=True)
    occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
    title: Mapped[str] = mapped_column(String(256), default="")  # 直接可展示
    amount: Mapped[int | None] = mapped_column(
        Integer, nullable=True
    )  # 充值额/消耗积分(有符号)
    balance_after: Mapped[int | None] = mapped_column(
        Integer, nullable=True
    )  # 变更后余额
    detail: Mapped[dict | None] = mapped_column(
        JSON, nullable=True
    )  # 精简后的细节(非原始大 JSON)
    source: Mapped[str] = mapped_column(
        String(24), default=""
    )  # login/task/audit/manual
    source_ref: Mapped[str] = mapped_column(String(96), unique=True)  # 幂等键
    created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)

设计要点:

  • source_ref 唯一键:如 douyu_task:123456、login_task:14166、audit:789。 作用是幂等:收集器重跑、事件重复发射都不会造出重复时间线(§4 的水位线失守时靠它兜底);
  • 不存原始大 JSON:只存提取出的业务字段(商品名、单号、金额…),把 687MB 的包袱甩掉;
  • amount 有符号:充值为正、兑换消耗为负,便于页面上做「累计充值 / 累计消耗」;
  • occurred_at 用业务发生时间(不是入库时间):收集器取原任务行的 finished_at || created_at; 直接发射的取当下时刻。

3.1 事件类型(初版)

event_type 含义 来源
imported 账号导入 accounts.created_at
login_ok 登录成功(含新 CK) login_tasks / 解耦后的账号写入点
ck_invalid CK 失效(新增记录) 巡检判定
relogin_ok / relogin_failed 自动重登结果 ck_relogin 作业
bind_ok / rebind 绑定 / 换绑 douyu_tasks
recharge 充值到账 create_gold_qr
exchange 兑换(含消耗积分) exchange_goods
points_credit 积分到账(含到期时间) donate_elite_gift
points_expire 积分即将到期 由 willExpireTime 派生
elite_opened 精英宝典开通(新增记录) douyu_runner_manual
esports_opened 电竞手册开通(新增记录) douyu_runner_manual
assigned / tag_changed / remark_changed 人工操作 audit_logs(需补 account_id,见 §7)

4. 业务事件怎么收录:增量收集器 → 已改为直发(不回填)

⚠️ 本节已作废(2026-10-04,S5)

收集器方案已放弃并删除代码。原因(详见 账号全生命周期设计 §3.8):

  1. 靠 allowlist 认业务类型必然漏:新增类型没人想起维护名单就静默丢数据 —— 电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此从来没进过时间线;
  2. 「任务 success」不等于「业务真的发生了」:预约到货、已预兑、「检测到本来就 已绑定」、积分未确认到账,都被标成 success,收集器照单全收写成事实;
  3. 字段路径靠抽样猜:二维码充值的 result 里根本没有金额,收集器因此恒写 amount=None,recharge_total 系统性少计;
  4. 作业默认关闭,生产上它一直没跑。

现在业务代码在动作成功那一刻用 services/business_events.py 的命名构造函数直发 (幂等键 douyu_task:{id},与旧收集器一致,迁移窗口内可互相去重)。

下文 §4.1 / §4.2 / §5.2 保留作为历史记录:其中的 result 字段实测结论仍然 有价值(接入斗鱼侧接口时用得上),但不要再按它实现收集器。

既有历史不导入。业务事件(充值/兑换/积分到账/绑定)用一个增量收集器从 douyu_tasks 往后提取 —— 好处是完全不侵入 runner 代码(不必改 8 个业务文件),且新老任务类型统一处理。

4.1 收集器设计

水位线(watermark):记录「已收集到 douyu_tasks.id = N」
每轮(挂到现有 automation 调度器,5~15 分钟一次):
  1. 读 douyu_tasks WHERE id > N AND task_type IN (业务类型) ORDER BY id LIMIT 500
  2. 按 §4.2 的映射提取字段 → 写 account_events(source_ref = "douyu_task:<id>" 保证幂等)
  3. 更新水位线到本批最大 id
  • 水位线存在 automation_jobs.config(JSON)或单独一小张表,必须持久化(进程重启不重来);
  • source_ref 唯一键兜住「同一行被处理两次」(例如水位线写入失败后重跑);
  • 起点 = 上线那一刻的 MAX(douyu_tasks.id),不回溯。

4.2 字段映射(已抽样核实,仅用于新数据)

已实测的 result 结构(用于收集器解析):

exchange_goods → exchange

事件字段 JSON 路径 实测样例
title commodity_name 套装-兔兔白日梦
detail.commodity_id commodity_id 20260120QYOOB_bag10
amount(消耗) score,取负 -1280
balance_after points_query.points 0
detail.order_id / exchange_id order_id / exchange_id 147879 / 11241526
detail.game_name game_name 唉痛恨透
detail.game_channel game_channel 手Q / 安卓(android)

create_gold_qr → recharge

事件字段 JSON 路径 实测样例
amount(充值) buy_num / pay_amount 98
detail.order_id order_id DYGF5C24C5E4EA84T238035
detail.supplier_order_id supplier_order.order_id 200000229425420261003114601
detail.supplier_finished_at supplier_order.finish_time 2026-10-03 11:46:07
detail.channel recharge_channel supplier_api
detail.douyu_uid douyu_uid 678503799

donate_elite_gift → points_credit

事件字段 JSON 路径 实测样例
amount(到账) gift_points_expected_gain 980
balance_after points / points_query.points 1280
detail.baseline / target gift_points_baseline / gift_points_target 300 / 1280
detail.points_expire_at points_query.raw.data.willExpireTime 1798819199(时间戳)

绑定类(confirm_bind / *_esports_bind)的 result 结构尚未抽样,实现前先各 dump 一条 (教训见 CK 误删复盘:字段假设必须实测)。收集器对 解析不出的行要记 warning 并跳过,不能让一行坏数据卡住整条水位线。

4.3 覆盖的事件量(预估)

按实测的业务任务量,稳定后每天新增事件量级在数百条(充值/兑换/到账为主), 一年约 10 万条以内 —— 事件表长期保留没有压力。

5. 事件从哪来:两条路

5.1 直接发(CK 相关 + 开通类)—— 4 处

这几类不是 douyu_tasks 里的行,只能在代码里「状态被覆盖之前先记一笔」:

场景 位置 事件
CK 判定失效 automation/jobs/ck_health.py(判定 invalid 时) ck_invalid
CK 重登成功/失败 login_service._write_login_success / ck_relogin(CK 解耦的同一处) login_ok / relogin_ok / relogin_failed
精英宝典开通 douyu_runner_manual.py:377(elite_opened = True 那一行) elite_opened
电竞手册开通 douyu_runner_manual.py:426(esports_bind_status = "esports_opened") esports_opened

这 4 处正好是「当前状态被覆盖」的现场。实现上收敛成一个 helper: account_events_service.emit(db, account_id, type, title, amount=..., balance_after=..., detail=...), 并要求发射失败不影响主流程(事件是旁路,不能因为它让登录/兑换失败)。

5.2 收集器(业务类)—— 0 处侵入

充值 / 兑换 / 积分到账 / 绑定 这些已经是 douyu_tasks 的行,走 §4 的增量收集器, 不动 runner 代码。

这个分工的取舍:CK 与开通事件「只在内存里出现过」,不记就永远没有; 业务事件已经在表里有结构化落库,再改一遍 runner 属于重复工作。

6. 页面设计:/accounts/:id(账号详情)

现在是列表页(AccountsPage.tsx),没有详情页。新增:

┌─ 顶部:账号身份 ────────────────────────────────────────┐
│ 用户名 / 斗鱼昵称+UID / 标签 / 归属客服 / 导入时间(第 N 天)  │
│ 操作:分配 · 标签 · 备注 · 登录 · 检测 CK · 重登 · 导出      │
└──────────────────────────────────────────────────────┘
┌─ 状态卡(当前值:accounts 表)──────────────────────────┐
│ 积分(含到期时间) · 鱼翅余额 · 兑换余额                    │
│ 精英宝典:已开通 / 到期时间 · 电竞手册:开通+绑定+可换绑     │
│ CK:状态 · 最近检测 · 最近重登  ← CK 解耦后从 accounts 读  │
└──────────────────────────────────────────────────────┘
┌─ 统计条(由 account_events 聚合)──────────────────────┐
│ 累计充值 ¥X · 累计兑换 N 次 / 消耗积分 M · CK 失效 K 次    │
└──────────────────────────────────────────────────────┘
┌─ 时间线(account_events 倒序 + 类型筛选 + 分页)────────┐
│ 10-03 11:46  充值    ¥98  供应商直充成功                 │
│ 10-03 11:46  积分    +980  → 余额 1280(到期 12-31)      │
│ 10-03 11:46  兑换    -1280 套装-兔兔白日梦               │
│ 10-02 17:17  登录    重新登录成功,Cookie 有效            │
│ 10-01 09:12  CK失效  检测判定 CK 已失效                   │
│ 09-30 14:00  开通    精英宝典开通                         │
└──────────────────────────────────────────────────────┘
  • 接口:GET /api/accounts/{id}/events?types=&page=&page_size=、GET /api/accounts/{id}/overview;
  • 权限:沿用账号可见性(客服只能看自己名下账号)+ 敏感字段按现有 account:view_full 控制;
  • 入口:账号列表点用户名进详情;从 CK 页、登录任务页也可跳转。

7. 必须先修的缺口:audit_logs 关联不到账号

实测 audit_logs.target 是自由文本:

action 样例 target
account:assign 冰柳192(用户名)
account:import 导入103个(一句话)
recharge:douyu:create douyu_batch:000619c494fd(批次号)
account:delete hui60945(用户名)

没有 account_id,只能拿用户名去模糊匹配 —— 用户名可改、可重复,不可靠。

建议:audit_logs 增加 account_id(可空)列,账号相关操作写入时带上。 这样「谁在什么时候分配/改标签/删除了这个账号」才能进账号时间线。 这是本次需求的前置项,改动很小(加一列 + 若干调用点传参)。

8. 与 CK 解耦怎么「一块弄」

两者的写入点是同一批,所以设计上共用一套约定,实施上排好顺序即可:

事项 CK 解耦 账号事件
状态落点 CK/健康/重登状态 → accounts 新增 account_events(流水)
写入点 _write_login_success、ck_health、ck_relogin 同上 + elite/esports 开通 两处
读取点 Cookie 页、工作台、仪表盘 新增详情页

共享的一条原则:accounts 存当前状态,account_events 存发生过什么 —— 这与 CK 解耦「状态归账号、流水归历史表」是同一个模型。做完解耦,账号上就有了完整的 「当前状态」,事件表补上「历史」,详情页就是把两者拼起来。

建议顺序(在 CK 解耦的 7 个提交 之后接):

提交 内容 依赖
E1 feat(db): account_events 表 + audit_logs.account_id 无
E2 feat(events): 事件发射 helper + CK 事件(失效/登录/重登) CK 解耦已完成(否则写入点还会变)
E3 feat(events): 开通事件(精英宝典 / 电竞手册) 无
E4 feat(events): douyu_tasks 增量收集器 + 水位线 已废弃(S5 改为直发并删除) 无
E5 feat(api): 账号事件与概览接口 E1
E6 feat(ui): 账号详情页(状态卡 + 统计 + 时间线) E5
E7 docs: 落点与验收 ——

原计划的「历史回填脚本」已取消(§2 已定不回填),E4 从「搬运 8.5 万行」变成 「装一个水位线收集器」。

9. 风险与注意

  1. 不改 runner 代码:业务事件走收集器(§4),只有 CK/开通 4 处直接发(§5.1)—— 把改动面压到最小;
  2. source_ref 唯一键是幂等的生命线:收集器重跑、事件重复发射都靠它兜住, 漏了就会污染时间线;
  3. 收集器的水位线必须持久化,且解析失败要跳过并告警,不能让一行坏数据卡住整条链路;
  4. 发射事件是旁路:必须保证 emit() 抛错不影响登录/兑换主流程(用 try 包住 + 记日志);
  5. 积分口径要先定(决策 C):accounts.points 与 donate_elite_gift 的 points_query.points 有不一致的情况(如兑换后 points_query.points=0),时间线显示哪个要明确;
  6. 绑定类任务的 result 结构尚未抽样(§4.2),收集器实现前先 dump 一条确认。 已随收集器一并作废:直发不再需要解析 result。

10. 待确认的决策点

# 决策 选项 建议
A 时间线是否收录「例行查询」类事件 ①只收业务事件(推荐)②全都收 ①:6.7 万行轮询进去后时间线没法看
B 详情页入口 ①账号列表点用户名(推荐)②独立菜单 ①更顺手;②可加,但主入口应是列表
C 积分显示口径 ①以 accounts.points 为准,事件里记变更量(推荐)②以事件累加为准 ①简单可靠;②容易和历史噪声打架
D 收集器的跑法 ①挂到现有 automation 调度器(推荐)②独立定时任务 ①复用现成的开关/运行记录/页面;②多一套机制
E audit_logs 加 account_id 是否随本次做 ①做(否则人工操作进不了时间线)②先不做 ①(§7)
F 回填范围 —— ✅ 已定:不回填,从上线起增量累积

11. 落地记录(2026-10-03)

已按本文实现,已提交在 dev、未碰生产(用户要求先本地验证)。迁移编号见 §11.4。

提交建议 内容 落点
E1 迁移 20261003_0041:account_events + audit_logs.account_id migrations/versions/20261003_0041_account_events.py、models.py(AccountEvent)、tests/test_migrations.py
E2 事件服务 emit() + 任务映射 services/account_events_service.py
E3 发射点:CK 失效 / 登录·重登成功 / 重登转人工 / 精英宝典·电竞手册开通 jobs/ck_health.py、services/login_service.py、jobs/ck_relogin.py、services/douyu_runner_manual.py
E4 收集器作业(水位线在 automation_jobs.config) jobs/account_events_collect.py,注册为「账号事件收集」
E5 接口 routers/accounts.py:GET /{id}/overview、GET /{id}/events
E6 页面 pages/AccountDetailPage.tsx;路由 /accounts/:id;列表用户名可点入

11.1 与设计的差异(实施时改的)

  1. 收集器的水位线放作业自己的 config,不再寄生在 ck_health 的 config 里 —— 顺带白拿「自动巡检」页的开关、运行记录与手动触发;
  2. source_ref 可空唯一,不用空串默认值。原设计写的是 server_default="":MySQL 唯一索引下 空串会互相冲突,第二条「无幂等键」的事件就插不进去。改为可空(MySQL 允许 NULL 重复), 并补了用例专门盯这点;
  3. 电竞手册只在「真正开通」那一支发事件。代码里有两处都会写 esports_bind_status="esports_opened": 支付流程成功后(真开通)与「基线查到已开通」(检测)。两处都发会每次检查刷一条,只取前者;
  4. 重登失败只在「转人工」时发事件:还要退避重试的每轮都会再来,发事件会刷屏;
  5. emit 用 SAVEPOINT 隔离:失败只回滚这一条,不污染调用方事务(有用例覆盖)。

11.2 实施时踩到的坑

  1. create_all 不改已存在的表 —— 给 audit_logs 加列后,测试库里的旧表没有该列, 22 个不相关测试全挂(Unknown column 'account_id')。改已有表的模型后,测试库要么手动 ALTER,要么 DROP 让 create_all 重建。已在本地测试库补列;这个坑值得写进通用约定;

  2. REPEATABLE READ 快照(又踩一次):用例里用另一个 session 写入事件后,原 session 查不到 —— 与 P1b「成功被记成失败」同根因。用例里补 commit() 结束事务;

  3. delete_account 必须连带清事件:account_events 有指向 accounts 的外键,不清就删不掉账号。 这是本次改动引入的问题,已修并有用例;

  4. 测试数据的字符集:用 docker exec mysql < file 灌中文时没带 --default-character-set=utf8mb4, 数据被双重编码(套装 → 套装)。排查时先看 HEX() 就能立刻分清「存坏了」还是「读坏了」;

  5. ⚠️ naive datetime 被当成「本地时间」(2026-10-04 审计发现并修复): emit() 原来写的是 (occurred_at or now).astimezone(UTC)。对 naive 值,Python 会把它 当作服务器本地时间再换算 —— 开发机 UTC+8,事件时间整整早 8 小时。 naive 的来源很常见:裸 SQL 读 DATETIME 列(收集器读 douyu_tasks.finished_at) 会绕过 UTCDateTime 装饰器,拿到的就是 naive。 仓库的既定约定是「naive = 已经是 UTC,原样用」,scheduler._as_utc / yyb_service._as_utc / audit.py 都遵守了,只有 emit() 漏了。 已抽成 account_events_service.to_naive_utc() 统一收口。

    这个 bug 在 TZ=UTC 的机器上完全不可见(astimezone(UTC) 恰好是恒等变换), 所以只在开发机上现形、CI/容器里全绿 —— 回归用例必须显式把进程时区切到非 UTC 才有意义(见 tests/test_account_events.py 的 _force_non_utc_timezone)。

    若某套环境曾开启过收集器,用这段按源任务时间对齐(幂等):

    UPDATE account_events e
    JOIN douyu_tasks t ON t.id = CAST(SUBSTRING(e.source_ref, 12) AS UNSIGNED)
    SET e.occurred_at = COALESCE(t.finished_at, t.created_at)
    WHERE e.source = 'task' AND e.source_ref LIKE 'douyu_task:%';
    

11.3 本地验证结果

项 结果
后端测试 363 通过(新增 20 条:幂等、旁路隔离、映射、水位线、API、账号隔离)
收集器 3 条业务任务 → 3 条事件;金额/余额/中文均正确;水位线 0 → 686
概览接口 累计充值 98、兑换 1 次、消耗积分 1280、导入时间、登录次数、最近 CK 检测均正确
时间线接口 倒序分页正确、按类型筛选正确、中文无乱码
前端 tsc -b 通过、vite build 通过、模块转译正常(dev 5174 → 8800)

本地验证时把收集器临时启用并手动灌了 3 条测试任务(batch_id='local-evt',仅本地开发库)。 生产上该作业仍是 enabled=false,需在「自动巡检」页手动打开;首次运行只定水位线、不回溯历史。

11.4 迁移编号改号:20261002_0040 → 20261003_0041

本功能最初取的号是 20261002_0040,down_revision 指向 20261002_0039。开发期间生产上并发 落地了索引优化,它同样从 20261002_0039 起链、占了 20261003_0040。两边一旦合并, 两个迁移平级 = 两个 alembic head,alembic upgrade head 会直接报 Multiple head revisions are present,tests/test_migrations.py 的单头断言也会失败。

处理(2026-10-03):

  1. 文件名改为 20261003_0041_account_events.py,revision 改 20261003_0041, down_revision 改指 20261003_0040,接在索引优化之后;
  2. tests/test_migrations.py 的 HEAD_REVISION 同步改 20261003_0041;
  3. 改号是安全的,因为 20261002_0040 从未被生产库应用过(dev 未部署)。 本机开发库 douyu_login 的 alembic_version 已是 20261003_0040,且 account_events 表已存在 —— 迁移写成幂等的(先 sa.inspect 查存在再建),所以升级到 0041 只会空跑一次 并把版本号推进,不会重复建表。

⚠️ 教训与规划 §C.1 一致:迁移编号一旦被任何库应用过就不能再改名。 这次能改,纯粹是因为它还没进生产。