【问题】 上线/下线时间此前取自审计记录(写操作)时间,当天只翻看、没做写操作的人 会被整条漏掉;而取登录时间更错(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 与移动端同一套口径、同一张表。
48 lines
2.1 KiB
Python
48 lines
2.1 KiB
Python
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
|
||
|
||
为什么需要它(而不是复用 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="当天末次活动时刻",
|
||
)
|