Files
track/backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
duxingchen 39697ca3ad feat(audit): 每日活动打点表 —— 修正日活「上线/下线时间」口径
【问题】
上线/下线时间此前取自审计记录(写操作)时间,当天只翻看、没做写操作的人
会被整条漏掉;而取登录时间更错(Refresh Token 有效期 7 天,用户不必每天登录,
会出现「登录次数 0 却操作 35 次」的自相矛盾报表)。

【方案 C+:一天一人一行的小状态表】
- 新增 user_daily_seen(user_id, day, first_seen_at, last_seen_at),
  迁移 k1l2m3n4o5p6(紧接 j1k2l3m4n5o6)
- 打点挂在 RequestContextMiddleware —— 它是最外层,能覆盖【所有】请求,
  含不被审计的普通 GET。挂审计中间件没用:那里只记写操作,正是漏人的原因
- 节流:进程内缓存,同一用户 2 分钟内只落盘一次,把"每请求一次写库"
  压到"每人每 2 分钟一次";代价是末次活动时间最多落后 2 分钟
- UPSERT on_conflict_do_update 只刷 last_seen_at,first_seen_at 保持当天首次值
- 打点失败全部吞掉并记日志,绝不影响业务请求

【为什么不复用 audit_logs】
「末次活动」是需要不断 UPDATE 的状态,而审计流水必须只增不改 ——
能改的审计记录等于没有审计价值。写进审计表还会让表随访问量线性膨胀。

【查询合并】
get_daily_usage 改为「活动表 ∪ 审计表」并集:只有活动记录的(只看不操作)
和只有审计记录的(本表上线前的历史数据)都会出现。
上线/下线时间取两者的【最早 / 最晚】而非"活动表优先"——打点有 2 分钟节流、
跨零点或写库失败时可能晚于当天第一次写操作,取 min/max 后结果永不劣于任一来源。

零前端改动、零移动端发版:打点在服务端,PC 与移动端同一套口径、同一张表。
2026-09-21 13:05:35 +08:00

54 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.

"""add_user_daily_seen
Revision ID: k1l2m3n4o5p6
Revises: j1k2l3m4n5o6
Create Date: 2026-09-21
每日用户活动表user_daily_seen
--------------------------------
一天一人一行,记录当天首次 / 末次活动时刻,供日活报表计算
「上线时间 / 下线时间」。
为什么不复用 audit_logs
· 上线/下线时间不能取登录时间 —— Refresh Token 有效期 7 天,用户不必每天
重新登录,「登录次数 0 却操作 35 次」的报表没有意义。
· 也不能只取写操作时间 —— 审计中间件只记写操作,普通 GET 不入账,
当天只翻看的人会被漏掉。
· 更不能把活动写进审计表 —— 「末次活动」是需要不断 UPDATE 的状态,
而审计流水必须只增不改;能改的审计记录等于没有审计价值。
存量数据无需回填:本表从上线时刻开始记录;日活接口对更早的日期会自动
回退到审计表的写操作时间去推算。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "k1l2m3n4o5p6"
down_revision: Union[str, None] = "j1k2l3m4n5o6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_daily_seen",
sa.Column("user_id", sa.String(64), primary_key=True,
comment="操作人账号(逻辑外键→MOM)"),
sa.Column("day", sa.Date(), primary_key=True,
comment="北京时间自然日"),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False,
comment="当天首次活动时刻"),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False,
comment="当天末次活动时刻"),
)
# 日活查询按日期区间扫,给 day 单独建索引。
# (主键是 (user_id, day),前缀是 user_id按 day 过滤用不上,故需补一条)
op.create_index("ix_user_daily_seen_day", "user_daily_seen", ["day"])
def downgrade() -> None:
op.drop_index("ix_user_daily_seen_day", table_name="user_daily_seen")
op.drop_table("user_daily_seen")