diff --git a/backend/__init__.py b/backend/__init__.py index 7a3295df..1bf396db 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from backend.common.i18n import i18n from backend.utils.console import console __version__ = '1.7.0' @@ -7,3 +8,7 @@ def get_version() -> str | None: console.print(f'[cyan]{__version__}[/]') + + +# 初始化 i18n +i18n.load_locales() diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py index 42a158a6..2266b4e7 100644 --- a/backend/app/admin/service/auth_service.py +++ b/backend/app/admin/service/auth_service.py @@ -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 ( @@ -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}') @@ -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 @@ -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( diff --git a/backend/cli.py b/backend/cli.py index cedce176..397aca9d 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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 @@ -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() diff --git a/backend/common/exception/errors.py b/backend/common/exception/errors.py index 64db8a03..54ec02b1 100644 --- a/backend/common/exception/errors.py +++ b/backend/common/exception/errors.py @@ -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) diff --git a/backend/common/exception/exception_handler.py b/backend/common/exception/exception_handler.py index ec28e77a..502c0fab 100644 --- a/backend/common/exception/exception_handler.py +++ b/backend/common/exception/exception_handler.py @@ -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 @@ -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': diff --git a/backend/common/i18n.py b/backend/common/i18n.py new file mode 100644 index 00000000..1cc40d21 --- /dev/null +++ b/backend/common/i18n.py @@ -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 diff --git a/backend/common/response/response_code.py b/backend/common/response/response_code.py index 9b3a7152..ca2fca9a 100644 --- a/backend/common/response/response_code.py +++ b/backend/common/response/response_code.py @@ -4,6 +4,8 @@ from enum import Enum +from backend.common.i18n import t + class CustomCodeBase(Enum): """自定义状态码基类""" @@ -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 diff --git a/backend/common/schema.py b/backend/common/schema.py index 924d71e6..37536e44 100644 --- a/backend/common/schema.py +++ b/backend/common/schema.py @@ -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}$')] diff --git a/backend/core/conf.py b/backend/core/conf.py index 7fdb8e58..9d40bcae 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -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 ################################################## diff --git a/backend/core/path_conf.py b/backend/core/path_conf.py index 8b9808b2..d05fc683 100644 --- a/backend/core/path_conf.py +++ b/backend/core/path_conf.py @@ -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' diff --git a/backend/core/registrar.py b/backend/core/registrar.py index e546fa44..5a38e48d 100644 --- a/backend/core/registrar.py +++ b/backend/core/registrar.py @@ -22,6 +22,7 @@ from backend.database.db import create_tables from backend.database.redis import redis_client from backend.middleware.access_middleware import AccessMiddleware +from backend.middleware.i18n_middleware import I18nMiddleware from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware from backend.middleware.opera_log_middleware import OperaLogMiddleware from backend.middleware.state_middleware import StateMiddleware @@ -42,21 +43,24 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]: """ # 创建数据库表 await create_tables() + + # 初始化 redis + await redis_client.open() + # 初始化 limiter await FastAPILimiter.init( redis=redis_client, prefix=settings.REQUEST_LIMITER_REDIS_PREFIX, http_callback=http_limit_callback, ) + # 创建操作日志任务 create_task(OperaLogMiddleware.consumer()) yield # 关闭 redis 连接 - await redis_client.close() - # 关闭 limiter - await FastAPILimiter.close() + await redis_client.aclose() def register_app() -> FastAPI: @@ -127,6 +131,9 @@ def register_middleware(app: FastAPI) -> None: on_error=JwtAuthMiddleware.auth_exception_handler, ) + # I18n + app.add_middleware(I18nMiddleware) + # CORS if settings.MIDDLEWARE_CORS: from fastapi.middleware.cors import CORSMiddleware diff --git a/backend/locale/en-US.json b/backend/locale/en-US.json new file mode 100644 index 00000000..723d7535 --- /dev/null +++ b/backend/locale/en-US.json @@ -0,0 +1,19 @@ +{ + "error": { + "captcha": { + "error": "Captcha error", + "expired": "Captcha has expired, please try again" + }, + "language_not_found": "Current language pack is not initialized or does not exist" + }, + "response": { + "error": "Request error", + "success": "Request success" + }, + "success": { + "login": { + "oauth2_success": "Login success (OAuth2)", + "success": "Login success" + } + } +} diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml new file mode 100644 index 00000000..82ea7c10 --- /dev/null +++ b/backend/locale/zh-CN.yml @@ -0,0 +1,113 @@ +error: + captcha: + error: 验证码错误 + expired: 验证码已过期,请重新获取 + language_not_found: 当前语言包未初始化或不存在 +pydantic: + # 自定义验证错误信息,参考: + # 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 + arguments_type: 参数必须是元组、列表或字典 + assertion_error: '断言失败,{error}' + bool_parsing: 输入应为有效的布尔值,无法解释输入 + bool_type: 输入应为有效的布尔值 + bytes_too_long: '数据最多应有 {max_length} 个字节' + bytes_too_short: '数据应至少有 {min_length} 个字节' + bytes_type: 输入应为有效的字节 + callable_type: 输入应为可调用对象 + dataclass_exact_type: '输入应为 {class_name} 的实例' + dataclass_type: '输入应为字典或 {class_name} 的实例' + date_from_datetime_inexact: 提供给日期的日期时间应具有零时间 - 例如为精确日期 + date_from_datetime_parsing: '输入应为有效的日期或日期时间,{error}' + date_future: 日期应为未来的时间 + date_past: 日期应为过去的时间 + date_parsing: '输入应为 YYYY-MM-DD 格式的有效日期,{error}' + date_type: 输入应为有效的日期 + datetime_future: 输入应为未来的时间 + datetime_object_invalid: '无效的日期时间对象,得到 {error}' + datetime_past: 输入应为过去的时间 + datetime_parsing: '输入应为有效的日期时间,{error}' + datetime_type: 输入应为有效的日期时间 + decimal_max_digits: '十进制输入总共应不超过 {max_digits} 位数字' + decimal_max_places: '十进制输入应不超过 {decimal_places} 位小数' + decimal_parsing: 输入应为有效的十进制数 + decimal_type: 十进制输入应为整数、浮点数、字符串或 Decimal 对象 + decimal_whole_digits: '十进制输入在小数点前应不超过 {whole_digits} 位数字' + dict_type: 输入应为有效的字典 + email_parsing: '输入应为有效的邮箱地址,{error}' + email_type: 输入应为有效的邮箱地址 + enum: '输入应为 {expected}' + extra_forbidden: 不允许额外的输入 + finite_number: 输入应为有限数字 + float_parsing: 输入应为有效的数字,无法将字符串解析为数字 + float_type: 输入应为有效的数字 + frozen_field: 字段已冻结 + frozen_instance: 实例已冻结 + frozen_set_type: 输入应为有效的冻结集合 + get_attribute_error: '提取属性时出错: {error}' + greater_than: '输入应大于 {gt}' + greater_than_equal: '输入应大于或等于 {ge}' + int_from_float: 输入应为有效的整数,得到一个带有小数部分的数字 + int_parsing: 输入应为有效的整数,无法将字符串解析为整数 + int_parsing_size: 无法将输入字符串解析为整数,超出最大大小 + int_type: 输入应为有效的整数 + invalid_key: 键应为字符串 + is_instance_of: '输入应为 {class} 的实例' + is_subclass_of: '输入应为 {class} 的子类' + iteration_error: '迭代对象时出错,错误: {error}' + iterable_type: 输入应为可迭代对象 + json_invalid: '无效的 JSON: {error}' + json_type: JSON 输入应为字符串、字节或字节数组 + less_than: '输入应小于 {lt}' + less_than_equal: '输入应小于或等于 {le}' + list_type: 输入应为有效的列表 + literal_error: '输入应为 {expected}' + mapping_type: '输入应为有效的映射,错误: {error}' + missing: 字段为必填项 + missing_argument: 缺少必需参数 + missing_keyword_only_argument: 缺少必需的关键字专用参数 + missing_positional_only_argument: 缺少必需的位置专用参数 + model_attributes_type: 输入应为有效的字典或可提取字段的对象 + model_type: '输入应为有效的字典或 {class_name} 的实例' + multiple_argument_values: 为参数提供了多个值 + multiple_of: '输入应为 {multiple_of} 的倍数' + no_such_attribute: '对象没有属性 ''{attribute}''' + none_required: 输入应为 None + recursion_loop: 递归错误 - 检测到循环引用 + set_type: 输入应为有效的集合 + string_pattern_mismatch: '字符串应匹配模式 ''{pattern}''' + string_sub_type: 输入应为字符串,而不是 str 子类的实例 + string_too_long: '字符串最多应有 {max_length} 个字符' + string_too_short: '字符串应至少有 {min_length} 个字符' + string_type: 输入应为有效的字符串 + string_unicode: 输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串 + time_delta_parsing: '输入应为有效的时间差,{error}' + time_delta_type: 输入应为有效的时间差 + time_parsing: '输入应为有效的时间格式,{error}' + time_type: 输入应为有效的时间 + timezone_aware: 输入应包含时区信息 + timezone_naive: 输入不应包含时区信息 + timezone_offset: '需要时区偏移为 {tz_expected},实际得到 {tz_actual}' + too_long: '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}' + too_short: '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}' + tuple_type: 输入应为有效的元组 + union_tag_invalid: '使用 {discriminator} 找到的输入标签 ''{tag}'' 与任何预期标签不匹配: {expected_tags}' + union_tag_not_found: '无法使用区分器 {discriminator} 提取标签' + unexpected_keyword_argument: 意外的关键字参数 + unexpected_positional_argument: 意外的位置参数 + url_parsing: '输入应为有效的 URL,{error}' + url_scheme: 'URL 方案应为 {expected_schemes}' + url_syntax_violation: '输入违反了严格的 URL 语法规则,{error}' + url_too_long: 'URL 最多应有 {max_length} 个字符' + url_type: URL 输入应为字符串或 URL + uuid_parsing: '输入应为有效的 UUID,{error}' + uuid_type: UUID 输入应为字符串、字节或 UUID 对象 + uuid_version: '预期 UUID 版本为 {expected_version}' + value_error: '值错误,{error}' +response: + error: 请求错误 + success: 请求成功 +success: + login: + oauth2_success: 登录成功(OAuth2) + success: 登录成功 diff --git a/backend/middleware/i18n_middleware.py b/backend/middleware/i18n_middleware.py new file mode 100644 index 00000000..cd9edf6c --- /dev/null +++ b/backend/middleware/i18n_middleware.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from functools import lru_cache +from typing import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from backend.common.i18n import i18n + + +class I18nMiddleware(BaseHTTPMiddleware): + """国际化中间件""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + """ + 处理请求并设置国际化语言 + + :param request: FastAPI 请求对象 + :param call_next: 下一个中间件或路由处理函数 + :return: + """ + language = self.get_current_language(request) + + # 设置国际化语言 + if language and i18n.current_language != language: + i18n.current_language = language + + response = await call_next(request) + + return response + + @lru_cache(maxsize=128) + def get_current_language(self, request: Request) -> str | None: + """ + 获取当前请求的语言偏好 + + :param request: FastAPI 请求对象 + :return: + """ + accept_language = request.headers.get('Accept-Language', '') + if not accept_language: + return None + + languages = [lang.split(';')[0] for lang in accept_language.split(',')] + lang = languages[0].lower().strip() + + # 语言映射 + lang_mapping = { + 'zh': 'zh-CN', + 'zh-cn': 'zh-CN', + 'zh-hans': 'zh-CN', + 'en': 'en-US', + 'en-us': 'en-US', + } + + return lang_mapping.get(lang, lang) diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index 95bb4d30..aba6dcb9 100644 --- a/backend/plugin/oauth2/service/oauth2_service.py +++ b/backend/plugin/oauth2/service/oauth2_service.py @@ -10,6 +10,7 @@ from backend.app.admin.schema.user import AddOAuth2UserParam from backend.app.admin.service.login_log_service import login_log_service from backend.common.enums import LoginLogStatusType, UserSocialType +from backend.common.i18n import t from backend.common.security import jwt from backend.core.conf import settings from backend.database.db import async_db_session @@ -113,7 +114,7 @@ async def create_with_login( username=sys_user.username, login_time=timezone.now(), status=LoginLogStatusType.success.value, - msg='登录成功(OAuth2)', + msg=t('success.login.oauth2_success'), ) background_tasks.add_task(login_log_service.create, **login_log) await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') diff --git a/backend/plugin/tools.py b/backend/plugin/tools.py index 5d634171..11f8bb3b 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -128,7 +128,6 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: # 使用独立单例,避免与主线程冲突 current_redis_client = RedisCli() - run_await(current_redis_client.open)() # 清理未知插件信息 run_await(current_redis_client.delete_prefix)(