← 返回题目列表

Django 的国际化(i18n)和时区处理怎么做?USE_TZ 到底影响什么?

中等 第 24 / 27 题 更新于 2026/08/02
Django国际化i18n时区USE_TZ

简化版

时区部分USE_TZ = True 时 Django 遵循一条铁律——「数据库和内存里一律存 UTC 的 aware datetime,只在渲染给用户时才转成本地时区」。所以要点是:取当前时间一律用 django.utils.timezone.now()(返回 UTC 的 aware 对象),绝不用 datetime.datetime.now()(返回 naive 对象,存进去会警告并被当成默认时区,跨时区就错了);TIME_ZONE 设置的是默认展示时区而不是存储时区;模板里 Django 会自动把 UTC 转成 TIME_ZONE 或当前激活的时区渲染,需要按用户时区显示就用中间件调 timezone.activate(user_tz)。最容易踩的坑是**「按天分组统计」——TruncDate/__date按当前时区做转换,时区不同结果就不同(UTC 的 8 月 1 日 00:30 在北京是 8 月 1 日 08:30,但 UTC 的 7 月 31 日 23:00 在北京已经是 8 月 1 日了)。国际化部分gettext 家族负责翻译——代码里用 gettext_lazy as _(模块级、模型字段、表单标签这些在 import 时就求值的地方必须用 lazy,否则语言切换不生效),视图内的即时字符串用 gettext,模板里用 {% translate %} / {% blocktranslate %}。流程是 makemessages 提取 → 翻译 .pocompilemessages 编译成 .mo。语言的选择顺序是:URL 前缀(i18n_patterns)→ session → cookie → Accept-Language 头 → LANGUAGE_CODE。核心记忆:USE_TZ=True 就是「存 UTC、显示本地」**;时间一律 timezone.now()翻译一律 gettext_lazy按天统计要当心时区

详细版

时区相关设置与 API

含义常见错误
USE_TZ = True存 UTC、aware datetimedatetime.now() 产生 naive
TIME_ZONE默认展示时区(不是存储时区)以为它改变了存储
timezone.now()UTC 的 aware 对象datetime.now() 代替
timezone.localtime(dt)转成当前激活时区——
timezone.activate(tz)设置本请求的展示时区忘了在中间件里设
timezone.make_aware(dt)naive → aware用错时区
# ① ★settings★
USE_TZ = True                  # ★存 UTC★
TIME_ZONE = "Asia/Shanghai"    # ★默认展示时区★
USE_I18N = True                # 启用翻译
LANGUAGE_CODE = "zh-hans"      # ★注意是 zh-hans 不是 zh-cn★
LANGUAGES = [("zh-hans", "简体中文"), ("en", "English")]
LOCALE_PATHS = [BASE_DIR / "locale"]

# ② ★时间:永远用 timezone.now()★
from django.utils import timezone
now = timezone.now()                       # ✓ ★UTC 的 aware datetime★
now = datetime.datetime.now()              # ✗ ★naive,存库会警告★
today = timezone.localdate()               # ✓ ★当前时区的日期★
local = timezone.localtime(obj.created)    # ✓ 转成当前激活时区

# naive → aware
import zoneinfo
naive = datetime.datetime(2026, 8, 1, 10, 0)
aware = timezone.make_aware(naive)                              # 用当前时区
aware = naive.replace(tzinfo=zoneinfo.ZoneInfo("Asia/Shanghai")) # ★py3.9+★

# ③ ★按用户时区展示:中间件★
class TimezoneMiddleware:
    def __init__(self, get_response): self.get_response = get_response
    def __call__(self, request):
        tzname = getattr(request.user, "timezone", None) if request.user.is_authenticated else None
        if tzname:
            timezone.activate(zoneinfo.ZoneInfo(tzname))   # ★本线程生效★
        else:
            timezone.deactivate()                          # ★回到 TIME_ZONE★
        return self.get_response(request)

# ④ ★★按天统计的时区陷阱★★
from django.db.models.functions import TruncDate
Order.objects.annotate(d=TruncDate("created")).values("d").annotate(n=Count("id"))
# ★TruncDate 会按"当前激活时区"转换后再截断★
# → 同一批数据,UTC 下和 Asia/Shanghai 下★分组结果不同★
# ✓ 需要固定时区时显式指定:
TruncDate("created", tzinfo=zoneinfo.ZoneInfo("Asia/Shanghai"))

# ⑤ ★模板里的时区★
# {{ obj.created }}              → ★自动转成当前时区显示★
# {% load tz %}
# {% localtime off %}{{ obj.created }}{% endlocaltime %}   ★关闭自动转换(显示 UTC)★
# {% timezone "America/New_York" %}{{ obj.created }}{% endtimezone %}

# ⑥ ★国际化:lazy vs 非 lazy★
from django.utils.translation import gettext_lazy as _, gettext, ngettext

class Article(models.Model):
    title = models.CharField(_("标题"), max_length=200)   # ★★必须 lazy★★

def my_view(request):
    msg = gettext("保存成功")                              # ★即时翻译★
    n = 5
    text = ngettext("%d 条评论", "%d 条评论", n) % n       # ★复数形式★

# ⑦ ★模板翻译★
# {% load i18n %}
# {% translate "首页" %}
# {% blocktranslate with name=user.name %}你好,{{ name }}{% endblocktranslate %}
# {% blocktranslate count n=items|length %}{{ n }} 项{% plural %}{{ n }} 项{% endblocktranslate %}

# ⑧ ★URL 里带语言前缀★
from django.conf.urls.i18n import i18n_patterns
urlpatterns = [path("api/", include("api.urls"))]          # ★不带前缀★
urlpatterns += i18n_patterns(
    path("", include("blog.urls")),                        # ★/zh-hans/... /en/...★
    prefix_default_language=False,                         # ★默认语言不加前缀★
)

