Skip to content

Add i18n support for response message #753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.common.i18n import i18n
from backend.utils.console import console

__version__ = '1.7.0'


def get_version() -> str | None:
console.print(f'[cyan]{__version__}[/]')


# 初始化 i18n
i18n.load_locales()
7 changes: 4 additions & 3 deletions backend/app/admin/service/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from backend.app.admin.service.login_log_service import login_log_service
from backend.common.enums import LoginLogStatusType
from backend.common.exception import errors
from backend.common.i18n import t
from backend.common.log import log
from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import (
Expand Down Expand Up @@ -93,7 +94,7 @@ async def login(
user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code:
raise errors.RequestError(msg='验证码失效,请重新获取')
raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
Expand Down Expand Up @@ -137,7 +138,7 @@ async def login(
msg=e.msg,
),
)
raise errors.RequestError(msg=e.msg, background=task)
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
except Exception as e:
log.error(f'登陆错误: {e}')
raise e
Expand All @@ -151,7 +152,7 @@ async def login(
username=obj.username,
login_time=timezone.now(),
status=LoginLogStatusType.success.value,
msg='登录成功',
msg=t('success.login.success'),
),
)
data = GetLoginToken(
Expand Down
9 changes: 8 additions & 1 deletion backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script


class CustomReloadFilter(PythonFilter):
"""自定义重载过滤器"""

def __init__(self):
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])


def run(host: str, port: int, reload: bool, workers: int | None) -> None:
url = f'http://{host}:{port}'
docs_url = url + settings.FASTAPI_DOCS_URL
Expand All @@ -45,7 +52,7 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None:
address=host,
port=port,
reload=not reload,
reload_filter=PythonFilter,
reload_filter=CustomReloadFilter,
workers=workers or 1,
).serve()

