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` 通过(本次未动前端)
This commit is contained in:
+8
-10
@@ -624,17 +624,15 @@ C1(加列+回填,无行为变化)
|
||||
|
||||
解耦(C1~C5)是**前置**:凭证状态不回到账号上,后面的「状态机」「账号页」都是空中楼阁。
|
||||
|
||||
| 阶段 | 内容 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| **S1 = C1~C3** | CK / 健康 / 重登 状态搬到 `accounts`(含作业改按账号调度) | —— |
|
||||
| **S2 = C4~C5** | Cookie 页与删除护栏改为账号级 | S1 |
|
||||
| **S3** | `AccountOut` 补全运营状态字段;账号列表页加「CK / 健康 / 积分 / 开通」列 | S1、S2 |
|
||||
| **S4** | 账号详情页补全生命周期(状态机、画像、开通时间、累计统计)—— 已有 `AccountDetailPage` 底座 | S3 |
|
||||
| **S5** | 补 ③ 生命周期 `status` + 停用/归档 + 导入批次 | S1 |
|
||||
| **S6** | 打通 `audit_logs.account_id`,让「分配/改标签/删除」进时间线 | S1 |
|
||||
> ⚠️ **阶段编号以 [设计-账号全生命周期](设计-账号全生命周期.md) §6 为准(S1~S8)。**
|
||||
> 本文早期版本在这里另写过一套 S1~S6,两套编号含义不同,已废弃 —— 保留本节只为说明依赖关系。
|
||||
|
||||
> S3/S4 是用户感知最强的部分(「账号管理终于能看全」),但**必须等 S1**——
|
||||
> 否则账号页展示的 CK 状态仍然来自登录记录,删一条记录页面就变了。
|
||||
| 依赖关系 | 说明 |
|
||||
| --- | --- |
|
||||
| **解耦(本文 C1~C5 = 生命周期设计 S1~S2)** | 一切的前置。不做,账号页展示的 CK 状态仍然来自登录记录,删一条记录页面就变了 |
|
||||
| **状态收口(S3)** | 依赖解耦;产出「可信的 `status`」 |
|
||||
| **账号页重构(S6)** | 依赖 S3(要显示状态)+ S5(要显示台账) |
|
||||
| **清理旧列(S8)** | 最后一步,不可逆,前置每日备份 |
|
||||
|
||||
### 13.6 待确认
|
||||
|
||||
|
||||
+22
-1
@@ -332,7 +332,28 @@ class AccountEvent(Base):
|
||||
3. **`delete_account` 必须连带清事件**:`account_events` 有指向 `accounts` 的外键,不清就删不掉账号。
|
||||
这是本次改动**引入的**问题,已修并有用例;
|
||||
4. **测试数据的字符集**:用 `docker exec mysql < file` 灌中文时没带 `--default-character-set=utf8mb4`,
|
||||
数据被双重编码(`套装` → `套装`)。排查时先看 `HEX()` 就能立刻分清「存坏了」还是「读坏了」。
|
||||
数据被双重编码(`套装` → `套装`)。排查时先看 `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`)。
|
||||
|
||||
**若某套环境曾开启过收集器**,用这段按源任务时间对齐(幂等):
|
||||
|
||||
```sql
|
||||
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 本地验证结果
|
||||
|
||||
|
||||
Reference in New Issue
Block a user