You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
16 KiB
16 KiB
时间与时区规范
版本: 1.0
最后更新: 2026-01-28
适用范围: PostgreSQL 17 + FastAPI + SQLModel
目录
核心原则
1. 始终使用 Aware Datetime
规则: 所有 datetime 对象必须包含时区信息(aware datetime)
# ✅ 正确:使用 aware datetime
from datetime import datetime, timezone
now = datetime.now(timezone.utc) # 带时区信息
# ❌ 错误:使用 naive datetime
now = datetime.now() # 无时区信息
now = datetime.utcnow() # 已废弃且无时区信息
2. 统一使用 UTC
规则: 数据库和后端统一使用 UTC 时区,前端展示时转换为本地时区
数据流向:
前端(本地时区) → API(转换为 UTC) → 数据库(存储 UTC)
数据库(UTC) → API(保持 UTC) → 前端(转换为本地时区)
3. 数据库使用 TIMESTAMPTZ 记录真实时间
规则: PostgreSQL 使用 TIMESTAMPTZ 记录所有真实世界时间点
-- ✅ 正确:记录真实世界时间点
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
deleted_at TIMESTAMPTZ
last_login_at TIMESTAMPTZ
-- ❌ 错误:不要用 TIMESTAMP WITHOUT TIME ZONE 记录事件时间
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
-- ✅ 允许:仅用于无绝对时间语义的字段
daily_report_time TIME WITHOUT TIME ZONE -- 每天的报告时间(如 09:00)
原因:
created_at、updated_at等字段表示真实世界中事件发生的时间点- TIMESTAMPTZ 自动处理时区转换,确保数据正确性
- 支持多时区部署和查询
- 符合 PostgreSQL 最佳实践和 SQL 标准
技术栈配置
Python 版本要求
# Python 3.12+
# datetime.utcnow() 已废弃,必须使用 datetime.now(timezone.utc)
依赖包版本
# requirements.txt
sqlmodel==0.0.14
asyncpg==0.29.0
pydantic==2.5.0
pydantic-settings==2.1.0
环境变量配置
# .env
TZ=UTC # 服务器时区设置为 UTC
Model 层规范
时间戳字段定义
标准模板:
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field
class BaseModel(SQLModel):
"""基础模型(所有模型继承)"""
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="创建时间(UTC)"
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="更新时间(UTC)"
)
deleted_at: Optional[datetime] = Field(
default=None,
description="软删除时间(UTC)"
)
关键点说明
1. 使用 lambda 包装
# ✅ 正确:每次创建实例时调用
default_factory=lambda: datetime.now(timezone.utc)
# ❌ 错误:类定义时调用一次,所有实例共享同一时间戳
default_factory=datetime.now(timezone.utc)
2. 导入时区模块
# ✅ 正确:导入 timezone
from datetime import datetime, timezone
# ❌ 错误:缺少 timezone 导入
from datetime import datetime
3. 可选时间戳字段
# 可选时间戳(如 deleted_at, completed_at)
deleted_at: Optional[datetime] = Field(default=None)
# 不要使用 default_factory
# ❌ 错误
deleted_at: Optional[datetime] = Field(
default_factory=lambda: None # 多余
)
完整示例
"""用户模型示例"""
from typing import Optional
from datetime import datetime, timezone
from uuid import UUID
from sqlmodel import SQLModel, Field
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from app.utils.id_generator import generate_uuid
class User(SQLModel, table=True):
"""用户表"""
__tablename__ = "users"
# 主键
user_id: UUID = Field(
sa_column=Column(PG_UUID(as_uuid=True), primary_key=True, default=generate_uuid)
)
# 基本信息
username: str = Field(max_length=255)
email: str = Field(max_length=255)
# 时间戳(标准格式)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="创建时间(UTC)"
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="更新时间(UTC)"
)
deleted_at: Optional[datetime] = Field(
default=None,
description="软删除时间(UTC)"
)
# 业务时间戳
last_login_at: Optional[datetime] = Field(
default=None,
description="最后登录时间(UTC)"
)
Service 层规范
时间戳操作
from datetime import datetime, timezone, timedelta
class UserService:
async def update_last_login(self, user_id: UUID) -> None:
"""更新最后登录时间"""
user = await self.repository.get_user(user_id)
# ✅ 正确:使用 aware datetime
user.last_login_at = datetime.now(timezone.utc)
user.updated_at = datetime.now(timezone.utc)
await self.session.flush()
async def get_expired_sessions(self, hours: int = 24) -> List[Session]:
"""获取过期会话"""
# ✅ 正确:时间计算使用 aware datetime
expiry_time = datetime.now(timezone.utc) - timedelta(hours=hours)
return await self.repository.get_sessions_before(expiry_time)
时间比较
# ✅ 正确:aware datetime 之间的比较
now = datetime.now(timezone.utc)
if user.created_at < now - timedelta(days=30):
# 用户创建超过 30 天
pass
# ❌ 错误:混合 aware 和 naive datetime
now = datetime.now() # naive
if user.created_at < now: # 会抛出 TypeError
pass
API 层规范
Schema 定义
from datetime import datetime
from pydantic import BaseModel, Field
class UserResponse(BaseModel):
"""用户响应 Schema"""
user_id: UUID
username: str
email: str
# 时间戳字段(Pydantic 自动处理 ISO 8601 格式)
created_at: datetime = Field(description="创建时间(UTC,ISO 8601 格式)")
updated_at: datetime = Field(description="更新时间(UTC,ISO 8601 格式)")
last_login_at: Optional[datetime] = Field(
default=None,
description="最后登录时间(UTC,ISO 8601 格式)"
)
class Config:
json_schema_extra = {
"example": {
"user_id": "019c0361-ea8e-7d43-bbcc-edd8eea06bb6",
"username": "john_doe",
"email": "john@example.com",
"created_at": "2026-01-28T07:30:00Z",
"updated_at": "2026-01-28T07:30:00Z",
"last_login_at": "2026-01-28T08:15:00Z"
}
}
API 响应格式
JSON 输出(自动转换为 ISO 8601):
{
"user_id": "019c0361-ea8e-7d43-bbcc-edd8eea06bb6",
"username": "john_doe",
"created_at": "2026-01-28T07:30:00Z",
"updated_at": "2026-01-28T07:30:00Z"
}
说明:
- Pydantic 自动将 Python datetime 转换为 ISO 8601 格式
Z后缀表示 UTC 时区- 前端可以使用
new Date()直接解析
接收时间戳
from datetime import datetime
from pydantic import BaseModel, field_validator
class EventCreate(BaseModel):
"""事件创建 Schema"""
title: str
scheduled_at: datetime # 前端传入 ISO 8601 格式
@field_validator('scheduled_at')
@classmethod
def validate_scheduled_at(cls, v: datetime) -> datetime:
"""验证并确保时间戳为 aware datetime"""
if v.tzinfo is None:
# 如果前端传入 naive datetime,假定为 UTC
v = v.replace(tzinfo=timezone.utc)
else:
# 转换为 UTC
v = v.astimezone(timezone.utc)
# 验证不能是过去时间
if v < datetime.now(timezone.utc):
raise ValueError("scheduled_at 不能是过去时间")
return v
数据库层规范
表结构定义
-- 标准时间戳字段(使用 TIMESTAMPTZ)
CREATE TABLE users (
user_id UUID PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
-- 时间戳字段(TIMESTAMPTZ - 记录真实世界时间点)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
-- 索引
INDEX idx_users_created_at (created_at),
INDEX idx_users_deleted_at (deleted_at)
);
-- 表和字段注释
COMMENT ON TABLE users IS '用户表';
COMMENT ON COLUMN users.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN users.updated_at IS '更新时间(自动记录时区)';
COMMENT ON COLUMN users.deleted_at IS '软删除时间(自动记录时区)';
Alembic 迁移脚本
"""添加时间戳字段
Revision ID: xxx
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime, timezone
def upgrade() -> None:
"""升级"""
# 添加时间戳字段
op.add_column(
'users',
sa.Column(
'created_at',
sa.TIMESTAMP(timezone=False), # WITHOUT TIME ZONE
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
comment='创建时间(UTC)'
)
)
# 为现有数据设置默认值(使用 UTC)
op.execute(
"""
UPDATE users
SET created_at = CURRENT_TIMESTAMP
WHERE created_at IS NULL
"""
)
def downgrade() -> None:
"""降级"""
op.drop_column('users', 'created_at')
查询示例
from sqlalchemy import select
from datetime import datetime, timezone, timedelta
# 查询最近 7 天创建的用户
seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7)
stmt = select(User).where(User.created_at >= seven_days_ago)
users = await session.execute(stmt)
测试规范
单元测试
import pytest
from datetime import datetime, timezone, timedelta
from app.models.user import User
def test_user_timestamps():
"""测试用户时间戳"""
user = User(
username="test_user",
email="test@example.com"
)
# 验证时间戳是 aware datetime
assert user.created_at.tzinfo is not None
assert user.created_at.tzinfo == timezone.utc
# 验证时间戳接近当前时间
now = datetime.now(timezone.utc)
assert abs((user.created_at - now).total_seconds()) < 1
@pytest.mark.asyncio
async def test_time_comparison():
"""测试时间比较"""
user = User(username="test", email="test@example.com")
# 等待 1 秒
await asyncio.sleep(1)
# 验证时间比较
now = datetime.now(timezone.utc)
assert user.created_at < now
assert (now - user.created_at).total_seconds() >= 1
Mock 时间
from unittest.mock import patch
from datetime import datetime, timezone
@patch('app.models.user.datetime')
def test_with_fixed_time(mock_datetime):
"""使用固定时间测试"""
# 设置固定时间
fixed_time = datetime(2026, 1, 28, 12, 0, 0, tzinfo=timezone.utc)
mock_datetime.now.return_value = fixed_time
user = User(username="test", email="test@example.com")
assert user.created_at == fixed_time
常见问题
Q1: 为什么必须使用 TIMESTAMPTZ 而非 TIMESTAMP WITHOUT TIME ZONE?
A:
- 语义正确性:
created_at、updated_at等字段表示真实世界中事件发生的时间点,必须包含时区信息 - 数据正确性:TIMESTAMPTZ 自动处理时区转换,避免应用层错误
- 多时区支持:支持全球化部署,不同时区的用户看到正确的本地时间
- 符合标准:PostgreSQL 官方推荐,SQL 标准做法
- 性能相同:存储大小和查询性能与 TIMESTAMP 完全相同
TIMESTAMPTZ 的实际行为:
-- 输入:接受任意时区
INSERT INTO events (event_time) VALUES ('2026-01-28 15:00:00+08:00');
-- 存储:自动转换为 UTC(内部表示)
-- 2026-01-28 07:00:00 UTC
-- 输出:根据客户端时区自动转换
SET timezone = 'America/New_York';
SELECT event_time FROM events;
-- 显示:2026-01-28 02:00:00-05
Q2: TIMESTAMP WITHOUT TIME ZONE 什么时候使用?
A: 仅用于不具备绝对时间语义的字段:
-- ✅ 正确使用场景
daily_report_time TIME WITHOUT TIME ZONE -- 每天的报告时间(如 09:00)
business_hours_start TIME WITHOUT TIME ZONE -- 营业开始时间
recurring_event_time TIMESTAMP WITHOUT TIME ZONE -- 重复事件时间
-- ❌ 错误使用场景
created_at TIMESTAMP WITHOUT TIME ZONE -- 应该用 TIMESTAMPTZ
order_placed_at TIMESTAMP WITHOUT TIME ZONE -- 应该用 TIMESTAMPTZ
Q2: 如何处理用户本地时区?
A:
# 后端:始终使用 UTC
created_at = datetime.now(timezone.utc)
# 前端:转换为本地时区
const localTime = new Date(response.created_at).toLocaleString();
# 或使用 moment.js / date-fns
import { format } from 'date-fns';
const localTime = format(new Date(response.created_at), 'yyyy-MM-dd HH:mm:ss');
Q3: datetime.utcnow() 为什么被废弃?
A:
datetime.utcnow()返回 naive datetime(无时区信息)- 容易导致时区混淆和比较错误
- Python 3.12+ 推荐使用
datetime.now(timezone.utc)
Q4: 如何处理历史数据的时区问题?
A:
-- 如果历史数据是 naive datetime,需要明确其时区
-- 假设历史数据都是 UTC
UPDATE users
SET created_at = created_at AT TIME ZONE 'UTC'
WHERE created_at IS NOT NULL;
Q5: asyncpg 报错 "can't subtract offset-naive and offset-aware datetimes"
A: 这是因为混合使用了 naive 和 aware datetime。解决方案:
- 确保所有 Model 的
default_factory使用datetime.now(timezone.utc) - 清空测试数据库中的旧数据(naive datetime)
- 运行迁移脚本更新历史数据
迁移指南
从 datetime.utcnow() 迁移
步骤 1: 全局搜索并替换
# 搜索所有使用 datetime.utcnow 的文件
grep -r "datetime.utcnow" server/app/
# 替换为 datetime.now(timezone.utc)
步骤 2: 更新 Model 定义
# 修改前
from datetime import datetime
class User(SQLModel, table=True):
created_at: datetime = Field(default_factory=datetime.utcnow)
# 修改后
from datetime import datetime, timezone
class User(SQLModel, table=True):
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
步骤 3: 清理测试数据
-- 清空测试数据库中的旧数据
TRUNCATE TABLE users CASCADE;
步骤 4: 运行测试
pytest tests/ -v
检查清单
- 所有 Model 的时间戳字段使用
datetime.now(timezone.utc) - 所有 Model 文件导入了
timezone - 所有 Service 层的时间操作使用 aware datetime
- 数据库表使用
TIMESTAMP WITHOUT TIME ZONE - API Schema 正确处理 ISO 8601 格式
- 测试用例使用 aware datetime
- 清理了历史 naive datetime 数据
参考资料
版本历史
| 版本 | 日期 | 变更内容 |
|---|---|---|
| 1.0 | 2026-01-28 | 初始版本,基于 CreditService 开发实践总结 |
维护者: Backend Team
审核者: Architecture Team
最后审核: 2026-01-28