# ⑨ ★工作流★
# django-admin makemessages -l en -l zh_Hans   # ★提取 → locale/<lang>/LC_MESSAGES/django.po★
# (翻译 .po 文件)
# django-admin compilemessages                 # ★编译成 .mo(★运行时读的是 .mo★)★

⚠️ 三个必须记住的点:① USE_TZ = True 下必须用 timezone.now() 而不是 datetime.now()。前者返回带 UTC 时区信息的 aware datetime,后者返回服务器本地时间的 naive 对象——把 naive 对象存进数据库时 Django 会发 RuntimeWarning,并TIME_ZONE 强行解释它;如果服务器时区和 TIME_ZONE 不一致(容器里常见 UTC,而 TIME_ZONE 是上海),时间就会整体偏移 8 小时。同理,比较、加减、传给 ORM 的时间参数也都必须是 aware 的。② TIME_ZONE 设置的是「默认展示时区」,不是「存储时区」。数据库里永远是 UTC(PostgreSQL 用 timestamptz,MySQL 存 UTC 的 datetime),Django 在从数据库读出时转成 aware、在模板渲染时转成当前激活时区。改 TIME_ZONE 只改变显示,不影响已存的数据——理解这一点就理解了整个时区体系。③ 翻译字符串在「模块级 / 类属性 / 函数默认值」这些 import 时求值的位置,必须用 gettext_lazy。因为模块只在进程启动时导入一次,那时还不知道用户会用什么语言;用非 lazy 的 gettext把启动时的语言永久固化,之后无论怎么切换语言都不变。模型字段的 verbose_namehelp_textchoices,表单的 label,以及任何模块级常量,一律用 gettext_lazy

完整版教学

一、USE_TZ 到底改变了什么

★ USE_TZ = False(★不推荐★):
  数据库存 ★naive datetime★(就是你写进去的墙上时间)
  timezone.now() → ★TIME_ZONE 的本地时间,naive★
  ★ 问题:★不知道这个时间是哪个时区的★
    - 服务器迁移到另一个时区 → ★历史数据全部错位★
    - 夏令时切换 → ★一小时重复或缺失,无法区分★
    - 多地区用户 → 无法正确展示

★ ★USE_TZ = True(推荐,Django 5.0 起是默认)★:
  ┌────────────────────────────────────────────────────┐
  │ ★存储层★:数据库里永远是 ★UTC★                       │
  │   PostgreSQL → timestamptz(★数据库自己知道是 UTC★)  │
  │   MySQL      → datetime(★Django 负责转成 UTC 存★)   │
  │   SQLite     → 字符串(Django 处理)                  │
  ├────────────────────────────────────────────────────┤
  │ ★Python 层★:全是 ★aware datetime(tzinfo=UTC)★     │
  │   timezone.now() → datetime(..., tzinfo=timezone.utc)│
  ├────────────────────────────────────────────────────┤
  │ ★展示层★:★渲染时转成当前激活时区★                    │
  │   模板 {{ obj.created }} → 自动 localtime            │
  │   激活时区 = timezone.activate() 设的,或 TIME_ZONE   │
  └────────────────────────────────────────────────────┘

★ ★一个具体例子(理解整条链路)★:
  设 TIME_ZONE = "Asia/Shanghai"(UTC+8)
  北京时间 2026-08-01 10:00 用户下单

  ① 视图:order.created = timezone.now()
     → ★datetime(2026, 8, 1, 2, 0, tzinfo=utc)★  ← ★UTC 时间★
  ② 数据库里:★2026-08-01 02:00:00+00★
  ③ 读出来:★还是 UTC 的 aware 对象★
  ④ 模板渲染:★2026年8月1日 10:00★  ← ★自动转回 +8★
  ⑤ 如果用户设了纽约时区(UTC-4):★2026年7月31日 22:00★

