Skip to content

Commit b36f6e7

Browse files
committed
refactor(time): 重构应用程序中的日期时间处理
- 新增工具模块 `datetime_utils.py`,用于统一处理UTC和上海时区 - 在 `manager.py`、`models.py` 等后端文件中,将直接操作日期时间的代码替换为工具函数 - 更新前端组件,使用 `dayjs` 进行时区感知的日期格式化和解析 - 移除冗余的日期格式化逻辑,将其集中到工具函数中 - 通过确保所有时间戳均以亚洲/上海时区显示,提升用户体验
1 parent a97d72c commit b36f6e7

35 files changed

+495
-304
lines changed

.env.template

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ NEO4J_PASSWORD=
4141
# Servies
4242
YUXI_SUPER_ADMIN_NAME=
4343
YUXI_SUPER_ADMIN_PASSWORD=
44+
45+
# MinerU
46+
MINERU_API_KEY=

docs/changelog/roadmap.md

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,31 @@
22

33
路线图可能会经常变更,如果有强烈的建议,可以在 [issue](https://github.com/xerrors/Yuxi-Know/issues) 中提。
44

5-
## Version 0.3.x
5+
- [2025/10/13] 0.3.0 进入 beta 测试环节,不会再封装新的特性,仅作 bug 层面的修复
66

77

8-
🐛**BUGs**
8+
**Bugs**
99
- [ ] 调用统计的统计结果疑似有问题(Token 计算方法可能也不对)
1010

1111

12-
---
13-
14-
💭 **Features Todo**
12+
**Next**
1513

1614
- [ ] 添加对于上传文件的支持
17-
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示
15+
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示 <Badge type="info" text="0.4" />
1816
- [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示
19-
- [ ] 开发与生产环境隔离,构建生产镜像(0.4)
20-
- [x] 添加统计信息
21-
- [ ] 支持 MinerU 2.5 的解析方法(含API方法)
22-
- [x] 移除自定义模型支持
17+
- [ ] 开发与生产环境隔离,构建生产镜像 <Badge type="info" text="0.4" />
18+
- [ ] 支持 MinerU 2.5 的解析方法(含API方法)<Badge type="info" text="0.3.5" />
19+
- [ ] 优化全局配置的管理模型,优化配置管理
2320

24-
📝 **Base**
21+
**Later**
2522

26-
- [ ] 优化全局配置的管理模型,以及配置文件的管理,子配置的管理
27-
- [x] 新建 tasker 模块,用来管理所有的后台任务,UI 上使用侧边栏管理。
28-
- [ ] 新增 files 模块,用来管理文件上传,下载等
23+
下面的功能**可能**会放在后续版本实现,暂时未定
2924

30-
## 未来可能会支持
25+
- [ ] 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
26+
27+
**Done**
3128

32-
下面的功能**可能**会放在后续版本实现,暂时未定
3329

3430
- [x] 添加测试脚本,覆盖最常见的功能(已覆盖API)
35-
- [x] 优化对文档信息的检索展示(检索结果页、详情页)
36-
- [ ] 集成 LangFuse (观望)添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
31+
- [x] 新建 tasker 模块,用来管理所有的后台任务,UI 上使用侧边栏管理。
32+
- [x] 优化对文档信息的检索展示(检索结果页、详情页)

docs/intro/quick-start.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
```bash
1616
# 克隆稳定版本
17-
git clone --branch 0.2.2 --depth 1 https://github.com/xerrors/Yuxi-Know.git
17+
git clone --branch v0.2.2 --depth 1 https://github.com/xerrors/Yuxi-Know.git
1818
cd Yuxi-Know
1919
```
2020

2121
::: warning 版本说明
22-
- `0.2.2`: 当前稳定版本(推荐)
22+
- `v0.2.2`: 当前稳定版本(推荐)
23+
- `v0.3.0-beta`:最新的 Beta 测试版
2324
- `main`: 最新开发版本(不稳定,新特性可能会导致新 bug)
2425
:::
2526

server/routers/auth_router.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from datetime import datetime
21
import re
32

43
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
@@ -13,6 +12,7 @@
1312
from server.utils.user_utils import generate_unique_user_id, validate_username, is_valid_phone_number
1413
from server.utils.common_utils import log_operation
1514
from src.storage.minio import upload_image_to_minio
15+
from src.utils.datetime_utils import utc_now
1616

1717
# 创建路由器
1818
auth = APIRouter(prefix="/auth", tags=["authentication"])
@@ -151,7 +151,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
151151

152152
# 登录成功,重置失败计数器
153153
user.reset_failed_login()
154-
user.last_login = datetime.now()
154+
user.last_login = utc_now()
155155
db.commit()
156156

157157
# 生成访问令牌
@@ -220,7 +220,7 @@ async def initialize_admin(admin_data: InitializeAdmin, db: Session = Depends(ge
220220
avatar=None, # 初始化时头像为空
221221
password_hash=hashed_password,
222222
role="superadmin",
223-
last_login=datetime.now(),
223+
last_login=utc_now(),
224224
)
225225

226226
db.add(new_admin)
@@ -527,7 +527,7 @@ async def delete_user(
527527
hash_suffix = hashlib.md5(user.user_id.encode()).hexdigest()[:4]
528528

529529
user.is_deleted = 1
530-
user.deleted_at = datetime.now()
530+
user.deleted_at = utc_now()
531531
user.username = f"已注销用户-{hash_suffix}"
532532
user.phone_number = None # 清空手机号,释放该手机号供其他用户使用
533533
user.password_hash = "DELETED" # 禁止登录

server/routers/chat_router.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,11 @@ async def stream_messages():
306306
else:
307307
yield make_chunk(msg=msg.model_dump(), metadata=metadata, status="loading")
308308

309-
if conf.enable_content_guard and hasattr(full_msg, "content") and await content_guard.check(full_msg.content):
309+
if (
310+
conf.enable_content_guard
311+
and hasattr(full_msg, "content")
312+
and await content_guard.check(full_msg.content)
313+
):
310314
logger.warning("Sensitive content detected in final message")
311315
yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
312316
return

server/routers/dashboard_router.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from server.utils.auth_middleware import get_db
1717
from src.storage.conversation import ConversationManager
1818
from src.storage.db.models import User
19+
from src.utils.datetime_utils import UTC, ensure_shanghai, shanghai_now, utc_now
1920
from src.utils.logging_config import logger
2021

2122

@@ -231,7 +232,7 @@ async def get_user_activity_stats(
231232
try:
232233
from src.storage.db.models import User, Conversation
233234

234-
now = datetime.utcnow()
235+
now = utc_now()
235236

236237
# Conversations may store either the numeric user primary key or the login user_id string.
237238
# Join condition accounts for both representations.
@@ -305,7 +306,7 @@ async def get_tool_call_stats(
305306
try:
306307
from src.storage.db.models import ToolCall
307308

308-
now = datetime.utcnow()
309+
now = utc_now()
309310

310311
# 基础工具调用统计
311312
total_calls = db.query(func.count(ToolCall.id)).scalar() or 0
@@ -746,30 +747,30 @@ async def get_call_timeseries_stats(
746747
from src.storage.db.models import Conversation, Message, ToolCall
747748

748749
# 计算时间范围(使用北京时间 UTC+8)
749-
now = datetime.utcnow()
750-
local_now = now + timedelta(hours=8)
750+
now = utc_now()
751+
local_now = shanghai_now()
751752

752753
if time_range == "7hours":
753754
intervals = 7
754755
# 包含当前小时:从6小时前开始
755756
start_time = now - timedelta(hours=intervals - 1)
756757
group_format = func.strftime("%Y-%m-%d %H:00", func.datetime(Message.created_at, "+8 hours"))
757-
base_local_time = start_time + timedelta(hours=8)
758+
base_local_time = ensure_shanghai(start_time)
758759
elif time_range == "7weeks":
759760
intervals = 7
760761
# 包含当前周:从6周前开始,并对齐到当周周一 00:00
761762
local_start = local_now - timedelta(weeks=intervals - 1)
762763
local_start = local_start - timedelta(days=local_start.weekday())
763764
local_start = local_start.replace(hour=0, minute=0, second=0, microsecond=0)
764-
start_time = local_start - timedelta(hours=8)
765+
start_time = local_start.astimezone(UTC)
765766
group_format = func.strftime("%Y-%W", func.datetime(Message.created_at, "+8 hours"))
766767
base_local_time = local_start
767768
else: # 7days (default)
768769
intervals = 7
769770
# 包含当前天:从6天前开始
770771
start_time = now - timedelta(days=intervals - 1)
771772
group_format = func.strftime("%Y-%m-%d", func.datetime(Message.created_at, "+8 hours"))
772-
base_local_time = start_time + timedelta(hours=8)
773+
base_local_time = ensure_shanghai(start_time)
773774

774775
# 根据类型查询数据
775776
if type == "models":

server/services/tasker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@
33
import os
44
import uuid
55
from dataclasses import asdict, dataclass, field
6-
from datetime import datetime
76
from pathlib import Path
87
from typing import Any
98
from collections.abc import Awaitable, Callable
109

1110
from src.config import config
1211
from src.utils.logging_config import logger
12+
from src.utils.datetime_utils import utc_isoformat
1313

1414
TaskCoroutine = Callable[["TaskContext"], Awaitable[Any]]
1515
TERMINAL_STATUSES = {"success", "failed", "cancelled"}
1616

1717

1818
def _utc_timestamp() -> str:
19-
return datetime.utcnow().isoformat() + "Z"
19+
return utc_isoformat()
2020

2121

2222
@dataclass
@@ -142,7 +142,7 @@ async def list_tasks(self, status: str | None = None) -> list[dict[str, Any]]:
142142
tasks = list(self._tasks.values())
143143
if status:
144144
tasks = [task for task in tasks if task.status == status]
145-
tasks.sort(key=lambda item: item.created_at, reverse=True)
145+
tasks.sort(key=lambda item: item.created_at or utc_isoformat(), reverse=True)
146146
return [task.to_dict() for task in tasks]
147147

148148
async def get_task(self, task_id: str) -> dict[str, Any] | None:

server/utils/auth_utils.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import hashlib
22
import os
3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44
from typing import Any
55

66
import jwt
77

8+
from src.utils.datetime_utils import utc_now
9+
810
# JWT配置
911
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "yuxi_know_secure_key")
1012
JWT_ALGORITHM = "HS256"
@@ -46,9 +48,9 @@ def create_access_token(data: dict[str, Any], expires_delta: timedelta | None =
4648

4749
# 设置过期时间
4850
if expires_delta:
49-
expire = datetime.utcnow() + expires_delta
51+
expire = utc_now() + expires_delta
5052
else:
51-
expire = datetime.utcnow() + timedelta(seconds=JWT_EXPIRATION)
53+
expire = utc_now() + timedelta(seconds=JWT_EXPIRATION)
5254

5355
to_encode.update({"exp": expire})
5456

server/utils/migrate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
import os
66
import shutil
77
import sqlite3
8-
from datetime import datetime
98
from pathlib import Path
109

1110
from src.utils import logger
11+
from src.utils.datetime_utils import shanghai_now
1212

1313

1414
class DatabaseMigrator:
@@ -30,7 +30,7 @@ def backup_database(self) -> str:
3030
return ""
3131

3232
self.ensure_backup_dir()
33-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
33+
timestamp = shanghai_now().strftime("%Y%m%d_%H%M%S")
3434
backup_filename = f"server_backup_{timestamp}.db"
3535
backup_path = os.path.join(self.backup_dir, backup_filename)
3636

src/knowledge/base.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import json
22
import os
3-
import time
43
from abc import ABC, abstractmethod
5-
from datetime import datetime
64
from typing import Any
75

86
from src.utils import logger
7+
from src.utils.datetime_utils import coerce_any_to_utc_datetime, utc_isoformat
98

109

1110
class KnowledgeBaseException(Exception):
@@ -54,6 +53,34 @@ def __init__(self, work_dir: str):
5453

5554
# 自动加载元数据
5655
self._load_metadata()
56+
self._normalize_metadata_state()
57+
58+
@staticmethod
59+
def _normalize_timestamp(value: Any) -> str | None:
60+
"""Convert persisted timestamps to a normalized UTC ISO string."""
61+
try:
62+
dt_value = coerce_any_to_utc_datetime(value)
63+
except (TypeError, ValueError) as exc: # noqa: BLE001
64+
logger.warning(f"Invalid timestamp encountered: {value!r} ({exc})")
65+
return None
66+
67+
if not dt_value:
68+
return None
69+
return utc_isoformat(dt_value)
70+
71+
def _normalize_metadata_state(self) -> None:
72+
"""Ensure in-memory metadata uses normalized timestamp formats."""
73+
for meta in self.databases_meta.values():
74+
if "created_at" in meta:
75+
normalized = self._normalize_timestamp(meta.get("created_at"))
76+
if normalized:
77+
meta["created_at"] = normalized
78+
79+
for file_info in self.files_meta.values():
80+
if "created_at" in file_info:
81+
normalized = self._normalize_timestamp(file_info.get("created_at"))
82+
if normalized:
83+
file_info["created_at"] = normalized
5784

5885
@property
5986
@abstractmethod
@@ -117,7 +144,7 @@ def create_database(
117144
"embed_info": embed_info,
118145
"llm_info": llm_info,
119146
"metadata": kwargs,
120-
"created_at": datetime.now().isoformat(),
147+
"created_at": utc_isoformat(),
121148
}
122149
self._save_metadata()
123150

@@ -237,17 +264,24 @@ def get_database_info(self, db_id: str) -> dict | None:
237264
db_files = {}
238265
for file_id, file_info in self.files_meta.items():
239266
if file_info.get("database_id") == db_id:
267+
created_at = self._normalize_timestamp(file_info.get("created_at"))
240268
db_files[file_id] = {
241269
"file_id": file_id,
242270
"filename": file_info.get("filename", ""),
243271
"path": file_info.get("path", ""),
244272
"type": file_info.get("file_type", ""),
245273
"status": file_info.get("status", "done"),
246-
"created_at": file_info.get("created_at", time.time()),
274+
"created_at": created_at,
247275
}
248276

249277
# 按创建时间倒序排序文件列表
250-
sorted_files = dict(sorted(db_files.items(), key=lambda x: x[1].get("created_at", 0), reverse=True))
278+
sorted_files = dict(
279+
sorted(
280+
db_files.items(),
281+
key=lambda item: item[1].get("created_at") or "",
282+
reverse=True,
283+
)
284+
)
251285

252286
meta["files"] = sorted_files
253287
meta["row_count"] = len(sorted_files)
@@ -273,17 +307,24 @@ def get_databases(self) -> dict:
273307
db_files = {}
274308
for file_id, file_info in self.files_meta.items():
275309
if file_info.get("database_id") == db_id:
310+
created_at = self._normalize_timestamp(file_info.get("created_at"))
276311
db_files[file_id] = {
277312
"file_id": file_id,
278313
"filename": file_info.get("filename", ""),
279314
"path": file_info.get("path", ""),
280315
"type": file_info.get("file_type", ""),
281316
"status": file_info.get("status", "done"),
282-
"created_at": file_info.get("created_at", time.time()),
317+
"created_at": created_at,
283318
}
284319

285320
# 按创建时间倒序排序文件列表
286-
sorted_files = dict(sorted(db_files.items(), key=lambda x: x[1].get("created_at", 0), reverse=True))
321+
sorted_files = dict(
322+
sorted(
323+
db_files.items(),
324+
key=lambda item: item[1].get("created_at") or "",
325+
reverse=True,
326+
)
327+
)
287328

288329
db_dict["files"] = sorted_files
289330
db_dict["row_count"] = len(sorted_files)
@@ -495,13 +536,14 @@ def _load_metadata(self):
495536

496537
def _save_metadata(self):
497538
"""保存元数据"""
539+
self._normalize_metadata_state()
498540
meta_file = os.path.join(self.work_dir, f"metadata_{self.kb_type}.json")
499541
try:
500542
data = {
501543
"databases": self.databases_meta,
502544
"files": self.files_meta,
503545
"kb_type": self.kb_type,
504-
"updated_at": datetime.now().isoformat(),
546+
"updated_at": utc_isoformat(),
505547
}
506548
with open(meta_file, "w", encoding="utf-8") as f:
507549
json.dump(data, f, ensure_ascii=False, indent=2)

0 commit comments

Comments
 (0)