Expand Down
12 changes: 9 additions & 3 deletions backend/common/exception/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ def __init__(self, *, error: CustomErrorCode, data: Any = None, background: Back
class RequestError(BaseExceptionMixin):
"""请求异常"""

code = StandardResponseCode.HTTP_400

def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
def __init__(
self,
*,
code: int = StandardResponseCode.HTTP_400,
msg: str = 'Bad Request',
data: Any = None,
background: BackgroundTask | None = None,
):
self.code = code
super().__init__(msg=msg, data=data, background=background)


Expand Down
30 changes: 15 additions & 15 deletions backend/common/exception/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES

from backend.common.exception.errors import BaseExceptionMixin
from backend.common.i18n import i18n, t
from backend.common.response.response_code import CustomResponseCode, StandardResponseCode
from backend.common.response.response_schema import response_base
from backend.common.schema import (
CUSTOM_VALIDATION_ERROR_MESSAGES,
)
from backend.core.conf import settings
from backend.utils.serializers import MsgSpecJSONResponse
from backend.utils.trace_id import get_request_trace_id
Expand Down Expand Up @@ -46,18 +44,20 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation
"""
errors = []
for error in exc.errors():
custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
if custom_message:
ctx = error.get('ctx')
if not ctx:
error['msg'] = custom_message
else:
ctx_error = ctx.get('error')
if ctx_error:
error['msg'] = custom_message.format(**ctx)
error['ctx']['error'] = (
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
)
# 非 en-US 语言下,使用自定义错误信息
if i18n.current_language != 'en-US':
custom_message = t(f'pydantic.{error["type"]}')
if custom_message:
ctx = error.get('ctx')
if not ctx:
error['msg'] = custom_message
else:
ctx_error = ctx.get('error')
if ctx_error:
error['msg'] = custom_message.format(**ctx)
error['ctx']['error'] = (
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
)
errors.append(error)
error = errors[0]
if error.get('type') == 'json_invalid':
Expand Down
83 changes: 83 additions & 0 deletions backend/common/i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import glob
import json
import os

from pathlib import Path
from typing import Any

import yaml

from backend.core.conf import settings
from backend.core.path_conf import LOCALE_DIR


class I18n:
"""国际化管理器"""

def __init__(self):
self.locales: dict[str, dict[str, Any]] = {}
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE

def load_locales(self):
"""加载语言文本"""
patterns = [
os.path.join(LOCALE_DIR, '*.json'),
os.path.join(LOCALE_DIR, '*.yaml'),
os.path.join(LOCALE_DIR, '*.yml'),
]

lang_files = []

for pattern in patterns:
lang_files.extend(glob.glob(pattern))

for lang_file in lang_files:
with open(lang_file, 'r', encoding='utf-8') as f:
lang = Path(lang_file).stem
file_type = Path(lang_file).suffix[1:]
match file_type:
case 'json':
self.locales[lang] = json.loads(f.read())
case 'yaml' | 'yml':
self.locales[lang] = yaml.full_load(f.read())

def t(self, key: str, default: Any | None = None, **kwargs) -> str:
"""
翻译函数

:param key: 目标文本键,支持点分隔,例如 'response.success'
:param default: 目标语言文本不存在时的默认文本
:param kwargs: 目标文本中的变量参数
:return:
"""
keys = key.split('.')

try:
translation = self.locales[self.current_language]
except KeyError:
keys = 'error.language_not_found'
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]

for k in keys:
if isinstance(translation, dict) and k in list(translation.keys()):
translation = translation[k]
else:
# Pydantic 兼容
if keys[0] == 'pydantic':
translation = None
else:
translation = key

if translation and kwargs:
translation = translation.format(**kwargs)

return translation or default


# 创建 i18n 单例
i18n = I18n()

# 创建翻译函数实例
t = i18n.t
11 changes: 7 additions & 4 deletions backend/common/response/response_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from enum import Enum

from backend.common.i18n import t


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


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

HTTP_200 = (200, '请求成功')
HTTP_400 = (400, '请求错误')
HTTP_200 = (200, 'response.success')
HTTP_400 = (400, 'response.error')
HTTP_500 = (500, '服务器内部错误')


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

CAPTCHA_ERROR = (40001, '验证码错误')
CAPTCHA_ERROR = (40001, 'error.captcha.error')


@dataclasses.dataclass
Expand Down
101 changes: 0 additions & 101 deletions backend/common/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,107 +7,6 @@

from backend.utils.timezone import timezone

# 自定义验证错误信息,参考:
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
CUSTOM_VALIDATION_ERROR_MESSAGES = {
'no_such_attribute': "对象没有属性 '{attribute}'",
'json_invalid': '无效的 JSON: {error}',
'json_type': 'JSON 输入应为字符串、字节或字节数组',
'recursion_loop': '递归错误 - 检测到循环引用',
'model_type': '输入应为有效的字典或 {class_name} 的实例',
'model_attributes_type': '输入应为有效的字典或可提取字段的对象',
'dataclass_exact_type': '输入应为 {class_name} 的实例',
'dataclass_type': '输入应为字典或 {class_name} 的实例',
'missing': '字段为必填项',
'frozen_field': '字段已冻结',
'frozen_instance': '实例已冻结',
'extra_forbidden': '不允许额外的输入',
'invalid_key': '键应为字符串',
'get_attribute_error': '提取属性时出错: {error}',
'none_required': '输入应为 None',
'enum': '输入应为 {expected}',
'greater_than': '输入应大于 {gt}',
'greater_than_equal': '输入应大于或等于 {ge}',
'less_than': '输入应小于 {lt}',
'less_than_equal': '输入应小于或等于 {le}',
'finite_number': '输入应为有限数字',
'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}',
'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}',
'string_type': '输入应为有效的字符串',
'string_sub_type': '输入应为字符串,而不是 str 子类的实例',
'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串',
'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'",
'string_too_short': '字符串应至少有 {min_length} 个字符',
'string_too_long': '字符串最多应有 {max_length} 个字符',
'dict_type': '输入应为有效的字典',
'mapping_type': '输入应为有效的映射,错误: {error}',
'iterable_type': '输入应为可迭代对象',
'iteration_error': '迭代对象时出错,错误: {error}',
'list_type': '输入应为有效的列表',
'tuple_type': '输入应为有效的元组',
'set_type': '输入应为有效的集合',
'bool_type': '输入应为有效的布尔值',
'bool_parsing': '输入应为有效的布尔值,无法解释输入',
'int_type': '输入应为有效的整数',
'int_parsing': '输入应为有效的整数,无法将字符串解析为整数',
'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小',
'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字',
'multiple_of': '输入应为 {multiple_of} 的倍数',
'float_type': '输入应为有效的数字',
'float_parsing': '输入应为有效的数字,无法将字符串解析为数字',
'bytes_type': '输入应为有效的字节',
'bytes_too_short': '数据应至少有 {min_length} 个字节',
'bytes_too_long': '数据最多应有 {max_length} 个字节',
'value_error': '值错误,{error}',
'assertion_error': '断言失败,{error}',
'literal_error': '输入应为 {expected}',
'date_type': '输入应为有效的日期',
'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}',
'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}',
'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期',
'date_past': '日期应为过去的时间',
'date_future': '日期应为未来的时间',
'time_type': '输入应为有效的时间',
'time_parsing': '输入应为有效的时间格式,{error}',
'datetime_type': '输入应为有效的日期时间',
'datetime_parsing': '输入应为有效的日期时间,{error}',
'datetime_object_invalid': '无效的日期时间对象,得到 {error}',
'datetime_past': '输入应为过去的时间',
'datetime_future': '输入应为未来的时间',
'timezone_naive': '输入不应包含时区信息',
'timezone_aware': '输入应包含时区信息',
'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}',
'time_delta_type': '输入应为有效的时间差',
'time_delta_parsing': '输入应为有效的时间差,{error}',
'frozen_set_type': '输入应为有效的冻结集合',
'is_instance_of': '输入应为 {class} 的实例',
'is_subclass_of': '输入应为 {class} 的子类',
'callable_type': '输入应为可调用对象',
'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}",
'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签',
'arguments_type': '参数必须是元组、列表或字典',
'missing_argument': '缺少必需参数',
'unexpected_keyword_argument': '意外的关键字参数',
'missing_keyword_only_argument': '缺少必需的关键字专用参数',
'unexpected_positional_argument': '意外的位置参数',
'missing_positional_only_argument': '缺少必需的位置专用参数',
'multiple_argument_values': '为参数提供了多个值',
'url_type': 'URL 输入应为字符串或 URL',
'url_parsing': '输入应为有效的 URL,{error}',
'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}',
'url_too_long': 'URL 最多应有 {max_length} 个字符',
'url_scheme': 'URL 方案应为 {expected_schemes}',
'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象',
'uuid_parsing': '输入应为有效的 UUID,{error}',
'uuid_version': '预期 UUID 版本为 {expected_version}',
'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象',
'decimal_parsing': '输入应为有效的十进制数',
'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字',
'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数',
'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字',
}

CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]


Expand Down
3 changes: 3 additions & 0 deletions backend/core/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ class Settings(BaseSettings):
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'

# I18n 配置
I18N_DEFAULT_LANGUAGE: str = 'zh-CN'

##################################################
# [ App ] task
##################################################
Expand Down
7 changes: 5 additions & 2 deletions backend/core/path_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
# 上传文件目录
UPLOAD_DIR = STATIC_DIR / 'upload'

# 离线 IP 数据库路径
IP2REGION_XDB = STATIC_DIR / 'ip2region.xdb'

# 插件目录
PLUGIN_DIR = BASE_PATH / 'plugin'

# 离线 IP 数据库路径
IP2REGION_XDB = STATIC_DIR / 'ip2region.xdb'
# 国际化文件目录
LOCALE_DIR = BASE_PATH / 'locale'
Loading