★ ★naive vs aware(Python 基础,但常混淆)★:
  naive = datetime(2026, 8, 1, 10, 0)                    # ★tzinfo 是 None★
  aware = datetime(2026, 8, 1, 10, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
  naive < aware      # ★TypeError: can't compare offset-naive and offset-aware★
  ★ 这个 TypeError 是最常见的时区报错

★ ★存 naive 会怎样★:
  Order.objects.create(created=datetime.datetime.now())    # ✗
  → RuntimeWarning: DateTimeField received a naive datetime
  → Django ★按 TIME_ZONE 把它当作本地时间★,转成 UTC 存
  → ★如果服务器在 UTC 而 TIME_ZONE 是上海★:
    datetime.now() 给的是 UTC 时间 02:00(服务器本地)
    Django 把它当成上海时间 02:00 → 存成 UTC 的 ★前一天 18:00★
    → ★整整差 8 小时★

★ ★容器化部署的常见坑★:
  Docker 容器默认时区是 ★UTC★
  → datetime.now() 返回 UTC 时间(★与开发机不同★)
  → ★本地开发正常、线上时间全错★
  ✓ 根本解法:★永远用 timezone.now()★,不依赖服务器时区
  ✓ 日志时间戳也建议显式指定时区

USE_TZ = True 的整个体系可以用三层理解存储层永远是 UTC(PostgreSQL 用 timestamptz、MySQL 由 Django 负责转换)、Python 层全是 aware datetime展示层在渲染时转成当前激活时区。跟着一笔订单走一遍就清楚了:北京时间 10:00 下单 → timezone.now() 返回 UTC 的 02:00 → 数据库存 02:00+00 → 模板渲染回 10:00 → 纽约用户看到的是 7 月 31 日 22:00。存 naive 对象的后果很具体:Django 会TIME_ZONE 把它当成本地时间,而容器默认时区是 UTC —— 于是 datetime.now() 给的 UTC 时间被当成上海时间,存进去整整差 8 小时,典型症状是「本地开发正常、线上时间全错」。根本解法只有一条:永远用 timezone.now(),不依赖服务器时区

二、时区处理的常见陷阱

★ ★陷阱一:按天/按月分组统计(★最容易出错★)★
  from django.db.models.functions import TruncDate, TruncMonth
  Order.objects.annotate(d=TruncDate("created")).values("d").annotate(n=Count("id"))

  ★ TruncDate 生成的 SQL(PostgreSQL):
    DATE(created AT TIME ZONE 'Asia/Shanghai')
  → ★用的是"当前激活时区"★
  → ★同一批数据在不同时区下分组结果不同!★

  ★ 具体例子:
    一笔订单 UTC 时间 ★2026-07-31 16:30★
    按 UTC 分组      → ★7 月 31 日★
    按 Asia/Shanghai → ★8 月 1 日 00:30 → 8 月 1 日★
  → ★"7 月总销售额"两种算法差一整天的数据★

  ✓ 报表类统计★必须显式指定时区★:
    tz = zoneinfo.ZoneInfo("Asia/Shanghai")
    TruncDate("created", tzinfo=tz)
    TruncMonth("created", tzinfo=tz)
  ✓ 或在报表视图里 timezone.activate(报表时区)
  ★ 记住:★统计口径的时区必须和业务方约定一致★

★ ★陷阱二:__date 和日期范围查询★
  Order.objects.filter(created__date=date(2026, 8, 1))
  → ★同样按当前时区转换★
  Order.objects.filter(created__range=(start, end))
  → ★start/end 必须是 aware 的★,否则警告 + 可能错位

  ✓ 构造"某天的范围":
    tz = zoneinfo.ZoneInfo("Asia/Shanghai")
    start = datetime.datetime(2026, 8, 1, tzinfo=tz)
    end = start + datetime.timedelta(days=1)
    Order.objects.filter(created__gte=start, created__lt=end)   # ★半开区间★
  ★ ★用 [start, end) 半开区间★,别用 __date 或 __lte(边界毫秒问题)

★ ★陷阱三:auto_now_add 和默认值★
  created = models.DateTimeField(default=datetime.datetime.now)   # ✗ ★naive★
  created = models.DateTimeField(default=timezone.now)            # ✓ ★注意不加括号★
  created = models.DateTimeField(auto_now_add=True)               # ✓ ★内部用 timezone.now★
  ★ default=timezone.now() ★加了括号就是"迁移生成时的固定值"★!

★ ★陷阱四:夏令时(DST)★
  美国、欧洲有夏令时 → ★同一个墙上时间可能出现两次或不存在★
  2026-11-01 01:30 America/New_York → ★出现两次(模糊)★
  2026-03-08 02:30 America/New_York → ★不存在(跳过)★
  ✓ zoneinfo 的处理:fold=0/1 区分重复的时刻
  ✓ ★存 UTC 就没有这个问题★(这正是存 UTC 的价值)
  ★ 只在"用户输入本地时间"时需要小心

★ ★陷阱五:时区名的写法★
  ✓ "Asia/Shanghai"(★IANA 时区数据库名★)
  ✗ "CST"(★歧义!可能是中国、美国中部、古巴★)
  ✗ "UTC+8"(不处理历史变更和夏令时)
  ★ Python 3.9+ 用 ★zoneinfo.ZoneInfo★(标准库),
    更早版本用 pytz(★注意 pytz 的 localize() 用法不同★)
  ★ Windows 上 zoneinfo 需要 ★tzdata 包★

★ ★陷阱六:前端传来的时间★
  前端传 "2026-08-01T10:00:00"(★没有时区信息★)
  → 后端怎么解释?★必须约定清楚★
  ✓ ★最佳实践:前后端一律用 ISO 8601 带时区★
    "2026-08-01T10:00:00+08:00" 或 "2026-08-01T02:00:00Z"
  ✓ DRF 的 DateTimeField 会按 settings 解析并转成 aware

时区的六个陷阱里,按天分组统计是最容易出错、后果也最严重的——TruncDate 生成的 SQL 是 DATE(created AT TIME ZONE '当前时区')同一批数据在不同时区下分组结果不同:一笔 UTC 2026-07-31 16:30 的订单,按 UTC 算是 7 月 31 日、按上海时间算就是 8 月 1 日,「7 月总销售额」两种算法能差一整天的数据。报表类统计必须显式传 tzinfo=,并且和业务方约定好口径。日期范围查询要[start, end) 半开区间而不是 __date__lte(避开边界毫秒问题)。default=timezone.now 不能加括号(加了就是「迁移生成时的固定值」)。夏令时的重复和缺失时刻正是「存 UTC」要解决的问题。时区名一定要用 IANA 名称Asia/Shanghai),CST 这种缩写有歧义(可能是中国、美国中部或古巴)。

三、按用户时区展示

★ 完整方案:
  ① 模型上存用户时区
     class User(AbstractUser):
         timezone = models.CharField(max_length=64, default="Asia/Shanghai")
     ★ 表单里用下拉选择:zoneinfo.available_timezones()(★几百个,要分组★)
     ★ 或用 django-timezone-field 提供的字段

  ② 中间件激活
     class TimezoneMiddleware:
         def __init__(self, get_response): self.get_response = get_response
         def __call__(self, request):
             tzname = None
             if request.user.is_authenticated:
                 tzname = request.user.timezone
             else:
                 tzname = request.session.get("django_timezone")  # ★游客也能选★
             if tzname:
                 try:
                     timezone.activate(zoneinfo.ZoneInfo(tzname))
                 except zoneinfo.ZoneInfoNotFoundError:
                     timezone.deactivate()
             else:
                 timezone.deactivate()
             try:
                 return self.get_response(request)
             finally:
                 timezone.deactivate()      # ★★必须清理!★★
     ★ ★为什么必须 deactivate★:
       activate 用的是 ★threading.local★(或 asgiref.local)
       → ★线程会被复用★,不清理会★污染下一个请求★
       → 症状:★偶发的时间显示错误,极难复现★

  ③ 前端自动探测(可选)
     Intl.DateTimeFormat().resolvedOptions().timeZone   // "Asia/Shanghai"
     → 首次访问时提交给后端存进 session

