e5f2d2d5d2b7b85d30c862acfec1d77eea643fa8
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
631e33a4f6 |
fix(events): 撞唯一键是幂等命中,不该刷 WARNING + dump 整条 SQL
## 现象(用户从日志里发现的)
WARNING | 写入账号事件失败(account=60 exchange):
(1062, "Duplicate entry 'official_exchange:elite:60:20260120QYOOB_bag360:...'")
[SQL: INSERT INTO account_events (...) VALUES (...)]
[parameters: {...}]
## 分析:**不是 bug,是幂等机制在工作,但日志级别错了**
`emit()` 刻意**不做预检查**,直接 INSERT,靠 `source_ref` 的唯一索引去重;
撞了就 catch(SAVEPOINT 只回滚这一条)。所以:
- **数据是对的** —— 重复的被挡住,`exchange_events_added` 只计新写入的
- **日志是错的** —— 撞唯一键被当成异常打 WARNING,还把整条 SQL + 参数 dump 出来
而"撞唯一键"在这里是**预期内的正常情况**:一键查询会把斗鱼官方兑换记录补成事件,
同一个账号会被反复查询 —— 第二次开始每条记录都会撞。一次查询能刷几十屏,
既淹掉真日志、又让人以为出事了(用户就是因此来问的)。
## 修法
区分「唯一键冲突」与「真错误」:
- 确认是 **1062**(并带 `Duplicate entry` 字样)→ 降为 **DEBUG**,一句话说明"已存在,跳过"
- 其它完整性错误(如非空约束 1048)→ **仍然是 WARNING**
刻意**不把 IntegrityError 整个静音** —— 那会把真问题一起藏起来。
新增 `_is_duplicate_entry()` 只在两种信号都对上时才降级。
## 测试(+3)
- 重复写入返回 False,且**不产生**任何 "写入账号事件失败" 的 WARNING
(用 loguru 的临时 sink 真的抓日志来断言,不是看代码)
- 重复写入不会写进第二条
- `_is_duplicate_entry` 只认 1062,1048(非空约束)仍然返回 False
|
||
|
|
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`
供列表点击预览(按需拉取,不进列表响应)。
|
||
|
|
0b1fc1dee0 |
feat(events): 补齐资产【出项】—— 开通金额、鱼翅消耗、积分到期(C 最全)
审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** —— 充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记, 于是「一共充了多少」算得出来,「花哪儿了」算不出来。 按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。 ## A. 开通类事件补金额 `elite_opened` / `esports_opened` 以前只记「开通后的积分余额」,不记**花了多少**。 现在补 `amount = -(开通前后积分差)`,并用**实测余额差**而不是"开通要多少分"的常量 (活动价、折扣会让实际扣减不同)。任一侧拿不到就不写金额,不编数字。 ## B. 鱼翅消耗事件(新 `gold_spent`) 送礼以前只发「积分到账」,**花掉的鱼翅没有任何记录**。现在: - 送礼**前后各刷一次**鱼翅(原来只在送礼后刷),取**实测余额差**; - 发 `gold_spent`,`amount` 用负数(与 `exchange` 一致); - 余额没减少或拿不到 → **不发**:宁可少一条,也不要记一笔没发生的消耗。 ## C. 积分到期(新 `points_expire`) 积分到期是资产真实减少,但**斗鱼不会通知** —— 只在积分查询里给 `willExpireTime`。 所以: - 新增 `accounts.points_expire_at`(迁移 `0046`),送礼流程学到就写下来; - 新增作业 `points_expire_check`:扫「已到期且积分未归零」的账号,补写事件并清标记 (不重复记);积分已为 0 则只清标记、不发事件(避免 `-0` 噪声)。 ⚠️ **金额的诚实边界**:到期那一刻**到底扣了多少,我们没亲眼看到**。 所以默认 `confirmed=False`、标题带「(预计)」,记的是「当时余额」这个**预计要归零**的数量, **不假装它是确认过的扣减**。作业刻意不发网络请求(纯 DB 可跑、不需要 CK), 要变成已确认只能由「到期后重查、真看到余额下降」的调用方补。 ## 顺带修掉一个会静默丢事件的坑 同一个任务可能产生**两条事件**(送礼:既「积分到账」又「消耗鱼翅」)。 `source_ref` 是唯一键 —— 共用 `task_ref(task_id)` 会让**后写的那条被静默丢弃**。 已让副事件带后缀(`douyu_task:{id}:gold`),主事件保持原形态(与旧收集器一致)。 有用例专门钉住「一个任务两条事件都不丢」。 ## 验证 - `TZ=Asia/Shanghai`:**550 passed / 6 skipped**(+18 条) - `TZ=UTC`(事件 + 迁移 + 绑定)95 passed;ruff 全绿;前端 build 通过、lint 0 errors - 真实开发库:迁移 `0046` 已应用;作业烟测 → 扫到 1 个到期账号, 写入「积分到期 -500(预计)」`confirmed=False`,标记被清、重跑不再重复 ## 现在的资产流水(斗鱼侧) | 方向 | 覆盖 | | --- | --- | | 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ | | 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** | | 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) | |
||
|
|
6e729e7a80 |
feat(account): S5 业务事实直发事件 + 删掉收集器 + 任务日志保留策略
时间线从此覆盖**全部**业务事实,`douyu_tasks` 也有了可控的增长边界。
## 一、删掉收集器(它必然漏数据)
`jobs/account_events_collect.py` 连同 `_task_to_event` / `COLLECTED_TASK_TYPES` /
水位线一起删除,迁移 `20261004_0043` 清掉它的作业行。删除理由是实测出来的:
- **靠 allowlist 认业务类型,新增类型就静默丢**:原有名单只有 5 个,
而 `exchange_esports_goods`(电竞兑换)、`exchange_xpd_goods`(和平小店兑换)、
`pay_locked_order`(锁单支付)、`donate_esports_*`(电竞礼物积分)**从来没进过时间线**;
- **「任务 success」≠「业务真的发生了」**(见下);
- **字段路径靠抽样猜**:二维码充值的 `result` 里根本没有金额,收集器恒写
`amount=None`,`recharge_total` 系统性少计;
- 作业默认 `enabled=False`,生产上一直没跑。
历史**不回填**(既有结论不变):需要历史走斗鱼侧权威接口
(`query_exchange_records` / `query_xpd_purchase_records` / `query_points` 都是现成任务类型)。
## 二、业务事实直发:命名构造函数
新增 `services/business_events.py`:`record_recharge` / `record_exchange` /
`record_points_credit` / `record_bind`。每类事实的字段、标题、金额符号只定义一次
(与 S4 的 `record_account_action`、`on_ck_verdict` 同一套路)。
**9 处成功点接线**:`exchange_goods` 真兑换分支、`exchange_esports_goods`、
`exchange_xpd_goods`、`pay_locked_order`、`create_gold_qr`(二维码 + 供应商轮询 +
**供应商回调**三条路径)、`donate_elite_gift`、`donate_esports_gift`、
`confirm_bind`、`confirm_esports_bind`。
**只记真实事实**(按你的决定):`exchange_goods` 里「预约到货 / 已预兑 / 预兑确认」
三条 success 分支**不发事件** —— 它们不消耗积分也没换到东西,旧收集器只按 status
收集,于是每预约一次就生成一条金额为空的 `exchange` 把 `exchange_count` 灌大。
`confirm_bind` 里「检测到账号本来就已绑定」那条也跳过。
## 三、修掉两个真 bug(都是钱账)
1. **到账判据 `!=` → `>`**(`douyu_runner_gold.py`):余额**下降**也算「鱼翅已到账」。
批内并发(最高 10)时同账号送礼会消耗鱼翅,余额一变就被充值任务读成到账,
**往台账记一笔根本没发生的充值**。
2. **供应商回调晚于 runner 超时**:原来守卫是「任务状态没被改过才更新」,
于是 runner 先判 failed、回调随后说成功的那笔真金白银**永远不进时间线**。
改成按**回调自己的终态**判断,并补发事件。
## 四、发射顺序:一个会静默丢事件的坑
`_mark_task` 内部 `db.commit()`,而 `emit` 用 `db.begin_nested()` **只 RELEASE
SAVEPOINT、不提交外层事务**;runner 的 `finally` 只 `close()`(未提交即回滚)。
所以**在 `_mark_task` 之后发事件会被静默丢弃,且 `emit` 仍返回 True**。
所有接线一律紧贴 `_mark_task(success)` **之前**,并加了源码扫描用例钉住
(已反向验证:故意挪到之后,用例立刻报出 file:line)。
## 五、`douyu_tasks` 保留策略(新增作业,默认关闭)
`jobs/douyu_tasks_retention.py`,两档:
| 档 | 保留 |
| --- | --- |
| 噪声查询(只读看一眼就走,且前端不拿它派生账号状态) | **7 天** |
| 其余一切(业务事实 + 状态派生依赖的查询 + **以后新增的类型**) | **90 天** |
长保留档用 `task_type NOT IN (噪声名单)` 表达 → **新增类型默认长保留**,不会因为
「忘了加名单」被误删(正是收集器翻车的反面)。在途任务(planned/pending/running)
**绝不删**;分批删避免长锁;两个窗口都能在作业 `config` 里改,不必发版。
⚠️ 「前端从任务列表反推账号当前状态」那几类查询(`query_xpd_bind_info` /
`query_esports_game_name` 等)**故意留在长保留档** —— 删早了工作台账号行会变
「未查」,那是把**当前显示**搞坏。根治要靠 §3.6 的状态列拆解(后续阶段)。
作业默认 `enabled=False`,由运维决定何时打开。
## 验证
- `TZ=Asia/Shanghai`:**450 passed / 6 skipped**(S4 后 426 → 净增 24)
- `TZ=UTC`(相关文件)101 passed;ruff check/format 全绿
- 前端 build 通过、lint 0 errors
- 真实开发库:迁移已应用(head `20261004_0043`,孤儿作业行已清);
保留作业实跑确认「作业行默认关闭、SQL 在 MySQL 上正确、近期行不误删」
- **两处反向验证**:把 `record_bind` 挪到 `_mark_task` 之后 → 顺序用例报红;
修掉 `registered_jobs()` 那个**假绿用例**(它返回 dict,原来的集合推导式
遍历空字典恒成立),并在断言里加了反向确认
未做(后续):S6 账号页重构 + `bind_status` 三列拆解(做完才能把状态派生类查询
也纳入短保留)、S7 账号检测改账号维度、S8 丢 `login_tasks` 旧凭据列。
|
||
|
|
253571730a |
feat(account): 补上改备注接口,remark_changed 事件从此有发射点
`remark_changed` 此前是「定义了事件类型、但压根没有改备注的接口」,于是永远不发。
按 S4 的原则(留着一个永远不发的类型比删掉更糟 —— 看代码的人会以为它有数据),
补接口而不是删类型。
## 接口
`PUT /api/accounts/{id}/remark`,权限点 `account:remark`(授予 super_admin /
operation / support,与 `account:tag` 一致)。
- 与改标签一样走 `record_account_action()`:**审计 + 账号时间线一次写完**,
审计带 `account_id`,事件 detail 带 `{from, to}`;
- **只做单个、不做批量**:备注是自由文本,给一堆账号写同一句备注没有实际意义
(标签是短枚举才有批量);
- 内容 trim 后截断到 256(与 `accounts.remark` 列宽一致),清空也记一条事件;
- 备注没变化时直接返回、**不写事件**:否则时间线会被「点开保存了一下但没改」刷满。
## 权限点新增前的安全检查
新权限点会被 `custom_permissions` 覆盖,所以先查了库:
`admin` / `kf1_test` 的 `custom_permissions` 是 **JSON null**(不是 SQL NULL),
Python 侧取出来是 `None` → `get_user_permissions` 走角色默认。
也就是说**没有任何用户在用自定义权限列表**,新增权限点不会把谁的能力锁掉。
(这一点很容易看错:`IS NULL` 判出来是「有自定义权限」,`JSON_LENGTH` 还是 1。)
## 前端
- 详情页「备注」一行改为可编辑(铅笔 → 输入框 → 保存/取消,回车即存);
- `EVENT_STYLE` 补 `remark_changed`,时间线里能看到备注的每次变更。
## 一个自己踩到又修掉的坑
第一版把两个 `useState` 写在了 `if (!accountId) return <Empty/>` **之后** ——
正是本次会话刚在 LoginTasksPage 修掉的那类 hooks 顺序违规(`react-hooks/rules-of-hooks`
直接报错)。已上移到早期 return 之前的 state 区块。lint 抓到,构建不报,属于
「能跑但迟早炸」的那类问题。
## 验证
- `TZ=Asia/Shanghai`:**426 passed / 6 skipped**(S4 后 420 → 新增 6 条)
- `TZ=UTC`(相关文件)77 passed;ruff check/format 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` 0 errors
- 新增用例:写入并带 from/to 事件与审计关联、未变化不写事件、清空也记一条、
trim + 截断到 256、账号不存在 404、**新权限点确实在三个角色的默认权限里**
(防止「加了接口但没人能用」)
|
||
|
|
0a922ce56d |
feat(account): S4 事件发射点补齐 —— 审计与时间线打通、批量事件、ck_invalid 只认转变
S3 让 `status` 可信,S4 让**时间线完整**:账号上发生的事,时间线里都能查到。 ## 补齐「定义了却从没发过」的事件 `assigned` / `tag_changed` 这两个类型早就定义了、前端 `EVENT_STYLE` 也渲染了, 但**全仓没有任何代码发过**(`grep` 只有定义处)。本次接上: - 单个分配 / 批量分配 → `assigned`(detail 带 assigned_to / assigned_username) - 单个改标签 / 批量改标签 / 按筛选批量改标签 → `tag_changed`(detail 带 from/to) ## 新增 `ck_cleared`,并修掉 `ck_invalid` 的刷屏 - **`ck_cleared`**:清 CK 是**不可逆**操作(重登当前成功率极低),时间线里却不留痕。 现在单个清除与批量清除共用 `account_lifecycle.on_ck_cleared()`。 - **`ck_invalid` 改为只在「由好变坏」时发**。旧实现每轮巡检只要判定 invalid 就发一条, 源码注释写着「避免每轮刷屏」,**但代码并没有做到** —— 6 小时一轮、失效一周就是 28 条一模一样的事件。判定失效是**一次状态转变**,不是每轮读数。`error`(查不到) 同样不改变结论、不发事件。 ## 审计与时间线一次写完(防漏一边) 新增 `audit_service.record_account_action()`:**审计 + 账号事件一起写**,并把 `account_id` 落到审计上。这两件事必须成对出现 —— 只写审计,时间线就缺「谁改了什么」; 只发事件,合规审计就缺一条。分成两次调用写,迟早有人漏一边(`assigned` 就是这么漏的)。 顺带把批量删除的审计补上被删账号 id(`detail.account_ids`,截前 200 条): 删账号会**连带删掉它的事件时间线**(外键),审计是唯一痕迹 —— 这正是 10-03 那次事故要留的东西。 ## 批量操作不再逐条开 SAVEPOINT 批量分配/改标签可能覆盖几千个账号,而每个账号的时间线都该各留一条。 逐条走 `emit()` 会给每条开一个 SAVEPOINT,几千条时请求明显变慢。 新增 `emit_bulk()`:一次 executemany,整批包在**一个** SAVEPOINT 里, 与 `emit()` 一样**绝不抛异常**(整批失败就整批丢弃 + warning,不影响业务写入)。 ## 检测入口收口:4 处 → 1 个 `on_ck_verdict()` `ck_health` / 人工检测 / 工作台执行前检测 / 重登后复检 原本各自写 `ck_check_status` + `ck_check_result` + `ck_checked_at` + 发事件 + 调 `sync_status`, 口径容易分叉(`ck_invalid` 的刷屏就是这么来的)。现在统一走 `account_lifecycle.on_ck_verdict()`,顺序固定:**写结论 → 由好变坏记事件 → 重算状态**。 至此 `account_lifecycle` 成为**唯一**写生命周期字段的模块,并加了一条**源码扫描用例** 把它钉死(任何地方直接 `acc.status = ...` 都会让它红,并指出 file:line)—— 比写在文档里的约定可靠。虎牙的 `huya_accounts.status` 是同一个病,但属另一套模型, 已在用例里显式排除并注明。 ## 接口与前端 - 时间线支持 `?category=` 筛选(lifecycle / auth / business / ops); 响应补 `category` / `subject_*` / `actor_*` - 前端 `EVENT_STYLE` 补上 `status_changed`(**S3 我漏的**:加了事件类型却没加样式, 页面上显示成原始英文)与 `ck_cleared`;时间线加「分类」筛选 ## 验证 - `TZ=Asia/Shanghai`:**420 passed / 6 skipped**(S3 后 404 → 新增 16 条) - 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 与 `npm run lint`(0 errors)均通过 - **两条关键用例都做过「反向验证」**(先注入旧行为确认会红): - 把 `ck_invalid` 改回「每轮都发」→ 用例报 `3 == 1`; - 在路由里直接写 `acc.status = ...` → 不变量用例给出精确 file:line。 - 新用例还覆盖:批量分配/改标签逐账号发事件、清 CK 事件与审计关联、 `emit_bulk` 的跳过与容错、时间线分类筛选、批量删除的 id 留痕。 未做(后续):S5 业务事实直发事件 + `douyu_tasks` 保留策略、S6 账号页重构 + `bind_status` 三列拆解。`remark_changed` 目前没有改备注的接口,故无发射点(已在文档标注)。 |
||
|
|
1a8710834a |
feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环
`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。
## 状态机(7 → 6 个状态)
`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`
**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。
## 唯一推导入口(防「双份真相」)
新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。
**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。
## 消费端:补上一个真缺口 + 删掉两处临时派生
- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
`status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。
## 接口与前端
- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关
## 两个自查出来的问题(都已修)
1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
`source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
—— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
`relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
临时改回旧 CASE 该用例立刻红)。
## 验证
- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
自动化排除 disabled/archived、列表状态筛选、接口
未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
|
||
|
|
94eace50c1 |
fix(events): S2.1 修掉 emit() 的 naive 时区偏移,并打通台账列与审计关联
外部审计对 S1+S2 提出 3 条,全部复核成立,本提交一并修掉。第 1 条是真缺陷,
且已经写进数据了。
## 1. ⚠️ emit() 把 naive datetime 当成「本地时间」(数据正确性)
`emit()` 原来写 `(occurred_at or now).astimezone(UTC)`。对 **naive** 值,Python 会
把它当作**服务器本地时间**再换算 —— 本机 UTC+8,事件时间整整早 8 小时。
naive 的来源很具体:收集器用**裸 SQL** 读 `douyu_tasks`,绕过 `UTCDateTime`
装饰器,拿到的就是 naive。而这条路正是「充值/兑换/积分/开通」台账。
仓库早有既定约定(写在 `database.UTCDateTime` 里):**naive → 视为已是 UTC,原样用**。
`scheduler._as_utc` / `yyb_service._as_utc` / `audit.py` 都遵守了,**只有 emit() 漏了**。
现抽成 `account_events_service.to_naive_utc()` 统一收口,并补齐文档。
**数据库铁证**(修复前,本机开发库):
```
事件 occurred_at 源 douyu_tasks.finished_at 相差
2026-09-30 20:23:36 → 2026-10-01 04:23:36 8 小时
```
已用 `UPDATE ... JOIN douyu_tasks` 把那 3 条按源时间对齐(相差 0 秒),
修复 SQL 记在账号事件设计文档 §11.2 第 5 条,供曾开启过收集器的环境使用。
该作业默认 `enabled=false`、需人工开启,故生产大概率无受影响数据。
## 2. 回归用例:这个 bug 在 UTC 机器上**看不见**
`astimezone(UTC)` 在 TZ=UTC 下恰好是恒等变换 —— 旧实现与正确实现结果相同。
也就是说单写一条普通用例,在 CI/容器里会**假绿**,只在开发机现形。
所以用例显式把进程时区切到 UTC+8(`_force_non_utc_timezone`)。已验证:
**在 TZ=UTC 下临时还原旧实现,该用例照样红**。另补一条 aware 路径的用例,
防止「为了修 naive 把换算弄坏」。
顺带补了收集器的端到端用例:**事件 occurred_at 必须等于源任务 finished_at**。
旧用例只断言 written/watermark 数量、从不看时间,这正是 bug 一路绿灯的原因。
## 3. emit() 支持 S1 建的台账列(此前是「空壳」)
迁移 0042 给 `account_events` 加了 `category` / `subject_type` / `subject_key` /
`actor_type` / `actor_id` 与复合索引 `ix_account_events_ledger`,但 `emit()` 一直没
暴露它们 —— 列建好了没有写入方。现补齐参数与 INSERT,并加用例锁住「能写」。
S3 的 `status_changed`(`category="lifecycle"`)与 S5 的台账查询都要靠它。
## 4. 清 CK 的审计补 account_id
`DELETE /cookies/{id}` 与 `/cookies/batch` 的审计只写了 `target`(自由文本,
账号改名/重名就关联不上)。S1 已给 `audit_logs` 加了 `account_id`,这里补上写入
(批量时仅在只涉及一个账号时填),并加用例断言审计能反查到账号。
这是 S6「审计进账号时间线」的前置。
## 验证
- 新增/修改 5 条用例,先红后绿;**已验证在 TZ=UTC 下旧实现照样被这些用例抓到**
- `TZ=Asia/Shanghai`:**373 passed / 6 skipped**(原 368 + 5 条新用例)
- `TZ=UTC`:369 passed / 4 failed —— 那 4 条是 `test_huya_web_cookie_fields.py`
的 qimei 用例,**在 main 上同样失败**(qimei 按 SDK 语义编码本地时间,
与本次改动无关,也未修)
- ruff check / format 全绿;前端 `vite build` 通过(本次未动前端)
|
||
|
|
834bfdd1d0 |
feat(account-events): 账号详情页与账号事件历史全链路支持
1. 新增账号事件数据表 AccountEvent 与迁移版本 20261002_0040_account_events,支持审计 Cookie 变更、自动重登、健康检查及任务执行事件; 2. 后端新增 account_events_service 并扩展 /api/accounts 路由,支持事件查询、过滤与上报; 3. 前端新增 AccountDetailPage 账号详情页组件,支持账号元信息展示、事件历史瀑布流与快捷重登操作; 4. 修复 CK 重登与自动化定时任务中事件关联逻辑,补充 test_account_events 单元测试; 5. 新增生产服务器高负载排查分析报告及相关设计文档。 |