- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
26 KiB
设计:账号详情页与账号事件历史
状态:后端已完成、前端已完成,本地验证通过(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_goods6.7 万行、query_change_bind_time3 万行是例行轮询); 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):
- 靠 allowlist 认业务类型必然漏:新增类型没人想起维护名单就静默丢数据 —— 电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此从来没进过时间线;
- 「任务 success」不等于「业务真的发生了」:预约到货、已预兑、「检测到本来就 已绑定」、积分未确认到账,都被标成 success,收集器照单全收写成事实;
- 字段路径靠抽样猜:二维码充值的
result里根本没有金额,收集器因此恒写amount=None,recharge_total系统性少计;- 作业默认关闭,生产上它一直没跑。
现在业务代码在动作成功那一刻用
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): 开通事件(精英宝典 / 电竞手册) |
无 |
feat(events): douyu_tasks 增量收集器 + 水位线 |
无 | |
| E5 | feat(api): 账号事件与概览接口 |
E1 |
| E6 | feat(ui): 账号详情页(状态卡 + 统计 + 时间线) |
E5 |
| E7 | docs: 落点与验收 |
—— |
原计划的「历史回填脚本」已取消(§2 已定不回填),E4 从「搬运 8.5 万行」变成 「装一个水位线收集器」。
9. 风险与注意
- 不改 runner 代码:业务事件走收集器(§4),只有 CK/开通 4 处直接发(§5.1)—— 把改动面压到最小;
source_ref唯一键是幂等的生命线:收集器重跑、事件重复发射都靠它兜住, 漏了就会污染时间线;- 收集器的水位线必须持久化,且解析失败要跳过并告警,不能让一行坏数据卡住整条链路;
- 发射事件是旁路:必须保证
emit()抛错不影响登录/兑换主流程(用 try 包住 + 记日志); - 积分口径要先定(决策 C):
accounts.points与donate_elite_gift的points_query.points有不一致的情况(如兑换后points_query.points=0),时间线显示哪个要明确; 绑定类任务的已随收集器一并作废:直发不再需要解析result结构尚未抽样(§4.2),收集器实现前先 dump 一条确认。result。
10. 待确认的决策点
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| A | 时间线是否收录「例行查询」类事件 | ①只收业务事件(推荐)②全都收 | ①:6.7 万行轮询进去后时间线没法看 |
| B | 详情页入口 | ①账号列表点用户名(推荐)②独立菜单 | ①更顺手;②可加,但主入口应是列表 |
| C | 积分显示口径 | ①以 accounts.points 为准,事件里记变更量(推荐)②以事件累加为准 |
①简单可靠;②容易和历史噪声打架 |
| D | 收集器的跑法 | ①挂到现有 automation 调度器(推荐)②独立定时任务 | ①复用现成的开关/运行记录/页面;②多一套机制 |
| E | audit_logs 加 account_id 是否随本次做 |
①做(否则人工操作进不了时间线)②先不做 | ①(§7) |
| —— | ✅ 已定:不回填,从上线起增量累积 |
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 与设计的差异(实施时改的)
- 收集器的水位线放作业自己的
config,不再寄生在ck_health的 config 里 —— 顺带白拿「自动巡检」页的开关、运行记录与手动触发; source_ref可空唯一,不用空串默认值。原设计写的是server_default="":MySQL 唯一索引下 空串会互相冲突,第二条「无幂等键」的事件就插不进去。改为可空(MySQL 允许 NULL 重复), 并补了用例专门盯这点;- 电竞手册只在「真正开通」那一支发事件。代码里有两处都会写
esports_bind_status="esports_opened": 支付流程成功后(真开通)与「基线查到已开通」(检测)。两处都发会每次检查刷一条,只取前者; - 重登失败只在「转人工」时发事件:还要退避重试的每轮都会再来,发事件会刷屏;
emit用 SAVEPOINT 隔离:失败只回滚这一条,不污染调用方事务(有用例覆盖)。
11.2 实施时踩到的坑
-
create_all不改已存在的表 —— 给audit_logs加列后,测试库里的旧表没有该列, 22 个不相关测试全挂(Unknown column 'account_id')。改已有表的模型后,测试库要么手动ALTER,要么DROP让create_all重建。已在本地测试库补列;这个坑值得写进通用约定; -
REPEATABLE READ 快照(又踩一次):用例里用另一个 session 写入事件后,原 session 查不到 —— 与 P1b「成功被记成失败」同根因。用例里补
commit()结束事务; -
delete_account必须连带清事件:account_events有指向accounts的外键,不清就删不掉账号。 这是本次改动引入的问题,已修并有用例; -
测试数据的字符集:用
docker exec mysql < file灌中文时没带--default-character-set=utf8mb4, 数据被双重编码(套装→套装)。排查时先看HEX()就能立刻分清「存坏了」还是「读坏了」; -
⚠️ 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):
- 文件名改为
20261003_0041_account_events.py,revision改20261003_0041,down_revision改指20261003_0040,接在索引优化之后; tests/test_migrations.py的HEAD_REVISION同步改20261003_0041;- 改号是安全的,因为
20261002_0040从未被生产库应用过(dev未部署)。 本机开发库douyu_login的alembic_version已是20261003_0040,且account_events表已存在 —— 迁移写成幂等的(先sa.inspect查存在再建),所以升级到0041只会空跑一次 并把版本号推进,不会重复建表。
⚠️ 教训与规划 §C.1 一致:迁移编号一旦被任何库应用过就不能再改名。 这次能改,纯粹是因为它还没进生产。