★ ★模板里的控制★:
  {% load tz %}
  {{ obj.created }}                                  ★自动转当前时区★
  {% localtime off %}{{ obj.created }}{% endlocaltime %}   ★显示原始 UTC★
  {% timezone "UTC" %}{{ obj.created }}{% endtimezone %}   ★临时切换★
  {{ obj.created|timezone:"America/New_York" }}            ★过滤器★

★ ★API 的时区处理(★和网页不同★)★:
  ✓ ★API 一律返回 UTC 的 ISO 8601★:"2026-08-01T02:00:00Z"
  → ★让客户端自己转成用户本地时间★
  → 好处:★服务端不用关心客户端时区★、缓存友好、时区逻辑集中在前端
  ✗ 不要在 API 里返回"已经转好的本地时间字符串"
  ★ DRF 的行为:DateTimeField 默认按 ★settings.TIME_ZONE★ 序列化
    → 想强制 UTC:★REST_FRAMEWORK = {"DATETIME_FORMAT": ...}★
      或 serializers.DateTimeField(default_timezone=timezone.utc)

★ ★异步(ASGI)下的时区★:
  Django 用 ★asgiref.local.Local★ 存激活的时区
  → ★协程之间是隔离的★(比 threading.local 更正确)
  ★ 但自己写的中间件要确保 ★async 版本也清理★

★ ★后台任务(Celery)里没有请求★:
  → ★没有激活的时区 → 用 TIME_ZONE★
  ✓ 需要按用户时区生成内容(如日报邮件)时★显式激活★:
    with timezone.override(user_tz):        # ★上下文管理器,自动恢复★
        render_report()
  ★ timezone.override 比 activate/deactivate 更安全

按用户时区展示的完整方案是「模型存时区 + 中间件激活 + 前端探测」。中间件里最关键的一点是必须 deactivate() 清理——activate() 用的是 threading.local(ASGI 下是 asgiref.local.Local),线程会被复用,不清理就会污染下一个请求,症状是「偶发的时间显示错误,极难复现」。API 的处理和网页不同API 应该一律返回 UTC 的 ISO 8601 字符串"...Z"),让客户端自己转——好处是服务端不关心客户端时区、缓存友好、时区逻辑集中在前端;注意 DRF 默认是按 settings.TIME_ZONE 序列化的,要强制 UTC 得显式配置。Celery 等后台任务里没有请求,所以用的是 TIME_ZONE——需要按用户时区生成内容(比如日报邮件)时用 timezone.override(user_tz) 上下文管理器(比 activate/deactivate 更安全,会自动恢复)。

四、国际化的核心:lazy vs 非 lazy

★ ★为什么需要 lazy(★理解这个就理解了 i18n 的一半★)★:
  # models.py(★模块级,进程启动时执行一次★)
  class Article(models.Model):
      title = models.CharField(gettext("标题"), max_length=200)   # ✗
  ★ 问题:gettext("标题") ★在 import 时就求值了★
  → 那一刻的激活语言(通常是 LANGUAGE_CODE)被★永久固化★
  → 之后无论用户切换成英文,verbose_name ★永远是中文★

  ✓ gettext_lazy 返回一个 ★惰性代理对象★:
    class Article(models.Model):
        title = models.CharField(gettext_lazy("标题"), max_length=200)
    → 它★不立即翻译★,而是在★真正被转成字符串时★才查翻译表
    → 每次渲染都按★当时激活的语言★翻译 ✓

★ ★什么时候用哪个(★记住这张表★)★:
  ┌──────────────────────────────┬──────────────────┐
  │ ★模型字段 verbose_name/help_text★ │ ★gettext_lazy★  │
  │ ★模型 Meta.verbose_name★          │ ★gettext_lazy★  │
  │ ★choices 的显示文本★              │ ★gettext_lazy★  │
  │ ★表单 label / error_messages★     │ ★gettext_lazy★  │
  │ ★模块级常量★                      │ ★gettext_lazy★  │
  │ ★函数默认参数值★                  │ ★gettext_lazy★  │
  │ ★视图里的即时消息★                │ ★gettext★       │
  │ ★函数体内构造的字符串★            │ ★gettext★       │
  └──────────────────────────────┴──────────────────┘
  ★ 口诀:★"import 时求值的地方用 lazy,运行时求值的用普通版"★

★ ★lazy 对象的坑★:
  msg = gettext_lazy("你好")
  json.dumps({"m": msg})          # ✗ ★TypeError: not JSON serializable★
  ✓ str(msg) 或用 ★django.core.serializers.json.DjangoJSONEncoder★
  msg + "!"                       # ✗ 有时会出问题
  ✓ ★format_lazy("{}{}", msg, "!")★
  isinstance(msg, str)            # ★False!★(它是代理对象)

★ ★复数形式(★不同语言规则不同★)★:
  from django.utils.translation import ngettext
  ngettext("%(n)d 个文件", "%(n)d 个文件", n) % {"n": n}
  ★ 英语:1 file / 2 files(2 种形式)
  ★ 俄语:★3 种形式★(1、2-4、5+)
  ★ 中文:★只有 1 种★
  → ★.po 文件里的 Plural-Forms 头定义规则★,gettext 自动选

★ ★带变量的翻译★:
  ✗ gettext("你好,") + name              # ★语序在其他语言里可能不同★
  ✓ gettext("你好,%(name)s") % {"name": name}      # ★占位符★
  ✓ 模板:{% blocktranslate with name=user.name %}你好,{{ name }}{% endblocktranslate %}
  ★ 为什么用具名占位符:★英语 "Hello, X" 和日语 "Xさん、こんにちは" 语序不同★

