Files
track/backend/app/models/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

48 lines
2.1 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.

"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
为什么需要它(而不是复用 audit_logs
--------------------------------------
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
1. **上线/下线时间不能取登录时间**Refresh Token 有效期 7 天,用户不必每天
重新登录。按登录算会出现「登录次数 0、上线时间空但操作次数 35」的
自相矛盾报表。
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
3. **更不能把活动记录写进 audit_logs**
· 「上线时间」是**事件**INSERT 一次即可),但「下线时间」是**状态**
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE等于承认审计记录
可以被改写 —— 那审计本身就失去可信度了。
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class UserDailySeen(Base):
"""用户在某个北京时间自然日的首末活动时刻"""
__tablename__ = "user_daily_seen"
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
)