Skip to content

Commit cd48bb4

Browse files
authored
Add i18n support for response message (#753)
* feat: i18n support * Optimize i18n * Update the locale in the code * Update the zh-CN file * Update the en-US file * Update the reload filter * Update locale success plugin value * Fix lint * Update pydantic error message translation * Update to minimal implementation * Fix minimal missing code
1 parent 4500dd0 commit cd48bb4

File tree

16 files changed

+340
-134
lines changed

16 files changed

+340
-134
lines changed

backend/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3+
from backend.common.i18n import i18n
34
from backend.utils.console import console
45

56
__version__ = '1.7.0'
67

78

89
def get_version() -> str | None:
910
console.print(f'[cyan]{__version__}[/]')
11+
12+
13+
# 初始化 i18n
14+
i18n.load_locales()

backend/app/admin/service/auth_service.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from backend.app.admin.service.login_log_service import login_log_service
1414
from backend.common.enums import LoginLogStatusType
1515
from backend.common.exception import errors
16+
from backend.common.i18n import t
1617
from backend.common.log import log
1718
from backend.common.response.response_code import CustomErrorCode
1819
from backend.common.security.jwt import (
@@ -93,7 +94,7 @@ async def login(
9394
user = await self.user_verify(db, obj.username, obj.password)
9495
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
9596
if not captcha_code:
96-
raise errors.RequestError(msg='验证码失效,请重新获取')
97+
raise errors.RequestError(msg=t('error.captcha.expired'))
9798
if captcha_code.lower() != obj.captcha.lower():
9899
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
99100
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
@@ -137,7 +138,7 @@ async def login(
137138
msg=e.msg,
138139
),
139140
)
140-
raise errors.RequestError(msg=e.msg, background=task)
141+
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
141142
except Exception as e:
142143
log.error(f'登陆错误: {e}')
143144
raise e
@@ -151,7 +152,7 @@ async def login(
151152
username=obj.username,
152153
login_time=timezone.now(),
153154
status=LoginLogStatusType.success.value,
154-
msg='登录成功',
155+
msg=t('success.login.success'),
155156
),
156157
)
157158
data = GetLoginToken(

backend/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script
2424

2525

26+
class CustomReloadFilter(PythonFilter):
27+
"""自定义重载过滤器"""
28+
29+
def __init__(self):
30+
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
31+
32+
2633
def run(host: str, port: int, reload: bool, workers: int | None) -> None:
2734
url = f'http://{host}:{port}'
2835
docs_url = url + settings.FASTAPI_DOCS_URL
@@ -45,7 +52,7 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None:
4552
address=host,
4653
port=port,
4754
reload=not reload,
48-
reload_filter=PythonFilter,
55+
reload_filter=CustomReloadFilter,
4956
workers=workers or 1,
5057
).serve()
5158

backend/common/exception/errors.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,15 @@ def __init__(self, *, error: CustomErrorCode, data: Any = None, background: Back
3838
class RequestError(BaseExceptionMixin):
3939
"""请求异常"""
4040

41-
code = StandardResponseCode.HTTP_400
42-
43-
def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
41+
def __init__(
42+
self,
43+
*,
44+
code: int = StandardResponseCode.HTTP_400,
45+
msg: str = 'Bad Request',
46+
data: Any = None,
47+
background: BackgroundTask | None = None,
48+
):
49+
self.code = code
4450
super().__init__(msg=msg, data=data, background=background)
4551

4652

backend/common/exception/exception_handler.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
99

1010
from backend.common.exception.errors import BaseExceptionMixin
11+
from backend.common.i18n import i18n, t
1112
from backend.common.response.response_code import CustomResponseCode, StandardResponseCode
1213
from backend.common.response.response_schema import response_base
13-
from backend.common.schema import (
14-
CUSTOM_VALIDATION_ERROR_MESSAGES,
15-
)
1614
from backend.core.conf import settings
1715
from backend.utils.serializers import MsgSpecJSONResponse
1816
from backend.utils.trace_id import get_request_trace_id
@@ -46,18 +44,20 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation
4644
"""
4745
errors = []
4846
for error in exc.errors():
49-
custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
50-
if custom_message:
51-
ctx = error.get('ctx')
52-
if not ctx:
53-
error['msg'] = custom_message
54-
else:
55-
ctx_error = ctx.get('error')
56-
if ctx_error:
57-
error['msg'] = custom_message.format(**ctx)
58-
error['ctx']['error'] = (
59-
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
60-
)
47+
# 非 en-US 语言下,使用自定义错误信息
48+
if i18n.current_language != 'en-US':
49+
custom_message = t(f'pydantic.{error["type"]}')
50+
if custom_message:
51+
ctx = error.get('ctx')
52+
if not ctx:
53+
error['msg'] = custom_message
54+
else:
55+
ctx_error = ctx.get('error')
56+
if ctx_error:
57+
error['msg'] = custom_message.format(**ctx)
58+
error['ctx']['error'] = (
59+
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
60+
)
6161
errors.append(error)
6262
error = errors[0]
6363
if error.get('type') == 'json_invalid':

backend/common/i18n.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import glob
4+
import json
5+
import os
6+
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import yaml
11+
12+
from backend.core.conf import settings
13+
from backend.core.path_conf import LOCALE_DIR
14+
15+
16+
class I18n:
17+
"""国际化管理器"""
18+
19+
def __init__(self):
20+
self.locales: dict[str, dict[str, Any]] = {}
21+
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE
22+
23+
def load_locales(self):
24+
"""加载语言文本"""
25+
patterns = [
26+
os.path.join(LOCALE_DIR, '*.json'),
27+
os.path.join(LOCALE_DIR, '*.yaml'),
28+
os.path.join(LOCALE_DIR, '*.yml'),
29+
]
30+
31+
lang_files = []
32+
33+
for pattern in patterns:
34+
lang_files.extend(glob.glob(pattern))
35+
36+
for lang_file in lang_files:
37+
with open(lang_file, 'r', encoding='utf-8') as f:
38+
lang = Path(lang_file).stem
39+
file_type = Path(lang_file).suffix[1:]
40+
match file_type:
41+
case 'json':
42+
self.locales[lang] = json.loads(f.read())
43+
case 'yaml' | 'yml':
44+
self.locales[lang] = yaml.full_load(f.read())
45+
46+
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
47+
"""
48+
翻译函数
49+
50+
:param key: 目标文本键,支持点分隔,例如 'response.success'
51+
:param default: 目标语言文本不存在时的默认文本
52+
:param kwargs: 目标文本中的变量参数
53+
:return:
54+
"""
55+
keys = key.split('.')
56+
57+
try:
58+
translation = self.locales[self.current_language]
59+
except KeyError:
60+
keys = 'error.language_not_found'
61+
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
62+
63+
for k in keys:
64+
if isinstance(translation, dict) and k in list(translation.keys()):
65+
translation = translation[k]
66+
else:
67+
# Pydantic 兼容
68+
if keys[0] == 'pydantic':
69+
translation = None
70+
else:
71+
translation = key
72+
73+
if translation and kwargs:
74+
translation = translation.format(**kwargs)
75+
76+
return translation or default
77+
78+
79+
# 创建 i18n 单例
80+
i18n = I18n()
81+
82+
# 创建翻译函数实例
83+
t = i18n.t

backend/common/response/response_code.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from enum import Enum
66

7+
from backend.common.i18n import t
8+
79

810
class CustomCodeBase(Enum):
911
"""自定义状态码基类"""
@@ -16,21 +18,22 @@ def code(self) -> int:
1618
@property
1719
def msg(self) -> str:
1820
"""获取状态码信息"""
19-
return self.value[1]
21+
message = self.value[1]
22+
return t(message)
2023

2124

2225
class CustomResponseCode(CustomCodeBase):
2326
"""自定义响应状态码"""
2427

25-
HTTP_200 = (200, '请求成功')
26-
HTTP_400 = (400, '请求错误')
28+
HTTP_200 = (200, 'response.success')
29+
HTTP_400 = (400, 'response.error')
2730
HTTP_500 = (500, '服务器内部错误')
2831

2932

3033
class CustomErrorCode(CustomCodeBase):
3134
"""自定义错误状态码"""
3235

33-
CAPTCHA_ERROR = (40001, '验证码错误')
36+
CAPTCHA_ERROR = (40001, 'error.captcha.error')
3437

3538

3639
@dataclasses.dataclass

backend/common/schema.py

Lines changed: 0 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -7,107 +7,6 @@
77

88
from backend.utils.timezone import timezone
99

10-
# 自定义验证错误信息,参考:
11-
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
12-
# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
13-
CUSTOM_VALIDATION_ERROR_MESSAGES = {
14-
'no_such_attribute': "对象没有属性 '{attribute}'",
15-
'json_invalid': '无效的 JSON: {error}',
16-
'json_type': 'JSON 输入应为字符串、字节或字节数组',
17-
'recursion_loop': '递归错误 - 检测到循环引用',
18-
'model_type': '输入应为有效的字典或 {class_name} 的实例',
19-
'model_attributes_type': '输入应为有效的字典或可提取字段的对象',
20-
'dataclass_exact_type': '输入应为 {class_name} 的实例',
21-
'dataclass_type': '输入应为字典或 {class_name} 的实例',
22-
'missing': '字段为必填项',
23-
'frozen_field': '字段已冻结',
24-
'frozen_instance': '实例已冻结',
25-
'extra_forbidden': '不允许额外的输入',
26-
'invalid_key': '键应为字符串',
27-
'get_attribute_error': '提取属性时出错: {error}',
28-
'none_required': '输入应为 None',
29-
'enum': '输入应为 {expected}',
30-
'greater_than': '输入应大于 {gt}',
31-
'greater_than_equal': '输入应大于或等于 {ge}',
32-
'less_than': '输入应小于 {lt}',
33-
'less_than_equal': '输入应小于或等于 {le}',
34-
'finite_number': '输入应为有限数字',
35-
'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}',
36-
'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}',
37-
'string_type': '输入应为有效的字符串',
38-
'string_sub_type': '输入应为字符串,而不是 str 子类的实例',
39-
'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串',
40-
'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'",
41-
'string_too_short': '字符串应至少有 {min_length} 个字符',
42-
'string_too_long': '字符串最多应有 {max_length} 个字符',
43-
'dict_type': '输入应为有效的字典',
44-
'mapping_type': '输入应为有效的映射,错误: {error}',
45-
'iterable_type': '输入应为可迭代对象',
46-
'iteration_error': '迭代对象时出错,错误: {error}',
47-
'list_type': '输入应为有效的列表',
48-
'tuple_type': '输入应为有效的元组',
49-
'set_type': '输入应为有效的集合',
50-
'bool_type': '输入应为有效的布尔值',
51-
'bool_parsing': '输入应为有效的布尔值,无法解释输入',
52-
'int_type': '输入应为有效的整数',
53-
'int_parsing': '输入应为有效的整数,无法将字符串解析为整数',
54-
'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小',
55-
'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字',
56-
'multiple_of': '输入应为 {multiple_of} 的倍数',
57-
'float_type': '输入应为有效的数字',
58-
'float_parsing': '输入应为有效的数字,无法将字符串解析为数字',
59-
'bytes_type': '输入应为有效的字节',
60-
'bytes_too_short': '数据应至少有 {min_length} 个字节',
61-
'bytes_too_long': '数据最多应有 {max_length} 个字节',
62-
'value_error': '值错误,{error}',
63-
'assertion_error': '断言失败,{error}',
64-
'literal_error': '输入应为 {expected}',
65-
'date_type': '输入应为有效的日期',
66-
'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}',
67-
'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}',
68-
'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期',
69-
'date_past': '日期应为过去的时间',
70-
'date_future': '日期应为未来的时间',
71-
'time_type': '输入应为有效的时间',
72-
'time_parsing': '输入应为有效的时间格式,{error}',
73-
'datetime_type': '输入应为有效的日期时间',
74-
'datetime_parsing': '输入应为有效的日期时间,{error}',
75-
'datetime_object_invalid': '无效的日期时间对象,得到 {error}',
76-
'datetime_past': '输入应为过去的时间',
77-
'datetime_future': '输入应为未来的时间',
78-
'timezone_naive': '输入不应包含时区信息',
79-
'timezone_aware': '输入应包含时区信息',
80-
'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}',
81-
'time_delta_type': '输入应为有效的时间差',
82-
'time_delta_parsing': '输入应为有效的时间差,{error}',
83-
'frozen_set_type': '输入应为有效的冻结集合',
84-
'is_instance_of': '输入应为 {class} 的实例',
85-
'is_subclass_of': '输入应为 {class} 的子类',
86-
'callable_type': '输入应为可调用对象',
87-
'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}",
88-
'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签',
89-
'arguments_type': '参数必须是元组、列表或字典',
90-
'missing_argument': '缺少必需参数',
91-
'unexpected_keyword_argument': '意外的关键字参数',
92-
'missing_keyword_only_argument': '缺少必需的关键字专用参数',
93-
'unexpected_positional_argument': '意外的位置参数',
94-
'missing_positional_only_argument': '缺少必需的位置专用参数',
95-
'multiple_argument_values': '为参数提供了多个值',
96-
'url_type': 'URL 输入应为字符串或 URL',
97-
'url_parsing': '输入应为有效的 URL,{error}',
98-
'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}',
99-
'url_too_long': 'URL 最多应有 {max_length} 个字符',
100-
'url_scheme': 'URL 方案应为 {expected_schemes}',
101-
'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象',
102-
'uuid_parsing': '输入应为有效的 UUID,{error}',
103-
'uuid_version': '预期 UUID 版本为 {expected_version}',
104-
'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象',
105-
'decimal_parsing': '输入应为有效的十进制数',
106-
'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字',
107-
'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数',
108-
'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字',
109-
}
110-
11110
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
11211

11312

backend/core/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ class Settings(BaseSettings):
191191
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
192192
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
193193

194+
# I18n 配置
195+
I18N_DEFAULT_LANGUAGE: str = 'zh-CN'
196+
194197
##################################################
195198
# [ App ] task
196199
##################################################

backend/core/path_conf.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
# 上传文件目录
1818
UPLOAD_DIR = STATIC_DIR / 'upload'
1919

20+
# 离线 IP 数据库路径
21+
IP2REGION_XDB = STATIC_DIR / 'ip2region.xdb'
22+
2023
# 插件目录
2124
PLUGIN_DIR = BASE_PATH / 'plugin'
2225

23-
# 离线 IP 数据库路径
24-
IP2REGION_XDB = STATIC_DIR / 'ip2region.xdb'
26+
# 国际化文件目录
27+
LOCALE_DIR = BASE_PATH / 'locale'

0 commit comments

Comments
 (0)