Files
ephron-ren-prd/prd-audit-timezone-fix.md

5.6 KiB
Raw Permalink Blame History

审计页面时间显示问题

版本: v1.0
日期: 2026-05-06
状态: 📝 待评审


一、问题描述

1.1 现象

审计页面 (/admin/audit) 显示的时间比实际时间少 8 小时。

1.2 复现步骤

  1. 登录 auth.ephron.ren 管理后台
  2. 访问 /admin/audit
  3. 观察「时间」列

预期行为显示北京时间UTC+8
实际行为:显示 UTC 时间(比北京时间少 8 小时)

1.3 影响范围

  • 影响所有审计日志的时间显示
  • 影响时间筛选功能(筛选条件也是 UTC 时间)

二、根因分析

2.1 数据库表结构

CREATE TABLE IF NOT EXISTS audit_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ...
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)

2.2 问题根因

  1. SQLite 的 CURRENT_TIMESTAMP 返回 UTC 时间,格式为 YYYY-MM-DD HH:MM:SS
  2. 模板直接显示 {{ event.created_at }},没有转换
  3. 服务器时区可能是 UTC或者 Python 没有正确处理时区转换

2.3 代码位置

模板auth/templates/admin/audit.html 第 272 行

<td>{{ event.created_at or "-" }}</td>

查询函数shared/audit_events.py 第 146 行

def query_audit_events(...) -> list[dict[str, Any]]:
    # 直接返回数据库原始值,没有时区转换

三、解决方案

3.1 方案对比

方案 实现位置 优点 缺点
A. 后端转换 Python 查询函数 统一处理,前端无需改动 需要修改查询函数
B. 前端转换 JavaScript 灵活,可适配用户时区 需要 JS 代码
C. 模板过滤器 Jinja2 过滤器 简单 需要自定义过滤器

3.2 推荐方案:后端转换

query_audit_events() 函数中,将 created_at 从 UTC 转换为北京时间。

实现

from datetime import datetime, timezone, timedelta

def _utc_to_beijing(utc_str: str | None) -> str | None:
    """将 UTC 时间字符串转换为北京时间字符串"""
    if not utc_str:
        return None
    try:
        # 解析 UTC 时间
        utc_dt = datetime.strptime(utc_str, "%Y-%m-%d %H:%M:%S")
        utc_dt = utc_dt.replace(tzinfo=timezone.utc)
        # 转换为北京时间
        beijing_dt = utc_dt.astimezone(timezone(timedelta(hours=8)))
        return beijing_dt.strftime("%Y-%m-%d %H:%M:%S")
    except (ValueError, TypeError):
        return utc_str

def query_audit_events(...) -> list[dict[str, Any]]:
    # ... 查询逻辑 ...
    
    events: list[dict[str, Any]] = []
    for row in rows:
        event = dict(row)
        # 转换时间
        event["created_at"] = _utc_to_beijing(event["created_at"])
        # ... 其他处理 ...
        events.append(event)
    return events

3.3 备选方案:模板过滤器

在模板中使用 Jinja2 过滤器:

# 在路由中注册过滤器
def format_datetime(utc_str):
    if not utc_str:
        return "-"
    try:
        utc_dt = datetime.strptime(utc_str, "%Y-%m-%d %H:%M:%S")
        utc_dt = utc_dt.replace(tzinfo=timezone.utc)
        beijing_dt = utc_dt.astimezone(timezone(timedelta(hours=8)))
        return beijing_dt.strftime("%Y-%m-%d %H:%M:%S")
    except:
        return utc_str

templates.env.filters["format_datetime"] = format_datetime

模板中使用:

<td>{{ event.created_at | format_datetime }}</td>

四、实现细节

4.1 修改文件

  • shared/audit_events.py:添加时区转换函数,修改 query_audit_events()

4.2 注意事项

  1. 时间筛选:筛选条件(start_timeend_time)也需要转换为 UTC 再查询
  2. 兼容性:确保旧数据(可能已经是北京时间)不会被重复转换
  3. 性能:时区转换是轻量操作,不会影响查询性能

4.3 筛选条件处理

def _beijing_to_utc(beijing_str: str | None) -> str | None:
    """将北京时间字符串转换为 UTC 时间字符串"""
    if not beijing_str:
        return None
    try:
        beijing_dt = datetime.strptime(beijing_str, "%Y-%m-%dT%H:%M")
        beijing_dt = beijing_dt.replace(tzinfo=timezone(timedelta(hours=8)))
        utc_dt = beijing_dt.astimezone(timezone.utc)
        return utc_dt.strftime("%Y-%m-%d %H:%M:%S")
    except (ValueError, TypeError):
        return beijing_str

# 在 query_audit_events 中
if start_time:
    conditions.append("created_at >= ?")
    params.append(_beijing_to_utc(start_time))
if end_time:
    conditions.append("created_at <= ?")
    params.append(_beijing_to_utc(end_time))

五、测试验证

5.1 测试用例

编号 测试步骤 预期结果
T-001 查看审计页面时间 显示北京时间(比 UTC 多 8 小时)
T-002 使用时间筛选 筛选结果正确
T-003 查看新产生的审计日志 时间正确

5.2 验证方法

  1. 部署后访问 /admin/audit
  2. 对比显示时间与实际时间
  3. 测试时间筛选功能

六、优先级与排期

优先级 任务 预估时间
P0 添加时区转换函数 10 分钟
P0 修改查询函数 5 分钟
P1 测试验证 5 分钟

总计20 分钟


附录

A. 相关文件

  • shared/audit_events.py:审计事件查询函数
  • auth/templates/admin/audit.html:审计页面模板
  • auth/src/routes/admin.py:审计页面路由

B. 参考资料