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

时间与时区规范

版本: 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_atupdated_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:

  1. 语义正确性created_atupdated_at 等字段表示真实世界中事件发生的时间点,必须包含时区信息
  2. 数据正确性:TIMESTAMPTZ 自动处理时区转换,避免应用层错误
  3. 多时区支持:支持全球化部署,不同时区的用户看到正确的本地时间
  4. 符合标准:PostgreSQL 官方推荐,SQL 标准做法
  5. 性能相同:存储大小和查询性能与 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。解决方案:

  1. 确保所有 Model 的 default_factory 使用 datetime.now(timezone.utc)
  2. 清空测试数据库中的旧数据(naive datetime)
  3. 运行迁移脚本更新历史数据

迁移指南

从 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