★ ★上下文消歧(同一个词多种含义)★:
  from django.utils.translation import pgettext
  pgettext("月份", "May")      # 五月
  pgettext("动词", "May")      # 可能
  ★ 模板:{% translate "May" context "月份" %}

理解 lazy 就理解了 i18n 的一半gettext("标题") 在 import 时就求值了,那一刻的激活语言被永久固化,之后切换语言也不变;而 gettext_lazy 返回一个惰性代理对象只在真正被转成字符串时才查翻译表,所以每次渲染都按当时的语言翻译。口诀是「import 时求值的地方用 lazy,运行时求值的用普通版」——模型字段、Metachoices、表单 label、模块级常量、函数默认参数全部用 lazy,视图里的即时消息用 gettext。lazy 对象有几个坑:不能直接 JSON 序列化isinstance(msg, str)False)、拼接要用 format_lazy。带变量的翻译必须用具名占位符而不是字符串拼接——因为英语「Hello, X」和日语「Xさん、こんにちは」语序不同。复数形式用 ngettext(俄语有 3 种形式、中文只有 1 种,规则由 .poPlural-Forms 头定义)。

五、翻译工作流与 URL

★ 完整流程:
  ① ★标记待翻译字符串★(代码里的 _() 和模板里的 {% translate %})
  ② ★提取★:
     django-admin makemessages -l en -l ja
     → ★locale/en/LC_MESSAGES/django.po★
     django-admin makemessages -d djangojs -l en    # ★JS 文件★
  ③ ★翻译 .po★(人工或 Poedit / Weblate / Crowdin)
     #: blog/models.py:12
     msgid "标题"
     msgstr "Title"
  ④ ★编译★:
     django-admin compilemessages       # ★.po → .mo(★运行时读 .mo★)★
  ⑤ ★重启进程★(★.mo 在启动时加载★)

★ ★常见问题★:
  ✗ "翻译不生效" → ★90% 是忘了 compilemessages 或没重启★
  ✗ ".po 里有但没翻译" → ★msgstr 为空时回退到 msgid(原文)★
  ✗ "makemessages 找不到 locale 目录" → ★要先手动创建 locale/★
  ✗ "#, fuzzy 标记" → ★fuzzy 的条目会被忽略★,翻好后要删掉标记
  ★ makemessages 会保留已有翻译,只增删条目(★不会覆盖你的工作★)

★ ★语言选择的优先级(★面试常问★)★:
  ┌────────────────────────────────────────────────┐
  │ ① ★URL 前缀★(i18n_patterns:/en/about/)       │
  │ ② ★session★(django_language 键)               │
  │ ③ ★cookie★(settings.LANGUAGE_COOKIE_NAME)     │
  │ ④ ★Accept-Language 请求头★(浏览器语言)        │
  │ ⑤ ★settings.LANGUAGE_CODE★(兜底)              │
  └────────────────────────────────────────────────┘
  ★ 由 ★LocaleMiddleware★ 实现(★必须放在 SessionMiddleware 之后、
    CommonMiddleware 之前★)

★ ★i18n_patterns(★SEO 相关★)★:
  urlpatterns = [path("api/", include("api.urls"))]     # ★API 不加前缀★
  urlpatterns += i18n_patterns(
      path("", include("blog.urls")),
      prefix_default_language=False,     # ★默认语言不加前缀(/ 而不是 /zh-hans/)★
  )
  ★ 效果:/about/(中文)、/en/about/(英文)
  ★ SEO 好处:★每种语言有独立 URL,能被分别索引★
  ✓ 配合 ★hreflang★ 标签:
    <link rel="alternate" hreflang="en" href="https://x.com/en/about/">
  ★ 模板里取其他语言的 URL:{% translate_url %} 或 get_language_info

★ ★语言切换视图★:
  # urls.py
  path("i18n/", include("django.conf.urls.i18n")),
  # 模板
  <form action="{% url 'set_language' %}" method="post">{% csrf_token %}
    <input name="next" value="{{ request.path }}">
    <select name="language" onchange="this.form.submit()">
      {% get_current_language as cur %}
      {% get_available_languages as langs %}
      {% for code, name in langs %}
        <option value="{{ code }}" {% if code == cur %}selected{% endif %}>{{ name }}</option>
      {% endfor %}
    </select>
  </form>
  ★ set_language 视图会★写 session 和 cookie★

★ ★localization(L10N):格式本地化★
  ★ 与翻译不同,这是★数字/日期/货币的格式★
  USE_L10N(★Django 5.0 起移除,始终启用★)
  {{ value|localize }}  {% localize off %}...{% endlocalize %}
  ★ 例:1234.56 → 英语 "1,234.56"、德语 "1.234,56"
  ★ 日期:美国 08/01/2026、中国 2026年8月1日
  ★ FORMAT_MODULE_PATH 可自定义格式

★ ★数据库内容的翻译(★i18n 框架不管这个★)★:
  gettext 只翻译★代码里的静态字符串★
  文章标题、商品名这些★数据库内容★要自己解决:
  ① ★每种语言一列★:title_zh / title_en(★简单,字段爆炸★)
  ② ★JSONField★:{"zh": "标题", "en": "Title"}(★灵活,难索引★)
  ③ ★翻译表★:ArticleTranslation(article, lang, title)(★规范,要 JOIN★)
  ④ 第三方:django-modeltranslation / django-parler

