# 审计页面时间显示问题
> **版本**: 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 数据库表结构
```sql
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 行
```html
{{ event.created_at or "-" }} |
```
**查询函数**:`shared/audit_events.py` 第 146 行
```python
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 转换为北京时间。
**实现**:
```python
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 过滤器:
```python
# 在路由中注册过滤器
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
```
模板中使用:
```html
{{ event.created_at | format_datetime }} |
```
---
## 四、实现细节
### 4.1 修改文件
- `shared/audit_events.py`:添加时区转换函数,修改 `query_audit_events()`
### 4.2 注意事项
1. **时间筛选**:筛选条件(`start_time`、`end_time`)也需要转换为 UTC 再查询
2. **兼容性**:确保旧数据(可能已经是北京时间)不会被重复转换
3. **性能**:时区转换是轻量操作,不会影响查询性能
### 4.3 筛选条件处理
```python
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. 参考资料
- [SQLite Date And Time Functions](https://www.sqlite.org/lang_datefunc.html)
- [Python datetime timezone](https://docs.python.org/3/library/datetime.html#timezone-objects)