init: consolidate all ephron.ren PRDs and docs
This commit is contained in:
215
prd-audit-timezone-fix.md
Normal file
215
prd-audit-timezone-fix.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 审计页面时间显示问题
|
||||
|
||||
> **版本**: 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)
|
||||
Reference in New Issue
Block a user