翻译流程是 makemessages → 翻译 .pocompilemessages重启进程.mo 在启动时加载)。「翻译不生效」90% 是忘了 compilemessages 或没重启,另外要注意 #, fuzzy 标记的条目会被忽略,翻好后要删掉标记。语言选择的优先级是面试常问点URL 前缀 → session → cookie → Accept-Language 头 → LANGUAGE_CODE,由 LocaleMiddleware 实现(位置必须在 SessionMiddleware 之后、CommonMiddleware 之前)。i18n_patterns 让每种语言有独立 URL,对 SEO 很重要(配合 hreflang 标签),prefix_default_language=False 可以让默认语言不带前缀。最后一个关键认知:gettext 只翻译代码里的静态字符串,数据库内容(文章标题、商品名)要自己解决——四种方案是每种语言一列、JSONField、翻译表、或用 django-modeltranslation 这类第三方包。

六、实践清单

★ 时区检查清单:
  □ ★USE_TZ = True★(Django 5.0 起默认)
  □ ★全项目搜索 datetime.now() / datetime.today() 并替换★
    grep -rn "datetime.now()\|datetime.today()" --include="*.py"
  □ ★default=timezone.now(★不加括号★)★
  □ ★报表统计显式指定 tzinfo★
  □ ★日期范围用 [start, end) 半开区间★
  □ ★中间件里 activate 后必须 deactivate★
  □ ★API 返回 UTC ISO 8601★
  □ ★前端传时间必须带时区偏移★
  □ ★Celery 任务里用 timezone.override★

★ i18n 检查清单:
  □ ★模型/表单/常量用 gettext_lazy★
  □ ★带变量用具名占位符,不用字符串拼接★
  □ ★LocaleMiddleware 位置正确★
  □ ★makemessages 后记得 compilemessages★
  □ ★部署流程里包含 compilemessages★
  □ ★fuzzy 标记清理了★
  □ ★数据库内容的翻译方案定了★
  □ ★i18n_patterns + hreflang(SEO)★

★ ★排查时间问题的顺序★:
  ① ★这个 datetime 是 aware 还是 naive★(print(dt.tzinfo))
  ② ★数据库里存的是什么★(直接查 SQL,看是不是 UTC)
  ③ ★当前激活的时区是什么★(timezone.get_current_timezone())
  ④ ★服务器系统时区★(date 命令 / time.tzname)
  ⑤ ★是不是统计口径的时区问题★(TruncDate 的 tzinfo)

★ ★一个把时区讲透的例子★:
  用户在北京时间 2026-08-01 00:30 下单
  ┌────────────────────┬──────────────────────────────┐
  │ timezone.now()      │ 2026-07-31 16:30 UTC (aware) │
  │ 数据库              │ 2026-07-31 16:30+00          │
  │ 模板(上海)        │ ★2026年8月1日 00:30★         │
  │ 模板(纽约 -4)     │ ★2026年7月31日 12:30★        │
  │ TruncDate(UTC)    │ ★2026-07-31★  ← ★统计口径 A★  │
  │ TruncDate(上海)   │ ★2026-08-01★  ← ★统计口径 B★  │
  └────────────────────┴──────────────────────────────┘
  ★ ★同一条数据,"哪一天"取决于时区 —— 这就是统计必须约定时区的原因★

★ 一句话总结:
  ★"USE_TZ=True 就是『存 UTC、显示本地』:时间一律 timezone.now(),
    TIME_ZONE 只管展示不管存储,按天统计务必显式指定 tzinfo;
    i18n 则是『import 时求值的地方用 gettext_lazy』,
    改完记得 compilemessages 并重启。"★

时区检查清单里最实用的一条是全项目搜索 datetime.now() 并替换grep -rn "datetime.now()" --include="*.py"),这一条能消灭绝大多数时间 bug。排查时间问题有固定顺序:先看这个 datetime 是 aware 还是 naiveprint(dt.tzinfo))、再直接查 SQL 看数据库里存的是不是 UTC、然后看当前激活时区、服务器系统时区、最后考虑是不是统计口径的时区问题。最后那张表把整件事讲透了:同一条数据「属于哪一天」完全取决于时区——北京时间 8 月 1 日 00:30 的订单,按 UTC 统计属于 7 月 31 日、按上海时间统计属于 8 月 1 日,这就是报表统计必须和业务方约定时区口径的原因

记忆钩子:「★USE_TZ=True 的铁律:存 UTC、显示本地★——★存储层数据库永远是 UTC★(PG 用 timestamptz)、★Python 层全是 aware datetime★、★展示层渲染时才转成当前激活时区★;所以 ★TIME_ZONE 设的是『默认展示时区』而不是『存储时区』★,改它只影响显示不影响已存数据。第一条硬规矩:★时间一律 timezone.now() 而不是 datetime.now()★——后者返回 naive 对象,Django 会★按 TIME_ZONE 把它当成本地时间★,而★容器默认时区是 UTC★,于是 UTC 时间被当成上海时间存进去,★整整差 8 小时★(典型症状:本地开发正常、线上时间全错);naive 和 aware 比较还会直接 ★TypeError★。★最容易出错的是按天分组统计★:TruncDate 生成 DATE(created AT TIME ZONE '当前时区'),★同一批数据在不同时区下分组结果不同★——UTC 的 07-31 16:30 按上海算就是 8 月 1 日,『7 月销售额』能差一整天的数据 → ★报表必须显式传 tzinfo= 并和业务方约定口径★;日期范围用 ★[start, end) 半开区间★而不是 __date/__lte。其他要点:★default=timezone.now 不能加括号★(加了就是迁移生成时的固定值)、时区名用 ★IANA 名(Asia/Shanghai),CST 有歧义★、★中间件 activate 后必须 deactivate★(用的是 threading.local,★线程复用会污染下个请求★,症状是偶发且极难复现)、★API 一律返回 UTC 的 ISO 8601 让客户端自己转★(DRF 默认按 TIME_ZONE 序列化,要注意)、★Celery 里用 timezone.override 上下文管理器★。i18n 的核心是 ★lazy★:★gettext() 在 import 时就求值,会把启动时的语言永久固化★,所以★模型字段/Meta/choices/表单 label/模块级常量/函数默认参数一律用 gettext_lazy★,★视图内的即时消息用 gettext★——口诀『★import 时求值的用 lazy,运行时求值的用普通版★』;lazy 对象★不能直接 JSON 序列化★、拼接要用 format_lazy。带变量★必须用具名占位符★(英语 ‘Hello, X’ 和日语 ‘Xさん…’ 语序不同)。流程是 makemessages → 翻 .po → ★compilemessages(运行时读 .mo)→ 重启★,★『翻译不生效』90% 是忘了这一步或没重启★,还要清理 ★#, fuzzy 标记(会被忽略)★。★语言选择优先级:URL 前缀 → session → cookie → Accept-Language → LANGUAGE_CODE★(LocaleMiddleware 实现,★位置在 Session 之后、Common 之前★)。最后:★gettext 只翻译代码里的静态字符串,数据库内容要自己解决★(多列/JSONField/翻译表/django-modeltranslation)。」

