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 提取 → 翻译 .po → compilemessages 编译成 .mo。语言的选择顺序是:URL 前缀(i18n_patterns)→ session → cookie → Accept-Language 头 → LANGUAGE_CODE。核心记忆:USE_TZ=True 就是「存 UTC、显示本地」**;时间一律 timezone.now();翻译一律 gettext_lazy;按天统计要当心时区。
详细版
时区相关设置与 API:
| 项 | 含义 | 常见错误 |
|---|---|---|
USE_TZ = True | 存 UTC、aware datetime | 用 datetime.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_name、help_text、choices,表单的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,运行时求值的用普通版」——模型字段、Meta、choices、表单 label、模块级常量、函数默认参数全部用 lazy,视图里的即时消息用 gettext。lazy 对象有几个坑:不能直接 JSON 序列化(isinstance(msg, str) 是 False)、拼接要用 format_lazy。带变量的翻译必须用具名占位符而不是字符串拼接——因为英语「Hello, X」和日语「Xさん、こんにちは」语序不同。复数形式用 ngettext(俄语有 3 种形式、中文只有 1 种,规则由 .po 的 Plural-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 → 翻译 .po → compilemessages → 重启进程(.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 还是 naive(print(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时数据库里永远存 UTC,TIME_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 datetime(tzinfo不为None);datetime.datetime.now()返回 服务器本地时间的 naive 对象。把 naive 对象存进DateTimeField时,Django 会发RuntimeWarning并按TIME_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_name、choices的显示文本、表单的label和error_messages、任何模块级常量、函数的默认参数值。判断口诀是「import 时求值的位置用 lazy,运行时求值的用普通版」。注意 lazy 对象不是str(isinstance返回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);② session(django_language键,set_language视图会写);③ cookie(settings.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_zh、title_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/Shanghai,CST 有歧义)、中间件里 activate 后必须 deactivate(它用的是 threading.local,线程复用会污染下一个请求,症状偶发且极难复现,更稳妥的是用 timezone.override 上下文管理器)、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 标记(fuzzy 条目会被忽略)。语言选择优先级:URL 前缀 → session → cookie → Accept-Language → LANGUAGE_CODE(由 LocaleMiddleware 实现,位置必须在 SessionMiddleware 之后、CommonMiddleware 之前)。最后记住:gettext 只翻译代码里的静态字符串,数据库内容要自己解决(多列 / JSONField / 翻译表 / django-modeltranslation)。