# 时间与时区规范 **版本**: 1.0 **最后更新**: 2026-01-28 **适用范围**: PostgreSQL 17 + FastAPI + SQLModel ## 目录 - [核心原则](#核心原则) - [技术栈配置](#技术栈配置) - [Model 层规范](#model-层规范) - [Service 层规范](#service-层规范) - [API 层规范](#api-层规范) - [数据库层规范](#数据库层规范) - [测试规范](#测试规范) - [常见问题](#常见问题) - [迁移指南](#迁移指南) --- ## 核心原则 ### 1. 始终使用 Aware Datetime **规则**: 所有 datetime 对象必须包含时区信息(aware datetime) ```python # ✅ 正确:使用 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` 记录所有真实世界时间点 ```sql -- ✅ 正确:记录真实世界时间点 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 # Python 3.12+ # datetime.utcnow() 已废弃,必须使用 datetime.now(timezone.utc) ``` ### 依赖包版本 ```txt # requirements.txt sqlmodel==0.0.14 asyncpg==0.29.0 pydantic==2.5.0 pydantic-settings==2.1.0 ``` ### 环境变量配置 ```bash # .env TZ=UTC # 服务器时区设置为 UTC ``` --- ## Model 层规范 ### 时间戳字段定义 **标准模板**: ```python 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 包装 ```python # ✅ 正确:每次创建实例时调用 default_factory=lambda: datetime.now(timezone.utc) # ❌ 错误:类定义时调用一次,所有实例共享同一时间戳 default_factory=datetime.now(timezone.utc) ``` #### 2. 导入时区模块 ```python # ✅ 正确:导入 timezone from datetime import datetime, timezone # ❌ 错误:缺少 timezone 导入 from datetime import datetime ``` #### 3. 可选时间戳字段 ```python # 可选时间戳(如 deleted_at, completed_at) deleted_at: Optional[datetime] = Field(default=None) # 不要使用 default_factory # ❌ 错误 deleted_at: Optional[datetime] = Field( default_factory=lambda: None # 多余 ) ``` ### 完整示例 ```python """用户模型示例""" 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 层规范 ### 时间戳操作 ```python 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) ``` ### 时间比较 ```python # ✅ 正确: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 定义 ```python 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): ```json { "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()` 直接解析 ### 接收时间戳 ```python 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 ``` --- ## 数据库层规范 ### 表结构定义 ```sql -- 标准时间戳字段(使用 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 迁移脚本 ```python """添加时间戳字段 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') ``` ### 查询示例 ```python 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) ``` --- ## 测试规范 ### 单元测试 ```python 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 时间 ```python 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**: 1. **语义正确性**:`created_at`、`updated_at` 等字段表示真实世界中事件发生的时间点,必须包含时区信息 2. **数据正确性**:TIMESTAMPTZ 自动处理时区转换,避免应用层错误 3. **多时区支持**:支持全球化部署,不同时区的用户看到正确的本地时间 4. **符合标准**:PostgreSQL 官方推荐,SQL 标准做法 5. **性能相同**:存储大小和查询性能与 TIMESTAMP 完全相同 **TIMESTAMPTZ 的实际行为**: ```sql -- 输入:接受任意时区 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**: 仅用于**不具备绝对时间语义**的字段: ```sql -- ✅ 正确使用场景 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**: ```python # 后端:始终使用 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**: ```sql -- 如果历史数据是 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。解决方案: 1. 确保所有 Model 的 `default_factory` 使用 `datetime.now(timezone.utc)` 2. 清空测试数据库中的旧数据(naive datetime) 3. 运行迁移脚本更新历史数据 --- ## 迁移指南 ### 从 datetime.utcnow() 迁移 **步骤 1**: 全局搜索并替换 ```bash # 搜索所有使用 datetime.utcnow 的文件 grep -r "datetime.utcnow" server/app/ # 替换为 datetime.now(timezone.utc) ``` **步骤 2**: 更新 Model 定义 ```python # 修改前 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**: 清理测试数据 ```sql -- 清空测试数据库中的旧数据 TRUNCATE TABLE users CASCADE; ``` **步骤 4**: 运行测试 ```bash 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 数据 --- ## 参考资料 - [Python datetime 文档](https://docs.python.org/3/library/datetime.html) - [PostgreSQL TIMESTAMP 文档](https://www.postgresql.org/docs/current/datatype-datetime.html) - [asyncpg 时区处理](https://magicstack.github.io/asyncpg/current/usage.html#type-conversion) - [Pydantic datetime 处理](https://docs.pydantic.dev/latest/concepts/types/#datetime-types) - [ISO 8601 标准](https://en.wikipedia.org/wiki/ISO_8601) --- ## 版本历史 | 版本 | 日期 | 变更内容 | |------|------|---------| | 1.0 | 2026-01-28 | 初始版本,基于 CreditService 开发实践总结 | --- **维护者**: Backend Team **审核者**: Architecture Team **最后审核**: 2026-01-28