Files
yml2213 65a4590291 refactor(time): 时间列统一走 UTCDateTime,并把 MySQL 会话时区对齐成 UTC
问题不是「UTC 还是本地时间」,而是这套口径有两个半吊子的地方:

1. 库里存 naive UTC、读出来也是裸时间,于是每一处比较/输出都得自己补时区。
   项目里因此散了三份 _as_utc 和一堆 replace(tzinfo=UTC),P1a 排下一轮时间
   时还炸过一次 TypeError: can't compare offset-naive and offset-aware。
2. MySQL 会话时区跟随系统 +08:00,而库里存的是 UTC —— SQL 里的 NOW()/
   CURDATE() 比库里的时间快 8 小时,查询会静默返回空(P1a 排查时就遇到过,
   看着像「没有数据」)。

改动:

- 新增 database.UTCDateTime:写时 aware→转 UTC 去 tzinfo、naive 视为 UTC;
  读时一律补 UTC 时区。底层 DDL 仍是 DATETIME,**不需要迁移**。models.py 的
  28 个时间列全部改用它,模型上的时间字段从此不再是裸值;
- docker-compose 给 mysql 加 --default-time-zone=+00:00,让 SQL 时间函数与存储
  口径一致。安全性已核实:全库 0 个 TIMESTAMP 列、0 个 CURRENT_TIMESTAMP
  默认值,改这里不会改变任何已有数据的语义;
- 新增 tests/test_utc_datetime.py:读出必须 aware、能直接和 datetime.now(UTC)
  比较、带 +08:00 的写入要落成 UTC、naive 写入视为 UTC,并有一条元测试扫描
  Base.metadata,防止以后新增时间列忘了用 UTCDateTime。

保留既有的 _as_utc 补丁(对 aware 值是幂等的,属防御性代码);新代码不再需要。
「存 UTC、算 UTC、显示本地」三条线至此各自明确:存储与比较统一 UTC,日志按
容器 TZ 显示本地时间,前端 formatTime 负责转本地。
2026-10-02 20:27:07 +08:00

193 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据库引擎与会话管理"""
import os
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import quote_plus
from sqlalchemy import DateTime, create_engine, event
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.types import TypeDecorator
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _env_int(name: str, default: int) -> int:
"""读取正整数环境变量,非法值回退到默认值。"""
try:
value = int(os.getenv(name, str(default)))
except ValueError:
return default
return value if value > 0 else default
def _get_database_url() -> str:
"""优先使用完整连接串,否则按 DB_* 环境变量构造 MySQL 连接。"""
database_url = os.getenv("DATABASE_URL", "").strip()
if database_url:
return database_url
db_host = os.getenv("DB_HOST", "").strip()
if db_host:
db_port = _env_int("DB_PORT", 3306)
db_name = os.getenv("DB_NAME", "douyu_login").strip()
db_user = os.getenv("DB_USER", "douyu_login").strip()
db_password = os.getenv("DB_PASSWORD", "")
if not db_name or not db_user or not db_password:
raise RuntimeError(
"使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD"
)
return (
f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}"
f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
)
raise RuntimeError(
"未配置数据库连接。请设置 DATABASE_URL=mysql+pymysql://...,"
"或设置 DB_HOST、DB_NAME、DB_USER、DB_PASSWORD"
)
DATABASE_URL = _get_database_url()
connect_args = (
{"check_same_thread": False, "timeout": 30}
if DATABASE_URL.startswith("sqlite")
else {}
)
engine_options = {
"connect_args": connect_args,
"echo": False,
}
if not DATABASE_URL.startswith("sqlite"):
engine_options.update(
pool_pre_ping=True,
pool_size=_env_int("DB_POOL_SIZE", 20),
max_overflow=_env_int("DB_MAX_OVERFLOW", 20),
pool_timeout=_env_int("DB_POOL_TIMEOUT", 30),
pool_recycle=_env_int("DB_POOL_RECYCLE", 1800),
)
engine = create_engine(DATABASE_URL, **engine_options)
if DATABASE_URL.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record):
"""提升 SQLite 并发写入稳定性。"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base()
class UTCDateTime(TypeDecorator):
"""时间列统一走这里:**库里存 naive UTC,Python 侧永远是 tz-aware UTC**。
之前是全靠约定:写入用 ``datetime.now(UTC)``(MySQL 绑定参数会丢掉 tzinfo,
落库成 UTC 裸值),读出是裸值,谁要比较或输出就得自己记得补时区 —— 项目里
因此散了三份 ``_as_utc`` 和一堆 ``replace(tzinfo=UTC)``,还踩过一次
``TypeError: can't compare offset-naive and offset-aware datetimes``。
这个装饰器把边界收在一处:
- 写:aware → 转 UTC 后去掉 tzinfo;naive → 视为已是 UTC,原样存;
- 读:一律补上 UTC 时区。
于是模型上的时间字段永远不会是裸时间,比较、相减、序列化都不必再各自处理。
底层 DDL 仍是 DATETIME(``impl`` 不变),**不需要迁移**。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def get_db():
"""FastAPI 依赖:提供数据库会话,请求结束自动关闭。"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""执行数据库迁移 + 写入初始数据。"""
run_migrations()
_seed()
_encrypt_existing_sensitive_data()
def run_migrations():
"""运行 Alembic 迁移到最新版本。"""
from alembic import command
from alembic.config import Config
config = Config(str(PROJECT_ROOT / "alembic.ini"))
config.set_main_option(
"script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")
)
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。
os.environ["ALEMBIC_EMBEDDED"] = "1"
try:
command.upgrade(config, "head")
finally:
os.environ.pop("ALEMBIC_EMBEDDED", None)
def _seed():
"""写入默认超管账号和角色(账号密码通过环境变量配置)。"""
from .models import User
from .security import hash_password
admin_username = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
db = SessionLocal()
try:
if not db.query(User).first():
admin = User(
username=admin_username,
password_hash=hash_password(admin_password),
role="super_admin",
is_active=True,
remark="默认超级管理员",
)
db.add(admin)
db.commit()
finally:
db.close()
def _encrypt_existing_sensitive_data():
"""启动时把历史明文敏感数据迁移为密文。"""
from loguru import logger
from .crypto_storage import encrypt_existing_sensitive_data
changed = encrypt_existing_sensitive_data(engine)
if changed:
logger.info(f"已加密历史敏感字段: {changed} 个值")