七、常见误区与追问

  • 误区:设了 TIME_ZONE = "Asia/Shanghai",数据库里存的就是北京时间。 不是——USE_TZ = True 时数据库里永远存 UTCTIME_ZONE 设置的只是默认的展示时区。整条链路是:timezone.now() 返回 UTC 的 aware datetime → 存进数据库还是 UTC(PostgreSQL 用 timestamptz 类型、MySQL 由 Django 负责转换)→ 读出来仍是 UTC 的 aware 对象 → 只在模板渲染时才转成当前激活的时区。理解这一点非常关键,因为它解释了很多现象:为什么改 TIME_ZONE 不会改变已存的数据、为什么直接用数据库客户端查看时间会「差 8 小时」(那是正常的,那就是 UTC)、为什么不同用户能看到各自时区的时间。存 UTC 的价值在于:服务器迁移、多地区用户、夏令时切换都不会让历史数据错位
  • 误区:datetime.datetime.now()timezone.now() 只是写法不同。 差别很大。timezone.now()USE_TZ=True 时返回 UTC 的 aware datetimetzinfo 不为 None);datetime.datetime.now() 返回 服务器本地时间的 naive 对象。把 naive 对象存进 DateTimeField 时,Django 会发 RuntimeWarningTIME_ZONE 强行把它解释成本地时间——问题在于容器的默认时区通常是 UTC,于是 datetime.now() 给出的是 UTC 时间,却被当成上海时间来转换,结果整整偏移 8 小时。这类 bug 的典型症状是「本地开发一切正常,部署到线上时间全错」,而且往往在跨天的边界才被发现。此外 naive 和 aware 对象不能直接比较TypeError: can't compare offset-naive and offset-aware datetimes),这是最常见的时区报错。规矩很简单:项目里全局搜索 datetime.now()datetime.today() 并全部替换掉
  • 误区:按天统计只要 TruncDate 一下就准确了。 TruncDate 生成的 SQL 是 DATE(created AT TIME ZONE '当前激活时区')——结果完全取决于用哪个时区去截断。举个具体例子:一笔订单的 UTC 时间是 2026-07-31 16:30,按 UTC 分组它属于 7 月 31 日,按 Asia/Shanghai 分组它是 2026-08-01 00:30、属于 8 月 1 日。于是「7 月总销售额」在两种口径下能差一整天的数据量——月底那几个小时的订单归属完全不同。所以任何报表类统计都必须显式指定时区TruncDate("created", tzinfo=ZoneInfo("Asia/Shanghai")),或者在报表视图里 timezone.activate(报表时区)。更重要的是和业务方约定清楚口径(是按用户所在时区算,还是按公司总部时区算)——这是业务问题不是技术问题,但技术上必须显式表达出来,不能依赖「当前碰巧激活的时区」。
  • 误区:中间件里 timezone.activate() 之后不清理也没关系,下个请求会重新设置。 会出偶发的错误activate() 把时区存在 threading.local(ASGI 下是 asgiref.local.Local)里,而 Web 服务器的工作线程是复用的——如果请求 A 激活了纽约时区却没清理,紧接着被同一线程处理的请求 B(一个没有登录用户、本该用默认时区的请求)就会沿用纽约时区。症状是「某些用户偶尔看到错误的时间」,而且极难复现(取决于线程调度)。所以中间件里必须在 finally 块里 timezone.deactivate(),或者更稳妥地使用 timezone.override(tz) 上下文管理器(它会自动恢复之前的状态)。同样的道理适用于 translation.activate()——语言也存在线程本地变量里,LocaleMiddleware 内部就做了正确的清理。
  • 误区:在模型字段里用 gettext 还是 gettext_lazy 无所谓,反正都能翻译。 模型字段必须用 gettext_lazy。原因是模型模块在进程启动时被 import 一次gettext("标题") 会在那一刻立即求值——用的是当时激活的语言(通常就是 LANGUAGE_CODE),结果被永久固化成一个普通字符串。之后无论用户怎么切换语言,verbose_name 永远是最初那个语言。gettext_lazy 返回的是惰性代理对象,它推迟到「真正需要字符串」的那一刻(渲染表单、生成 admin 页面时)才去查翻译表,因此每次都能按当前语言给出正确结果。同样必须用 lazy 的还有:Meta.verbose_namechoices 的显示文本、表单的 labelerror_messages任何模块级常量函数的默认参数值。判断口诀是「import 时求值的位置用 lazy,运行时求值的用普通版」。注意 lazy 对象不是 strisinstance 返回 False),直接 json.dumps 会报错,拼接要用 format_lazy
  • 追问:.po.mo 文件分别是什么?部署时要注意什么? .po(Portable Object)是人类可读可编辑的翻译源文件——文本格式,每条记录包含 #: 源码位置注释、msgid(原文)、msgstr(译文),由 makemessages 生成和更新(它会保留已有翻译、只增删条目,不会覆盖你的工作)。.mo(Machine Object)是编译后的二进制文件,由 compilemessages.po 生成,运行时 Django 读的是 .mo(二进制格式加载和查找都更快)。部署要点有四个:① 部署流程里必须包含 compilemessages——只提交 .po 而忘了编译,线上就完全没有翻译;.mo 在进程启动时加载,改了必须重启(这是「翻译不生效」的另一个高频原因);③ 决定 .mo 要不要进版本库——进库的好处是部署简单、坏处是二进制文件产生冲突,更常见的做法是在 CI 里编译;④ 清理 #, fuzzy 标记——makemessages 会给「原文改动过、译文可能过时」的条目打 fuzzy,而fuzzy 条目在运行时会被忽略、回退到原文,翻译确认后要删掉这个标记。
  • 追问:Django 是怎么决定用哪种语言的?LocaleMiddleware 按固定优先级依次尝试:① URL 前缀(用了 i18n_patterns 时,/en/about/ 里的 en);② sessiondjango_language 键,set_language 视图会写);③ cookiesettings.LANGUAGE_COOKIE_NAME,默认 django_language);Accept-Language 请求头(浏览器语言偏好,会按 q 值排序并做语言回退,比如 zh-CN 匹配到 zh-hans);settings.LANGUAGE_CODE 兜底。有两个配置细节容易出错:LocaleMiddleware 的位置必须在 SessionMiddleware 之后(它要读 session)、在 CommonMiddleware 之前(URL 重定向要基于正确的语言);语言代码要写 zh-hans/zh-hant 而不是 zh-cn/zh-tw(Django 用的是 IETF 的书写系统标记)。如果用了 i18n_patterns,还要注意它对 SEO 有好处(每种语言独立 URL 能被搜索引擎分别索引,配合 hreflang 标签),但 API 路由不要放进去(不需要语言前缀)。
  • 追问:数据库里的内容(文章标题、商品名)怎么做多语言? Django 的 i18n 框架管不了这个——gettext 只翻译代码里的静态字符串,动态内容要自己设计。四种方案。① 每种语言一列title_zhtitle_en):最简单直观、能加索引和约束、查询性能最好,缺点是语言多了字段爆炸、加语言要改表结构,适合语言数量固定且很少(2~3 种)的场景。② JSONField{"zh": "标题", "en": "Title"}:加语言零成本、结构灵活,但难以索引和排序(PostgreSQL 可以用表达式索引部分弥补)、无法用数据库约束保证必填。③ 独立的翻译表ArticleTranslation(article, lang, title, body) + unique_together(article, lang)):最规范,加语言、查「有哪些语言版本」都很自然,代价是每次查询要 JOIN 或 prefetch。④ 第三方包——django-modeltranslation(自动给模型加语言后缀字段,本质是方案①的自动化)或 django-parler(基于方案③)。选择建议:语言 ≤3 且不常变用方案①,语言多或会持续增加用方案③。另外别忘了配套问题:回退策略(某语言缺失时显示哪个)、搜索(全文检索要按语言分索引)、SEO(每种语言独立 URL + hreflang)。

