216 lines
5.6 KiB
Markdown
216 lines
5.6 KiB
Markdown
# 审计页面时间显示问题
|
||
|
||
> **版本**: 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
|
||
<td>{{ event.created_at or "-" }}</td>
|
||
```
|
||
|
||
**查询函数**:`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
|
||
<td>{{ event.created_at | format_datetime }}</td>
|
||
```
|
||
|
||
---
|
||
|
||
## 四、实现细节
|
||
|
||
### 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)
|