八、加强记忆

USE_TZ = True 的铁律是「存 UTC、显示本地」——存储层数据库永远是 UTC(PostgreSQL 用 timestamptz)、Python 层全是 aware datetime展示层在渲染时才转成当前激活的时区。所以 TIME_ZONE 设的是「默认展示时区」而不是「存储时区」,改它只影响显示、不影响已存的数据。第一条硬规矩:时间一律用 timezone.now() 而不是 datetime.now()——后者返回 naive 对象,Django 会TIME_ZONE 把它当成本地时间,而容器的默认时区通常是 UTC,于是 UTC 时间被当成上海时间存进去,整整差 8 小时(典型症状是「本地开发正常、线上时间全错」);而且 naive 和 aware 对象比较会直接抛 TypeError最容易出错的是按天分组统计TruncDate 生成 DATE(created AT TIME ZONE '当前时区')同一批数据在不同时区下分组结果不同——UTC 的 07-31 16:30 按上海时间算就属于 8 月 1 日,「7 月销售额」能差一整天的数据,所以报表必须显式传 tzinfo= 并和业务方约定口径;日期范围要用 [start, end) 半开区间而不是 __date/__lte。其他要点:default=timezone.now 不能加括号(加了就变成迁移生成时的固定值)、时区名用 IANA 名称Asia/ShanghaiCST 有歧义)、中间件里 activate 后必须 deactivate(它用的是 threading.local线程复用会污染下一个请求,症状偶发且极难复现,更稳妥的是用 timezone.override 上下文管理器)、API 一律返回 UTC 的 ISO 8601 让客户端自己转(注意 DRF 默认是按 TIME_ZONE 序列化的)、Celery 任务里用 timezone.override。i18n 的核心是 lazygettext() 在 import 时就求值,会把启动时的语言永久固化,所以模型字段、Metachoices、表单 label、模块级常量、函数默认参数一律用 gettext_lazy视图内的即时消息用 gettext——口诀是「import 时求值的用 lazy,运行时求值的用普通版」;lazy 对象不能直接 JSON 序列化、拼接要用 format_lazy。带变量的翻译必须用具名占位符(英语「Hello, X」和日语「Xさん、こんにちは」语序不同)。工作流是 makemessages → 翻译 .pocompilemessages(运行时读的是 .mo)→ 重启进程「翻译不生效」90% 是忘了这一步或没重启,还要记得清理 #, fuzzy 标记(fuzzy 条目会被忽略)语言选择优先级:URL 前缀 → session → cookie → Accept-LanguageLANGUAGE_CODE(由 LocaleMiddleware 实现,位置必须在 SessionMiddleware 之后、CommonMiddleware 之前)。最后记住:gettext 只翻译代码里的静态字符串,数据库内容要自己解决(多列 / JSONField / 翻译表 / django-modeltranslation)。