From 34eb41c42a3cbeceec1d1505bea885ca374066a9 Mon Sep 17 00:00:00 2001 From: downdawn <1436759077@qq.com> Date: Mon, 4 Aug 2025 23:28:31 +0800 Subject: [PATCH 01/11] feat: i18 support --- backend/common/i18n/README.md | 254 +++++++++++++++++++++++++ backend/common/i18n/__init__.py | 12 ++ backend/common/i18n/debug_i18n.py | 251 ++++++++++++++++++++++++ backend/common/i18n/locales/en-US.json | 150 +++++++++++++++ backend/common/i18n/locales/zh-CN.json | 150 +++++++++++++++ backend/common/i18n/manager.py | 105 ++++++++++ backend/common/i18n/middleware.py | 89 +++++++++ backend/common/i18n/run_example.py | 212 +++++++++++++++++++++ backend/common/i18n/test_i18n.py | 224 ++++++++++++++++++++++ backend/common/i18n/usage_example.py | 148 ++++++++++++++ backend/core/registrar.py | 10 +- 11 files changed, 1602 insertions(+), 3 deletions(-) create mode 100644 backend/common/i18n/README.md create mode 100644 backend/common/i18n/__init__.py create mode 100644 backend/common/i18n/debug_i18n.py create mode 100644 backend/common/i18n/locales/en-US.json create mode 100644 backend/common/i18n/locales/zh-CN.json create mode 100644 backend/common/i18n/manager.py create mode 100644 backend/common/i18n/middleware.py create mode 100644 backend/common/i18n/run_example.py create mode 100644 backend/common/i18n/test_i18n.py create mode 100644 backend/common/i18n/usage_example.py diff --git a/backend/common/i18n/README.md b/backend/common/i18n/README.md new file mode 100644 index 000000000..e3dcd20aa --- /dev/null +++ b/backend/common/i18n/README.md @@ -0,0 +1,254 @@ +# 国际化 (i18n) 模块 + +FastAPI 项目的完整国际化解决方案,支持多语言响应消息、验证错误消息和业务逻辑消息的自动翻译。 + +## 🌍 功能特性 + +- **自动语言检测**: 支持从 URL 参数、请求头、Accept-Language 等多种方式检测用户语言偏好 +- **响应码国际化**: 自动翻译所有响应状态码消息 +- **验证消息国际化**: 支持 100+ 条 Pydantic 验证错误消息的翻译 +- **业务消息国际化**: 支持业务逻辑中的错误和成功消息翻译 +- **灵活的翻译管理**: 基于 JSON 文件的翻译资源管理 +- **上下文感知**: 支持参数格式化的动态翻译 + +## 📁 文件结构 + +``` +backend/common/i18n/ +├── __init__.py # 模块导出 +├── manager.py # 国际化管理器 +├── middleware.py # 国际化中间件 +├── locales/ # 翻译文件目录 +│ ├── zh-CN.json # 中文翻译 +│ └── en-US.json # 英文翻译 +├── usage_example.py # 使用示例 +└── README.md # 文档说明 +``` + +## 🚀 快速开始 + +### 1. 启用国际化中间件 + +在 `main.py` 中添加国际化中间件: + +```python +from fastapi import FastAPI +from backend.common.i18n import I18nMiddleware + +app = FastAPI() + +# 添加国际化中间件 +app.add_middleware(I18nMiddleware, default_language='zh-CN') +``` + +### 2. 基本使用 + +```python +from backend.common.i18n.manager import t +from backend.common.response.response_code import CustomResponseCode + +# 使用响应码(自动国际化) +res = CustomResponseCode.HTTP_200 +print(res.msg) # 根据当前语言显示 "请求成功" 或 "Request successful" + +# 手动翻译 +message = t('error.user_not_found') +formatted_msg = t('error.invalid_request_params', message="用户名") +``` + +### 3. 语言切换方式 + +客户端可以通过以下方式指定语言: + +1. **URL 参数**: `GET /api/users?lang=en-US` +2. **请求头**: `X-Language: en-US` +3. **Accept-Language**: `Accept-Language: en-US,en;q=0.9` + +优先级: URL 参数 > X-Language 头 > Accept-Language 头 > 默认语言 + +## 📖 API 文档 + +### I18nManager + +国际化管理器,负责加载和管理翻译资源。 + +```python +from backend.common.i18n.manager import get_i18n_manager, t + +# 获取管理器实例 +i18n = get_i18n_manager() + +# 翻译方法 +def t(key: str, language: str = None, **kwargs) -> str: + """ + 翻译函数 + + Args: + key: 翻译键,支持点号分隔的嵌套键 + language: 目标语言,None 则使用当前语言 + **kwargs: 格式化参数 + + Returns: + 翻译后的文本 + """ +``` + +### I18nMiddleware + +国际化中间件,自动检测和设置请求语言。 + +```python +class I18nMiddleware(BaseHTTPMiddleware): + def __init__(self, app, default_language: str = 'zh-CN'): + """ + Args: + app: FastAPI 应用实例 + default_language: 默认语言 + """ +``` + +## 🔧 翻译文件格式 + +翻译文件使用 JSON 格式,支持嵌套结构: + +```json +{ + "response": { + "success": "请求成功", + "error": "请求错误" + }, + "error": { + "user_not_found": "用户不存在", + "invalid_request_params": "请求参数非法: {message}" + }, + "validation": { + "missing": "字段为必填项", + "string_too_short": "字符串应至少有 {min_length} 个字符" + } +} +``` + +## 💡 使用示例 + +### 在 API 端点中使用 + +```python +from fastapi import APIRouter +from backend.common.i18n.manager import t +from backend.common.response.response_code import CustomResponseCode + +router = APIRouter() + +@router.get("/users") +async def get_users(): + # 响应码会自动国际化 + res = CustomResponseCode.HTTP_200 + return { + "code": res.code, + "msg": res.msg, # 自动翻译 + "data": [] + } + +@router.post("/users") +async def create_user(user_data: dict): + if not user_data.get('username'): + # 手动翻译错误消息 + raise HTTPException( + status_code=400, + detail=t('error.user_not_found') + ) + + return { + "msg": t('success.create_success', name="用户") + } +``` + +### 在服务层中使用 + +```python +from backend.common.exception.errors import CustomError +from backend.common.response.response_code import CustomErrorCode +from backend.common.i18n.manager import t + +class UserService: + def get_user(self, user_id: int): + user = self.user_repository.get(user_id) + if not user: + # 使用预定义的错误码 + raise CustomError(error=CustomErrorCode.USER_NOT_FOUND) + + return user + + def validate_password(self, password: str): + if len(password) < 8: + # 使用动态翻译 + raise ValueError(t('error.password_too_short', min_length=8)) +``` + +### 在 Pydantic 模型中使用 + +```python +from pydantic import BaseModel, Field, validator +from backend.common.i18n.manager import t + +class UserCreateSchema(BaseModel): + username: str = Field(..., description="用户名") + password: str = Field(..., description="密码") + + @validator('username') + def validate_username(cls, v): + if not v or len(v) < 3: + raise ValueError(t('validation.string_too_short', min_length=3)) + return v +``` + +## 🔄 扩展新语言 + +1. 在 `locales/` 目录下创建新的语言文件,如 `ja-JP.json` +2. 复制现有翻译文件结构,翻译所有文本 +3. 在 `I18nManager` 中添加新语言到 `supported_languages` 列表 +4. 在中间件的 `_normalize_language` 方法中添加语言映射 + +## 📝 翻译键命名规范 + +- **响应码**: `response.{type}` (如: `response.success`) +- **错误消息**: `error.{error_type}` (如: `error.user_not_found`) +- **成功消息**: `success.{action}` (如: `success.login_success`) +- **验证消息**: `validation.{validation_type}` (如: `validation.missing`) +- **任务消息**: `task.{task_type}` (如: `task.execute_failed`) + +## ⚠️ 注意事项 + +1. **性能考虑**: 翻译文件在启动时加载到内存,避免频繁的文件 I/O +2. **缓存机制**: 使用 `@lru_cache` 缓存管理器实例 +3. **参数格式化**: 支持 Python 字符串格式化语法,如 `{name}`, `{count:d}` +4. **回退机制**: 如果翻译不存在,会回退到默认语言或返回翻译键 +5. **上下文变量**: 使用 `contextvars` 确保请求级别的语言隔离 + +## 🔍 故障排除 + +### 翻译不生效 +- 检查翻译文件是否存在且格式正确 +- 确认中间件已正确添加 +- 验证翻译键是否正确 + +### 语言检测不准确 +- 检查请求头格式 +- 确认支持的语言列表包含目标语言 +- 验证语言代码规范化映射 + +### 格式化参数错误 +- 确保参数名与翻译文件中的占位符匹配 +- 检查参数类型是否正确 +- 验证格式化语法 + +## 🤝 贡献指南 + +1. 添加新的翻译键时,请同时更新所有语言文件 +2. 保持翻译文件结构的一致性 +3. 为新功能编写相应的使用示例 +4. 更新文档说明 + +--- + +通过这个国际化模块,你的 FastAPI 项目可以轻松支持多语言,为全球用户提供本地化的体验。 \ No newline at end of file diff --git a/backend/common/i18n/__init__.py b/backend/common/i18n/__init__.py new file mode 100644 index 000000000..16aa37890 --- /dev/null +++ b/backend/common/i18n/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +国际化(i18n)模块 + +支持多语言的翻译系统 +""" + +from .manager import I18nManager, get_i18n_manager +from .middleware import I18nMiddleware + +__all__ = ['I18nManager', 'get_i18n_manager', 'I18nMiddleware'] diff --git a/backend/common/i18n/debug_i18n.py b/backend/common/i18n/debug_i18n.py new file mode 100644 index 000000000..262c82bde --- /dev/null +++ b/backend/common/i18n/debug_i18n.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +国际化调试脚本 + +用于诊断翻译文件加载和路径问题 +""" + +import json +import os + +from backend.common.i18n.manager import get_i18n_manager +from backend.core.path_conf import BASE_PATH + + +def debug_translation_files(): + """调试翻译文件""" + print('🔍 国际化功能调试') + print('=' * 60) + print() + + # 1. 检查BASE_PATH + print(f'📁 BASE_PATH: {BASE_PATH}') + print(f' 存在: {os.path.exists(BASE_PATH)}') + print() + + # 2. 检查翻译文件目录 + translations_dir = os.path.join(BASE_PATH, 'backend', 'common', 'i18n', 'locales') + print(f'📁 翻译文件目录: {translations_dir}') + print(f' 存在: {os.path.exists(translations_dir)}') + + if os.path.exists(translations_dir): + files = os.listdir(translations_dir) + print(f' 文件列表: {files}') + print() + + # 3. 检查具体的翻译文件 + for lang in ['zh-CN', 'en-US']: + lang_file = os.path.join(translations_dir, f'{lang}.json') + print(f'📄 {lang}.json') + print(f' 路径: {lang_file}') + print(f' 存在: {os.path.exists(lang_file)}') + + if os.path.exists(lang_file): + try: + with open(lang_file, 'r', encoding='utf-8') as f: + data = json.load(f) + print(' JSON有效: ✅') + print(f' 顶级键: {list(data.keys())}') + + # 检查一些关键翻译 + if 'response' in data and 'success' in data['response']: + print(f" 示例翻译: response.success = '{data['response']['success']}'") + else: + print(' ❌ 缺少 response.success 翻译') + + except json.JSONDecodeError as e: + print(f' JSON错误: ❌ {e}') + except Exception as e: + print(f' 读取错误: ❌ {e}') + print() + + # 4. 测试翻译管理器 + print('🔧 测试翻译管理器') + print('-' * 40) + + try: + i18n = get_i18n_manager() + print('✅ 管理器创建成功') + print(f' 默认语言: {i18n.default_language}') + print(f' 支持语言: {i18n.supported_languages}') + print(f' 翻译缓存键: {list(i18n.translations.keys())}') + + # 检查翻译缓存内容 + for lang in i18n.supported_languages: + if lang in i18n.translations: + trans_data = i18n.translations[lang] + print(f' {lang} 缓存: {len(trans_data)} 个顶级键') + if trans_data: + print(f' 顶级键: {list(trans_data.keys())}') + + # 测试一个具体的翻译 + if 'response' in trans_data and isinstance(trans_data['response'], dict): + if 'success' in trans_data['response']: + print(f" response.success: '{trans_data['response']['success']}'") + else: + print(' ❌ response.success 不存在') + else: + print(' ❌ response 键不存在或格式错误') + else: + print(f' ❌ {lang} 翻译为空') + else: + print(f' ❌ {lang} 未加载到缓存') + + except Exception as e: + print(f'❌ 管理器创建失败: {e}') + import traceback + + traceback.print_exc() + + print() + + # 5. 测试翻译函数 + print('🧪 测试翻译函数') + print('-' * 40) + + from backend.common.i18n.manager import t + + test_keys = ['response.success', 'response.error', 'error.captcha_error'] + + for key in test_keys: + try: + zh_result = t(key, language='zh-CN') + en_result = t(key, language='en-US') + + print(f'📝 {key}') + print(f" 中文: '{zh_result}' {'✅' if zh_result != key else '❌'}") + print(f" 英文: '{en_result}' {'✅' if en_result != key else '❌'}") + + except Exception as e: + print(f'❌ 翻译 {key} 失败: {e}') + print() + + +def fix_common_issues(): + """尝试修复常见问题""" + print('🔧 尝试修复常见问题') + print('=' * 60) + print() + + translations_dir = os.path.join(BASE_PATH, 'backend', 'common', 'i18n', 'locales') + + # 确保目录存在 + if not os.path.exists(translations_dir): + print(f'📁 创建翻译目录: {translations_dir}') + os.makedirs(translations_dir, exist_ok=True) + + # 检查并重新创建翻译文件(如果有问题) + translations = { + 'zh-CN': { + 'response': {'success': '请求成功', 'error': '请求错误', 'server_error': '服务器内部错误'}, + 'error': {'captcha_error': '验证码错误', 'user_not_found': '用户不存在'}, + 'success': {'login_success': '登录成功'}, + 'validation': {'missing': '字段为必填项'}, + }, + 'en-US': { + 'response': { + 'success': 'Request successful', + 'error': 'Request error', + 'server_error': 'Internal server error', + }, + 'error': {'captcha_error': 'Captcha error', 'user_not_found': 'User not found'}, + 'success': {'login_success': 'Login successful'}, + 'validation': {'missing': 'Field required'}, + }, + } + + for lang, data in translations.items(): + lang_file = os.path.join(translations_dir, f'{lang}.json') + + # 检查文件是否需要重新创建 + needs_recreation = False + + if not os.path.exists(lang_file): + needs_recreation = True + print(f'📄 {lang}.json 不存在,需要创建') + else: + try: + with open(lang_file, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + + # 检查关键键是否存在 + if not existing_data.get('response', {}).get('success') or not existing_data.get('error', {}).get( + 'captcha_error' + ): + needs_recreation = True + print(f'📄 {lang}.json 内容不完整,需要重新创建') + + except Exception as e: + needs_recreation = True + print(f'📄 {lang}.json 有问题,需要重新创建: {e}') + + if needs_recreation: + try: + with open(lang_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f'✅ 成功创建/修复 {lang}.json') + except Exception as e: + print(f'❌ 创建 {lang}.json 失败: {e}') + + print() + print('🔄 重新测试翻译功能...') + + # 重新加载翻译管理器 + try: + # 清除缓存 + import backend.common.i18n.manager + + if hasattr(backend.common.i18n.manager, '_i18n_manager'): + backend.common.i18n.manager._i18n_manager = None + + from backend.common.i18n.manager import get_i18n_manager, t + + # 强制重新加载 + i18n = get_i18n_manager() + i18n._load_translations() + + # 测试翻译 + zh_msg = t('response.success', language='zh-CN') + en_msg = t('response.success', language='en-US') + + print('🧪 测试结果:') + print(f" 中文: '{zh_msg}' {'✅' if zh_msg == '请求成功' else '❌'}") + print(f" 英文: '{en_msg}' {'✅' if en_msg == 'Request successful' else '❌'}") + + if zh_msg == '请求成功' and en_msg == 'Request successful': + print('🎉 翻译功能修复成功!') + return True + else: + print('❌ 翻译功能仍有问题') + return False + + except Exception as e: + print(f'❌ 重新测试失败: {e}') + import traceback + + traceback.print_exc() + return False + + +if __name__ == '__main__': + print('🚀 开始国际化问题诊断...') + print() + + # 先进行诊断 + debug_translation_files() + + print() + print('🔧 是否尝试自动修复?(y/n): ', end='') + try: + response = input().lower().strip() + if response in ['y', 'yes', '']: + if fix_common_issues(): + print('\n✅ 问题已修复!现在可以重新运行测试:') + print(' python backend/common/i18n/run_example.py') + else: + print('\n❌ 自动修复失败,请检查错误信息') + else: + print('\n📝 请根据诊断信息手动修复问题') + except (EOFError, KeyboardInterrupt): + print('\n📝 请根据诊断信息手动修复问题') diff --git a/backend/common/i18n/locales/en-US.json b/backend/common/i18n/locales/en-US.json new file mode 100644 index 000000000..d3b7edd69 --- /dev/null +++ b/backend/common/i18n/locales/en-US.json @@ -0,0 +1,150 @@ +{ + "response": { + "success": "Request successful", + "error": "Request error", + "server_error": "Internal server error" + }, + "error": { + "captcha_error": "Captcha error", + "json_parse_failed": "JSON parsing failed", + "invalid_request_params": "Invalid request parameters: {message}", + "user_not_found": "User not found", + "username_or_password_error": "Incorrect username or password", + "user_locked": "User has been locked, please contact system administrator", + "user_forbidden": "User has been prohibited from backend management operations, please contact system administrator", + "user_no_role": "User has not been assigned a role, please contact system administrator", + "user_no_menu": "User has not been assigned a menu, please contact system administrator", + "username_already_exists": "Username already registered", + "password_required": "Password cannot be empty", + "old_password_error": "Incorrect old password", + "password_mismatch": "Password inputs do not match", + "refresh_token_expired": "Refresh Token has expired, please log in again", + "user_login_elsewhere": "This user has logged in elsewhere, please log in again and change password promptly", + "dept_has_users": "Department has users, cannot delete", + "task_params_invalid": "Execution failed, task parameters are invalid", + "upload_file_failed": "File upload failed", + "plugin_install_failed": "Plugin installation failed, please try again later", + "model_parse_failed": "Data model column dynamic parsing failed, please contact system super administrator", + "permission_check_failed": "Permission check failed, please contact system administrator" + }, + "success": { + "login_success": "Login successful", + "login_success_oauth2": "Login successful (OAuth2)", + "task_execute_success": "Task {task_id} executed successfully", + "plugin_install_success": "Plugin {plugin_name} installed successfully" + }, + "validation": { + "no_such_attribute": "Object does not have attribute '{attribute}'", + "json_invalid": "Invalid JSON: {error}", + "json_type": "JSON input should be string, bytes or bytearray", + "recursion_loop": "Recursion error - circular reference detected", + "model_type": "Input should be a valid dictionary or {class_name} instance", + "model_attributes_type": "Input should be a valid dictionary or object with extractable fields", + "dataclass_exact_type": "Input should be an instance of {class_name}", + "dataclass_type": "Input should be a dictionary or {class_name} instance", + "missing": "Field required", + "frozen_field": "Field is frozen", + "frozen_instance": "Instance is frozen", + "extra_forbidden": "Extra inputs are not permitted", + "invalid_key": "Keys should be strings", + "get_attribute_error": "Error extracting attribute: {error}", + "none_required": "Input should be None", + "enum": "Input should be {expected}", + "greater_than": "Input should be greater than {gt}", + "greater_than_equal": "Input should be greater than or equal to {ge}", + "less_than": "Input should be less than {lt}", + "less_than_equal": "Input should be less than or equal to {le}", + "finite_number": "Input should be a finite number", + "too_short": "{field_type} should have at least {min_length} items after validation, not {actual_length}", + "too_long": "{field_type} should have at most {max_length} items after validation, not {actual_length}", + "string_type": "Input should be a valid string", + "string_sub_type": "Input should be a string, not an instance of a str subclass", + "string_unicode": "Input should be a valid string, unable to parse raw data as a unicode string", + "string_pattern_mismatch": "String should match pattern '{pattern}'", + "string_too_short": "String should have at least {min_length} characters", + "string_too_long": "String should have at most {max_length} characters", + "dict_type": "Input should be a valid dictionary", + "mapping_type": "Input should be a valid mapping, error: {error}", + "iterable_type": "Input should be iterable", + "iteration_error": "Error iterating over object, error: {error}", + "list_type": "Input should be a valid list", + "tuple_type": "Input should be a valid tuple", + "set_type": "Input should be a valid set", + "bool_type": "Input should be a valid boolean", + "bool_parsing": "Input should be a valid boolean, unable to interpret input", + "int_type": "Input should be a valid integer", + "int_parsing": "Input should be a valid integer, unable to parse string as an integer", + "int_parsing_size": "Unable to parse input string as an integer, exceeds maximum size", + "int_from_float": "Input should be a valid integer, got a number with a fractional part", + "multiple_of": "Input should be a multiple of {multiple_of}", + "float_type": "Input should be a valid number", + "float_parsing": "Input should be a valid number, unable to parse string as a number", + "bytes_type": "Input should be valid bytes", + "bytes_too_short": "Data should have at least {min_length} bytes", + "bytes_too_long": "Data should have at most {max_length} bytes", + "value_error": "Value error, {error}", + "assertion_error": "Assertion failed, {error}", + "literal_error": "Input should be {expected}", + "date_type": "Input should be a valid date", + "date_parsing": "Input should be a valid date in YYYY-MM-DD format, {error}", + "date_from_datetime_parsing": "Input should be a valid date or datetime, {error}", + "date_from_datetime_inexact": "Datetime provided to date should have zero time - e.g. be an exact date", + "date_past": "Date should be in the past", + "date_future": "Date should be in the future", + "time_type": "Input should be a valid time", + "time_parsing": "Input should be in a valid time format, {error}", + "datetime_type": "Input should be a valid datetime", + "datetime_parsing": "Input should be a valid datetime, {error}", + "datetime_object_invalid": "Invalid datetime object, got {error}", + "datetime_past": "Input should be in the past", + "datetime_future": "Input should be in the future", + "timezone_naive": "Input should not have timezone info", + "timezone_aware": "Input should have timezone info", + "timezone_offset": "Timezone offset of {tz_expected} required, got {tz_actual}", + "time_delta_type": "Input should be a valid timedelta", + "time_delta_parsing": "Input should be a valid timedelta, {error}", + "frozen_set_type": "Input should be a valid frozenset", + "is_instance_of": "Input should be an instance of {class}", + "is_subclass_of": "Input should be a subclass of {class}", + "callable_type": "Input should be callable", + "union_tag_invalid": "Input tag '{tag}' found using {discriminator} does not match any of the expected tags: {expected_tags}", + "union_tag_not_found": "Unable to extract tag using discriminator {discriminator}", + "arguments_type": "Arguments must be a tuple, list or dict", + "missing_argument": "Missing required argument", + "unexpected_keyword_argument": "Unexpected keyword argument", + "missing_keyword_only_argument": "Missing required keyword-only argument", + "unexpected_positional_argument": "Unexpected positional argument", + "missing_positional_only_argument": "Missing required positional-only argument", + "multiple_argument_values": "Multiple values provided for argument", + "url_type": "URL input should be a string or URL", + "url_parsing": "Input should be a valid URL, {error}", + "url_syntax_violation": "Input violates strict URL syntax rules, {error}", + "url_too_long": "URL should have at most {max_length} characters", + "url_scheme": "URL scheme should be {expected_schemes}", + "uuid_type": "UUID input should be a string, bytes or UUID object", + "uuid_parsing": "Input should be a valid UUID, {error}", + "uuid_version": "Expected UUID version {expected_version}", + "decimal_type": "Decimal input should be an integer, float, string or Decimal object", + "decimal_parsing": "Input should be a valid decimal", + "decimal_max_digits": "Decimal input should have no more than {max_digits} digits in total", + "decimal_max_places": "Decimal input should have no more than {decimal_places} decimal places", + "decimal_whole_digits": "Decimal input should have no more than {whole_digits} digits before the decimal point", + "email_type": "Input should be a valid email address", + "email_parsing": "Input should be a valid email address, {error}" + }, + "task": { + "clean_login_log": "Clean login logs", + "execute_failed": "Task {task_id} execution failed", + "save_status_failed": "Failed to save latest status of task {name}: {error}", + "add_to_db_failed": "Failed to add task {name} to database" + }, + "websocket": { + "no_auth": "WebSocket connection failed: no authorization", + "auth_failed": "WebSocket connection failed: authorization failed, please check", + "connection_failed": "WebSocket connection failed: {error}" + }, + "database": { + "redis_auth_failed": "❌ Database redis connection authentication failed", + "connection_failed": "❌ Database connection failed {error}" + } +} \ No newline at end of file diff --git a/backend/common/i18n/locales/zh-CN.json b/backend/common/i18n/locales/zh-CN.json new file mode 100644 index 000000000..70e6a1481 --- /dev/null +++ b/backend/common/i18n/locales/zh-CN.json @@ -0,0 +1,150 @@ +{ + "response": { + "success": "请求成功", + "error": "请求错误", + "server_error": "服务器内部错误" + }, + "error": { + "captcha_error": "验证码错误", + "json_parse_failed": "json解析失败", + "invalid_request_params": "请求参数非法: {message}", + "user_not_found": "用户不存在", + "username_or_password_error": "用户名或密码有误", + "user_locked": "用户已被锁定, 请联系统管理员", + "user_forbidden": "用户已被禁止后台管理操作,请联系系统管理员", + "user_no_role": "用户未分配角色,请联系系统管理员", + "user_no_menu": "用户未分配菜单,请联系系统管理员", + "username_already_exists": "用户名已注册", + "password_required": "密码不允许为空", + "old_password_error": "原密码错误", + "password_mismatch": "密码输入不一致", + "refresh_token_expired": "Refresh Token 已过期,请重新登录", + "user_login_elsewhere": "此用户已在异地登录,请重新登录并及时修改密码", + "dept_has_users": "部门下存在用户,无法删除", + "task_params_invalid": "执行失败,任务参数非法", + "upload_file_failed": "上传文件失败", + "plugin_install_failed": "插件安装失败,请稍后重试", + "model_parse_failed": "数据模型列动态解析失败,请联系系统超级管理员", + "permission_check_failed": "权限校验失败,请联系系统管理员" + }, + "success": { + "login_success": "登录成功", + "login_success_oauth2": "登录成功(OAuth2)", + "task_execute_success": "任务 {task_id} 执行成功", + "plugin_install_success": "插件 {plugin_name} 安装成功" + }, + "validation": { + "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} 位数字", + "email_type": "输入应为有效的邮箱地址", + "email_parsing": "输入应为有效的邮箱地址,{error}" + }, + "task": { + "clean_login_log": "清理登录日志", + "execute_failed": "任务 {task_id} 执行失败", + "save_status_failed": "保存任务 {name} 最新状态失败:{error}", + "add_to_db_failed": "添加任务 {name} 到数据库失败" + }, + "websocket": { + "no_auth": "WebSocket 连接失败:无授权", + "auth_failed": "WebSocket 连接失败:授权失败,请检查", + "connection_failed": "WebSocket 连接失败:{error}" + }, + "database": { + "redis_auth_failed": "❌ 数据库 redis 连接认证失败", + "connection_failed": "❌ 数据库链接失败 {error}" + } +} \ No newline at end of file diff --git a/backend/common/i18n/manager.py b/backend/common/i18n/manager.py new file mode 100644 index 000000000..cd2fcd478 --- /dev/null +++ b/backend/common/i18n/manager.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import json +import os + +from functools import lru_cache +from typing import Any, Dict + +from backend.core.path_conf import BASE_PATH + + +class I18nManager: + """国际化管理器""" + + def __init__(self): + self.translations: Dict[str, Dict[str, Any]] = {} + self.default_language = 'zh-CN' + self.supported_languages = ['zh-CN', 'en-US'] + self._load_translations() + + def _load_translations(self): + """加载翻译文件""" + translations_dir = os.path.join(BASE_PATH, 'common', 'i18n', 'locales') + + for lang in self.supported_languages: + lang_file = os.path.join(translations_dir, f'{lang}.json') + if os.path.exists(lang_file): + with open(lang_file, 'r', encoding='utf-8') as f: + self.translations[lang] = json.load(f) + else: + self.translations[lang] = {} + + def t(self, key: str, language: str = None, **kwargs) -> str: + """ + 翻译函数 + + :param key: 翻译键,支持点号分隔的嵌套键,如 'response.success' + :param language: 目标语言,如果不指定则使用默认语言 + :param kwargs: 格式化参数 + :return: 翻译后的文本 + """ + if language is None: + language = self.default_language + + if language not in self.translations: + language = self.default_language + + # 获取翻译文本 + translation = self._get_nested_value(self.translations[language], key) + + if translation is None: + # 如果在指定语言中找不到,尝试默认语言 + if language != self.default_language: + translation = self._get_nested_value(self.translations[self.default_language], key) + + # 如果仍然找不到,返回键名 + if translation is None: + return key + + # 格式化参数 + if kwargs: + try: + return translation.format(**kwargs) + except (KeyError, ValueError): + return translation + + return translation + + @staticmethod + def _get_nested_value(data: Dict[str, Any], key: str) -> str | None: + """获取嵌套字典的值""" + keys = key.split('.') + current = data + + for k in keys: + if isinstance(current, dict) and k in current: + current = current[k] + else: + return None + + return current if isinstance(current, str) else None + + def set_language(self, language: str): + """设置默认语言""" + if language in self.supported_languages: + self.default_language = language + + +# 全局单例实例 +_i18n_manager = None + + +@lru_cache() +def get_i18n_manager() -> I18nManager: + """获取国际化管理器实例""" + global _i18n_manager + if _i18n_manager is None: + _i18n_manager = I18nManager() + return _i18n_manager + + +# 便捷地翻译函数 +def t(key: str, language: str = None, **kwargs) -> str: + """便捷地翻译函数""" + return get_i18n_manager().t(key, language, **kwargs) diff --git a/backend/common/i18n/middleware.py b/backend/common/i18n/middleware.py new file mode 100644 index 000000000..058a9003e --- /dev/null +++ b/backend/common/i18n/middleware.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from contextvars import ContextVar +from typing import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from .manager import get_i18n_manager + +# 上下文变量,用于在请求中存储当前语言 +current_language: ContextVar[str] = ContextVar('current_language', default='zh-CN') + + +class I18nMiddleware(BaseHTTPMiddleware): + """国际化中间件""" + + def __init__(self, app, default_language: str = 'zh-CN'): + super().__init__(app) + self.default_language = default_language + self.i18n_manager = get_i18n_manager() + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + """处理请求,设置语言""" + + # 从多个来源检测语言偏好 + language = self._detect_language(request) + + # 设置上下文变量 + current_language.set(language) + + # 设置管理器的默认语言 + self.i18n_manager.set_language(language) + + # 继续处理请求 + response = await call_next(request) + + # 可选:在响应头中添加语言信息 + response.headers['Content-Language'] = language + + return response + + def _detect_language(self, request: Request) -> str: + """检测请求的语言偏好""" + + # 1. 优先检查 URL 参数 + lang_param = request.query_params.get('lang') + if lang_param and lang_param in self.i18n_manager.supported_languages: + return lang_param + + # 2. 检查请求头中的自定义语言字段 + lang_header = request.headers.get('X-Language') + if lang_header and lang_header in self.i18n_manager.supported_languages: + return lang_header + + # 3. 检查 Accept-Language 头 + accept_language = request.headers.get('Accept-Language', '') + if accept_language: + # 简单解析 Accept-Language,取第一个支持的语言 + languages = [lang.strip().split(';')[0] for lang in accept_language.split(',')] + for lang in languages: + # 处理语言代码的变体,如 'en' -> 'en-US', 'zh' -> 'zh-CN' + normalized_lang = self._normalize_language(lang) + if normalized_lang in self.i18n_manager.supported_languages: + return normalized_lang + + # 4. 返回默认语言 + return self.default_language + + @staticmethod + def _normalize_language(lang: str) -> str: + """规范化语言代码""" + lang = lang.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) + + +def get_current_language() -> str: + """获取当前请求的语言""" + return current_language.get('zh-CN') diff --git a/backend/common/i18n/run_example.py b/backend/common/i18n/run_example.py new file mode 100644 index 000000000..dd7e065a5 --- /dev/null +++ b/backend/common/i18n/run_example.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +国际化功能运行示例 + +可以直接运行的国际化演示脚本 +""" + +from backend.common.i18n.manager import get_i18n_manager, t +from backend.common.response.response_code import CustomErrorCode, CustomResponseCode + + +def test_basic_translation(): + """测试基本翻译功能""" + print('🌍 基本翻译测试') + print('-' * 50) + + # 测试不同语言的基本消息 + test_keys = [ + 'response.success', + 'response.error', + 'error.user_not_found', + 'error.captcha_error', + 'success.login_success', + 'validation.missing', + ] + + for key in test_keys: + zh_msg = t(key, language='zh-CN') + en_msg = t(key, language='en-US') + print(f'📝 {key}') + print(f' 🇨🇳 中文: {zh_msg}') + print(f' 🇺🇸 英文: {en_msg}') + print() + + +def test_parameter_formatting(): + """测试参数格式化""" + print('🔧 参数格式化测试') + print('-' * 50) + + # 测试带参数的翻译 + test_cases = [ + {'key': 'error.invalid_request_params', 'params': {'message': '用户名格式错误'}}, + {'key': 'validation.string_too_short', 'params': {'min_length': 8}}, + {'key': 'validation.string_too_long', 'params': {'max_length': 20}}, + ] + + for case in test_cases: + key = case['key'] + params = case['params'] + + zh_msg = t(key, language='zh-CN', **params) + en_msg = t(key, language='en-US', **params) + + print(f'📝 {key} (参数: {params})') + print(f' 🇨🇳 中文: {zh_msg}') + print(f' 🇺🇸 英文: {en_msg}') + print() + + +def test_response_codes(): + """测试响应码自动翻译""" + print('📋 响应码翻译测试') + print('-' * 50) + + i18n = get_i18n_manager() + + # 测试不同响应码 + response_codes = [CustomResponseCode.HTTP_200, CustomResponseCode.HTTP_400, CustomResponseCode.HTTP_500] + + error_codes = [CustomErrorCode.CAPTCHA_ERROR] + + for lang_code, lang_name in [('zh-CN', '中文'), ('en-US', '英文')]: + print(f'🌐 {lang_name} ({lang_code})') + i18n.set_language(lang_code) + + print(' 📊 响应码:') + for code in response_codes: + print(f' {code.code}: {code.msg}') + + print(' ❌ 错误码:') + for code in error_codes: + print(f' {code.code}: {code.msg}') + print() + + +def test_language_detection_simulation(): + """模拟语言检测过程""" + print('🔍 语言检测模拟') + print('-' * 50) + + # 模拟不同的请求场景 + scenarios = [ + { + 'name': 'URL参数优先', + 'url_lang': 'en-US', + 'header_lang': 'zh-CN', + 'accept_lang': 'ja-JP', + 'expected': 'en-US', + }, + {'name': '请求头次优先', 'url_lang': None, 'header_lang': 'en-US', 'accept_lang': 'zh-CN', 'expected': 'en-US'}, + { + 'name': 'Accept-Language兜底', + 'url_lang': None, + 'header_lang': None, + 'accept_lang': 'en-US,en;q=0.9', + 'expected': 'en-US', + }, + {'name': '默认语言', 'url_lang': None, 'header_lang': None, 'accept_lang': None, 'expected': 'zh-CN'}, + ] + + for scenario in scenarios: + print(f'📌 场景: {scenario["name"]}') + print(f' URL参数: {scenario["url_lang"]}') + print(f' X-Language: {scenario["header_lang"]}') + print(f' Accept-Language: {scenario["accept_lang"]}') + print(f' 预期语言: {scenario["expected"]}') + print(f' 结果消息: {t("response.success", language=scenario["expected"])}') + print() + + +def test_error_scenarios(): + """测试错误场景""" + print('⚠️ 错误场景测试') + print('-' * 50) + + # 测试不存在的翻译键 + print('🔍 测试不存在的翻译键') + nonexistent_key = 'nonexistent.test.key' + result = t(nonexistent_key) + print(f' 键: {nonexistent_key}') + print(f' 结果: {result} (应该返回键名本身)') + print() + + # 测试不支持的语言 + print('🔍 测试不支持的语言') + unsupported_lang = 'ja-JP' + result = t('response.success', language=unsupported_lang) + print(f' 语言: {unsupported_lang}') + print(f' 结果: {result} (应该回退到默认语言)') + print() + + # 测试参数格式化错误 + print('🔍 测试参数格式化错误') + try: + result = t('validation.string_too_short', min_length_wrong='wrong') + print(f' 结果: {result} (参数名错误,应该正常处理)') + except Exception as e: + print(f' 异常: {e}') + print() + + +def test_performance(): + """简单的性能测试""" + print('⚡ 性能测试') + print('-' * 50) + + import time + + # 测试翻译性能 + start_time = time.time() + iterations = 1000 + + for _ in range(iterations): + t('response.success') + t('error.user_not_found') + t('validation.missing') + + end_time = time.time() + total_time = end_time - start_time + avg_time = (total_time / iterations) * 1000 # 毫秒 + + print(f'📊 执行 {iterations} 次翻译') + print(f' 总时间: {total_time:.4f} 秒') + print(f' 平均时间: {avg_time:.4f} 毫秒/次') + print(f' TPS: {iterations / total_time:.0f} 次/秒') + print() + + +def main(): + """主函数""" + print('🎉 FastAPI 国际化功能演示') + print('=' * 60) + print() + + try: + # 运行各种测试 + test_basic_translation() + test_parameter_formatting() + test_response_codes() + test_language_detection_simulation() + test_error_scenarios() + test_performance() + + print('✅ 所有测试完成!国际化功能正常工作。') + print() + print('📝 如需运行 FastAPI 服务器测试,请使用:') + print(' python backend/common/i18n/usage_example.py') + print() + print('📝 如需运行完整测试套件,请使用:') + print(' pytest backend/common/i18n/test_i18n.py -v') + + except Exception as e: + print(f'❌ 测试过程中出现错误: {e}') + import traceback + + traceback.print_exc() + + +if __name__ == '__main__': + main() diff --git a/backend/common/i18n/test_i18n.py b/backend/common/i18n/test_i18n.py new file mode 100644 index 000000000..bbbe29f5f --- /dev/null +++ b/backend/common/i18n/test_i18n.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +国际化功能测试 + +专门用于pytest测试的国际化功能验证 +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from backend.common.i18n import I18nMiddleware, get_i18n_manager +from backend.common.i18n.manager import t +from backend.common.response.response_code import CustomErrorCode, CustomResponseCode + + +class TestI18nManager: + """测试国际化管理器""" + + def setup_method(self): + """测试前准备""" + self.i18n = get_i18n_manager() + + def test_basic_translation(self): + """测试基本翻译功能""" + # 测试中文翻译 + msg_zh = t('response.success', language='zh-CN') + assert msg_zh == '请求成功' + + # 测试英文翻译 + msg_en = t('response.success', language='en-US') + assert msg_en == 'Request successful' + + def test_parameter_formatting(self): + """测试参数格式化""" + # 测试带参数的翻译 + msg_zh = t('error.invalid_request_params', language='zh-CN', message='用户名') + assert '用户名' in msg_zh + + msg_en = t('error.invalid_request_params', language='en-US', message='username') + assert 'username' in msg_en + + def test_nested_keys(self): + """测试嵌套键翻译""" + # 测试验证消息 + msg_zh = t('validation.missing', language='zh-CN') + assert msg_zh == '字段为必填项' + + msg_en = t('validation.missing', language='en-US') + assert msg_en == 'Field required' + + def test_fallback_mechanism(self): + """测试回退机制""" + # 测试不存在的键 + result = t('non_existent.key') + assert result == 'non_existent.key' # 应该返回键名本身 + + def test_supported_languages(self): + """测试支持的语言""" + assert 'zh-CN' in self.i18n.supported_languages + assert 'en-US' in self.i18n.supported_languages + + +class TestResponseCodeI18n: + """测试响应码国际化""" + + def test_response_code_translation(self): + """测试响应码自动翻译""" + # 设置不同语言并测试 + i18n = get_i18n_manager() + + # 测试中文 + i18n.set_language('zh-CN') + res = CustomResponseCode.HTTP_200 + assert res.msg == '请求成功' + + # 测试英文 + i18n.set_language('en-US') + res = CustomResponseCode.HTTP_200 + assert res.msg == 'Request successful' + + def test_error_code_translation(self): + """测试错误码翻译""" + i18n = get_i18n_manager() + + # 测试中文 + i18n.set_language('zh-CN') + error = CustomErrorCode.CAPTCHA_ERROR + assert error.msg == '验证码错误' + + # 测试英文 + i18n.set_language('en-US') + error = CustomErrorCode.CAPTCHA_ERROR + assert error.msg == 'Captcha error' + + +class TestI18nMiddleware: + """测试国际化中间件""" + + def setup_method(self): + """设置测试应用""" + self.app = FastAPI() + self.app.add_middleware(I18nMiddleware, default_language='zh-CN') + + @self.app.get('/test') + async def test_endpoint(): + res = CustomResponseCode.HTTP_200 + return {'code': res.code, 'msg': res.msg} + + self.client = TestClient(self.app) + + def test_default_language(self): + """测试默认语言""" + response = self.client.get('/test') + assert response.status_code == 200 + data = response.json() + assert data['msg'] == '请求成功' # 默认中文 + + def test_url_parameter_language(self): + """测试URL参数语言切换""" + response = self.client.get('/test?lang=en-US') + assert response.status_code == 200 + data = response.json() + assert data['msg'] == 'Request successful' # 英文 + + def test_header_language(self): + """测试请求头语言切换""" + headers = {'X-Language': 'en-US'} + response = self.client.get('/test', headers=headers) + assert response.status_code == 200 + data = response.json() + assert data['msg'] == 'Request successful' # 英文 + + def test_accept_language_header(self): + """测试Accept-Language头""" + headers = {'Accept-Language': 'en-US,en;q=0.9'} + response = self.client.get('/test', headers=headers) + assert response.status_code == 200 + data = response.json() + assert data['msg'] == 'Request successful' # 英文 + + def test_language_priority(self): + """测试语言优先级""" + # URL参数应该优先于请求头 + headers = {'X-Language': 'zh-CN', 'Accept-Language': 'zh-CN'} + response = self.client.get('/test?lang=en-US', headers=headers) + assert response.status_code == 200 + data = response.json() + assert data['msg'] == 'Request successful' # URL参数的英文优先 + + def test_unsupported_language_fallback(self): + """测试不支持语言的回退""" + headers = {'X-Language': 'ja-JP'} # 不支持的日语 + response = self.client.get('/test', headers=headers) + assert response.status_code == 200 + data = response.json() + assert data['msg'] == '请求成功' # 回退到默认中文 + + def test_response_language_header(self): + """测试响应语言头""" + headers = {'X-Language': 'en-US'} + response = self.client.get('/test', headers=headers) + assert response.headers.get('Content-Language') == 'en-US' + + +class TestValidationI18n: + """测试验证消息国际化""" + + def test_validation_messages(self): + """测试验证消息翻译""" + # 测试几个常用的验证消息 + validation_keys = [ + 'validation.missing', + 'validation.string_too_short', + 'validation.string_too_long', + 'validation.int_type', + 'validation.email_type', + ] + + for key in validation_keys: + # 测试中文 + msg_zh = t(key, language='zh-CN') + assert msg_zh != key # 应该有翻译 + assert isinstance(msg_zh, str) + assert len(msg_zh) > 0 + + # 测试英文 + msg_en = t(key, language='en-US') + assert msg_en != key # 应该有翻译 + assert isinstance(msg_en, str) + assert len(msg_en) > 0 + + # 中英文不应该相同 + assert msg_zh != msg_en + + +if __name__ == '__main__': + # 如果直接运行此文件,执行基本测试 + print('🧪 运行国际化功能基础测试...') + + # 测试基本翻译 + print('✅ 测试基本翻译:') + print(f' 中文: {t("response.success", language="zh-CN")}') + print(f' 英文: {t("response.success", language="en-US")}') + + # 测试参数化翻译 + print('✅ 测试参数化翻译:') + print(f' 中文: {t("error.invalid_request_params", language="zh-CN", message="用户名")}') + print(f' 英文: {t("error.invalid_request_params", language="en-US", message="username")}') + + # 测试响应码 + print('✅ 测试响应码翻译:') + i18n = get_i18n_manager() + i18n.set_language('zh-CN') + res_zh = CustomResponseCode.HTTP_200 + print(f' 中文: {res_zh.msg}') + + i18n.set_language('en-US') + res_en = CustomResponseCode.HTTP_200 + print(f' 英文: {res_en.msg}') + + print('🎉 基础测试完成!所有功能正常工作。') + print('\n📝 运行完整测试套件:') + print(' pytest backend/common/i18n/test_i18n.py -v') diff --git a/backend/common/i18n/usage_example.py b/backend/common/i18n/usage_example.py new file mode 100644 index 000000000..45ce2c8f2 --- /dev/null +++ b/backend/common/i18n/usage_example.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +国际化使用示例 + +展示如何在 FastAPI 项目中使用 i18n 功能 +""" + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field, field_validator + +from backend.common.exception.errors import CustomError +from backend.common.i18n import I18nMiddleware +from backend.common.i18n.manager import t +from backend.common.response.response_code import CustomErrorCode, CustomResponseCode + +app = FastAPI() + +# 添加国际化中间件 +app.add_middleware(I18nMiddleware, default_language='zh-CN') + + +@app.get('/api/test') +async def test_endpoint(): + """测试端点 - 展示基本的国际化响应""" + # 使用响应码(会自动国际化) + res = CustomResponseCode.HTTP_200 + return { + 'code': res.code, + 'msg': res.msg, # 会根据请求语言自动翻译 + 'data': {'test': 'success'}, + } + + +@app.get('/api/error') +async def error_endpoint(): + """错误端点 - 展示错误消息的国际化""" + # 抛出自定义错误(使用翻译键) + raise CustomError(error=CustomErrorCode.CAPTCHA_ERROR) + + +@app.get('/api/manual') +async def manual_translation(): + """手动翻译示例""" + # 手动使用翻译函数 + success_msg = t('success.login_success') + error_msg = t('error.user_not_found') + + return { + 'success': success_msg, + 'error': error_msg, + 'formatted': t('error.invalid_request_params', message='test parameter'), + } + + +@app.get('/api/lang/{lang}') +async def change_language(lang: str): + """切换语言示例""" + # 手动指定语言进行翻译 + messages = { + 'zh': t('response.success', language='zh-CN'), + 'en': t('response.success', language='en-US'), + } + + return {'current_lang': lang, 'messages': messages} + + +# 如何在业务逻辑中使用 +class UserService: + """用户服务示例""" + + def validate_user(self, username: str) -> dict: + if not username: + # 使用翻译键抛出错误 + raise HTTPException(status_code=400, detail=t('error.user_not_found')) + + return {'msg': t('success.login_success'), 'user': {'username': username}} + + +# 在 Pydantic 模型中使用(需要动态获取) +class UserModel(BaseModel): + username: str = Field(..., description='用户名') + + @field_validator('username') + @classmethod + def validate_username(cls, v): + if not v: + # 在验证器中使用翻译 + raise ValueError(t('validation.missing')) + return v + + +def test_basic_functionality(): + """测试基本功能(非异步)""" + from backend.common.i18n.manager import get_i18n_manager, t + + print('🧪 测试国际化基本功能') + print('-' * 40) + + # 测试基本翻译 + zh_msg = t('response.success', language='zh-CN') + en_msg = t('response.success', language='en-US') + + print('✅ 基本翻译测试:') + print(f' 中文: {zh_msg}') + print(f' 英文: {en_msg}') + + # 测试响应码翻译 + i18n = get_i18n_manager() + + i18n.set_language('zh-CN') + res_zh = CustomResponseCode.HTTP_200 + + i18n.set_language('en-US') + res_en = CustomResponseCode.HTTP_200 + + print('✅ 响应码翻译测试:') + print(f' 中文: {res_zh.msg}') + print(f' 英文: {res_en.msg}') + + print('🎉 基本功能测试完成!') + return True + + +if __name__ == '__main__': + import sys + + if len(sys.argv) > 1 and sys.argv[1] == 'server': + # 运行FastAPI服务器 + import uvicorn + + print('🚀 启动国际化测试服务器...') + print('📝 测试不同语言:') + print(' curl http://localhost:8000/api/test') + print(' curl -H "X-Language: en-US" http://localhost:8000/api/test') + print(' curl "http://localhost:8000/api/test?lang=en-US"') + print() + uvicorn.run(app, host='0.0.0.0', port=8000) + else: + # 运行基本功能测试 + test_basic_functionality() + print() + print('📝 运行服务器测试:') + print(' python backend/common/i18n/usage_example.py server') + print() + print('📝 运行完整测试:') + print(' python backend/common/i18n/run_example.py') + print(' pytest backend/common/i18n/test_i18n.py -v') diff --git a/backend/core/registrar.py b/backend/core/registrar.py index 439a24203..a3c70251e 100644 --- a/backend/core/registrar.py +++ b/backend/core/registrar.py @@ -15,6 +15,7 @@ from starlette.staticfiles import StaticFiles from backend.common.exception.exception_handler import register_exception +from backend.common.i18n.middleware import I18nMiddleware from backend.common.log import set_custom_logfile, setup_logging from backend.core.conf import settings from backend.core.path_conf import STATIC_DIR, UPLOAD_DIR @@ -124,6 +125,12 @@ def register_middleware(app: FastAPI) -> None: on_error=JwtAuthMiddleware.auth_exception_handler, ) + # I18n + app.add_middleware(I18nMiddleware, default_language='zh-CN') + + # Access log + app.add_middleware(AccessMiddleware) + # CORS if settings.MIDDLEWARE_CORS: from fastapi.middleware.cors import CORSMiddleware @@ -137,9 +144,6 @@ def register_middleware(app: FastAPI) -> None: expose_headers=settings.CORS_EXPOSE_HEADERS, ) - # Access log - app.add_middleware(AccessMiddleware) - # Trace ID app.add_middleware(CorrelationIdMiddleware, validator=False) From 8218a4589f7b71fe0008434b1e8bc84e1752ca2f Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 19:12:22 +0800 Subject: [PATCH 02/11] Optimize i18n --- backend/__init__.py | 5 + backend/app/admin/api/v1/sys/plugin.py | 9 +- backend/app/admin/service/auth_service.py | 23 +- .../app/admin/service/data_rule_service.py | 11 +- .../app/admin/service/data_scope_service.py | 11 +- backend/app/admin/service/dept_service.py | 19 +- backend/app/admin/service/menu_service.py | 17 +- backend/app/admin/service/plugin_service.py | 11 +- backend/app/admin/service/role_service.py | 21 +- backend/app/admin/service/user_service.py | 53 ++-- backend/cli.py | 2 + backend/common/exception/errors.py | 12 +- backend/common/exception/exception_handler.py | 12 +- backend/common/i18n.py | 83 ++++++ backend/common/i18n/README.md | 254 ------------------ backend/common/i18n/__init__.py | 12 - backend/common/i18n/debug_i18n.py | 251 ----------------- backend/common/i18n/locales/zh-CN.json | 150 ----------- backend/common/i18n/manager.py | 105 -------- backend/common/i18n/middleware.py | 89 ------ backend/common/i18n/run_example.py | 212 --------------- backend/common/i18n/test_i18n.py | 224 --------------- backend/common/i18n/usage_example.py | 148 ---------- backend/common/response/response_code.py | 13 +- backend/common/schema.py | 101 ------- backend/core/conf.py | 3 + backend/core/path_conf.py | 7 +- backend/core/registrar.py | 19 +- .../i18n/locales => locale}/en-US.json | 28 +- backend/locale/zh-CN.yml | 205 ++++++++++++++ backend/middleware/i18n_middleware.py | 57 ++++ backend/plugin/tools.py | 1 - backend/utils/health_check.py | 3 +- 33 files changed, 512 insertions(+), 1659 deletions(-) create mode 100644 backend/common/i18n.py delete mode 100644 backend/common/i18n/README.md delete mode 100644 backend/common/i18n/__init__.py delete mode 100644 backend/common/i18n/debug_i18n.py delete mode 100644 backend/common/i18n/locales/zh-CN.json delete mode 100644 backend/common/i18n/manager.py delete mode 100644 backend/common/i18n/middleware.py delete mode 100644 backend/common/i18n/run_example.py delete mode 100644 backend/common/i18n/test_i18n.py delete mode 100644 backend/common/i18n/usage_example.py rename backend/{common/i18n/locales => locale}/en-US.json (83%) create mode 100644 backend/locale/zh-CN.yml create mode 100644 backend/middleware/i18n_middleware.py diff --git a/backend/__init__.py b/backend/__init__.py index 7a3295df7..1bf396db0 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/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index afa219097..80c60eb57 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -8,6 +8,7 @@ from backend.app.admin.service.plugin_service import plugin_service from backend.common.enums import PluginType +from backend.common.i18n import t from backend.common.response.response_code import CustomResponse from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth @@ -43,11 +44,9 @@ async def install_plugin( file: Annotated[UploadFile | None, File()] = None, repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None, ) -> ResponseModel: - plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url) + plugin = await plugin_service.install(type=type, file=file, repo_url=repo_url) return response_base.success( - res=CustomResponse( - code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' - ) + res=CustomResponse(code=200, msg=t('success.plugin_install_success', plugin_name=plugin)) ) @@ -63,7 +62,7 @@ async def install_plugin( async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: await plugin_service.uninstall(plugin=plugin) return response_base.success( - res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') + res=CustomResponse(code=200, msg=t('success.plugin_uninstall_success', plugin_name=plugin)) ) diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py index 42a158a60..f0fde61a4 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 ( @@ -44,16 +45,16 @@ async def user_verify(db: AsyncSession, username: str, password: str) -> User: """ user = await user_dao.get_by_username(db, username) if not user: - raise errors.NotFoundError(msg='用户名或密码有误') + raise errors.NotFoundError(msg=t('error.username_or_password_error')) if user.password is None: - raise errors.AuthorizationError(msg='用户名或密码有误') + raise errors.AuthorizationError(msg=t('error.username_or_password_error')) else: if not password_verify(password, user.password): - raise errors.AuthorizationError(msg='用户名或密码有误') + raise errors.AuthorizationError(msg=t('error.username_or_password_error')) if not user.status: - raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') + raise errors.AuthorizationError(msg=t('error.user.locked')) return user @@ -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( @@ -197,17 +198,17 @@ async def refresh_token(*, request: Request) -> GetNewToken: """ refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) if not refresh_token: - raise errors.RequestError(msg='Refresh Token 已过期,请重新登录') + raise errors.RequestError(msg=t('error.refresh_token_expired')) token_payload = jwt_decode(refresh_token) async with async_db_session() as db: user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) elif not user.status: - raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') + raise errors.AuthorizationError(msg=t('error.user.locked')) if not user.is_multi_login: if await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'): - raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码') + raise errors.ForbiddenError(msg=t('error.user.login_elsewhere')) new_token = await create_new_token( refresh_token, token_payload.session_uuid, diff --git a/backend/app/admin/service/data_rule_service.py b/backend/app/admin/service/data_rule_service.py index 916d07d1a..7f87dbd9c 100644 --- a/backend/app/admin/service/data_rule_service.py +++ b/backend/app/admin/service/data_rule_service.py @@ -13,6 +13,7 @@ UpdateDataRuleParam, ) from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.utils.import_parse import dynamic_import_data_model @@ -32,7 +33,7 @@ async def get(*, pk: int) -> DataRule: async with async_db_session() as db: data_rule = await data_rule_dao.get(db, pk) if not data_rule: - raise errors.NotFoundError(msg='数据规则不存在') + raise errors.NotFoundError(msg=t('error.data_rule.not_found')) return data_rule @staticmethod @@ -49,7 +50,7 @@ async def get_columns(model: str) -> list[GetDataRuleColumnDetail]: :return: """ if model not in settings.DATA_PERMISSION_MODELS: - raise errors.NotFoundError(msg='数据规则可用模型不存在') + raise errors.NotFoundError(msg=t('error.data_rule.available_models')) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) model_columns = [ @@ -87,7 +88,7 @@ async def create(*, obj: CreateDataRuleParam) -> None: async with async_db_session.begin() as db: data_rule = await data_rule_dao.get_by_name(db, obj.name) if data_rule: - raise errors.ConflictError(msg='数据规则已存在') + raise errors.ConflictError(msg=t('error.data_rule.exists')) await data_rule_dao.create(db, obj) @staticmethod @@ -102,10 +103,10 @@ async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: async with async_db_session.begin() as db: data_rule = await data_rule_dao.get(db, pk) if not data_rule: - raise errors.NotFoundError(msg='数据规则不存在') + raise errors.NotFoundError(msg=t('error.data_rule.not_found')) if data_rule.name != obj.name: if await data_rule_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg='数据规则已存在') + raise errors.ConflictError(msg=t('error.data_rule.exists')) count = await data_rule_dao.update(db, pk, obj) return count diff --git a/backend/app/admin/service/data_scope_service.py b/backend/app/admin/service/data_scope_service.py index 88099d2fe..bf31e09f7 100644 --- a/backend/app/admin/service/data_scope_service.py +++ b/backend/app/admin/service/data_scope_service.py @@ -13,6 +13,7 @@ UpdateDataScopeRuleParam, ) from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -32,7 +33,7 @@ async def get(*, pk: int) -> DataScope: async with async_db_session() as db: data_scope = await data_scope_dao.get(db, pk) if not data_scope: - raise errors.NotFoundError(msg='数据范围不存在') + raise errors.NotFoundError(msg=t('error.data_scope.not_found')) return data_scope @staticmethod @@ -53,7 +54,7 @@ async def get_rules(*, pk: int) -> DataScope: async with async_db_session() as db: data_scope = await data_scope_dao.get_with_relation(db, pk) if not data_scope: - raise errors.NotFoundError(msg='数据范围不存在') + raise errors.NotFoundError(msg=t('error.data_scope.not_found')) return data_scope @staticmethod @@ -78,7 +79,7 @@ async def create(*, obj: CreateDataScopeParam) -> None: async with async_db_session.begin() as db: data_scope = await data_scope_dao.get_by_name(db, obj.name) if data_scope: - raise errors.ConflictError(msg='数据范围已存在') + raise errors.ConflictError(msg=t('error.data_scope.exists')) await data_scope_dao.create(db, obj) @staticmethod @@ -93,10 +94,10 @@ async def update(*, pk: int, obj: UpdateDataScopeParam) -> int: async with async_db_session.begin() as db: data_scope = await data_scope_dao.get(db, pk) if not data_scope: - raise errors.NotFoundError(msg='数据范围不存在') + raise errors.NotFoundError(msg=t('error.data_scope.not_found')) if data_scope.name != obj.name: if await data_scope_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg='数据范围已存在') + raise errors.ConflictError(msg=t('error.data_scope.exists')) count = await data_scope_dao.update(db, pk, obj) for role in await data_scope.awaitable_attrs.roles: for user in await role.awaitable_attrs.users: diff --git a/backend/app/admin/service/dept_service.py b/backend/app/admin/service/dept_service.py index 049e229b4..755afbf7f 100644 --- a/backend/app/admin/service/dept_service.py +++ b/backend/app/admin/service/dept_service.py @@ -8,6 +8,7 @@ from backend.app.admin.model import Dept from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -28,7 +29,7 @@ async def get(*, pk: int) -> Dept: async with async_db_session() as db: dept = await dept_dao.get(db, pk) if not dept: - raise errors.NotFoundError(msg='部门不存在') + raise errors.NotFoundError(msg=t('error.dept.not_found')) return dept @staticmethod @@ -61,11 +62,11 @@ async def create(*, obj: CreateDeptParam) -> None: async with async_db_session.begin() as db: dept = await dept_dao.get_by_name(db, obj.name) if dept: - raise errors.ConflictError(msg='部门名称已存在') + raise errors.ConflictError(msg=t('error.dept.exists')) if obj.parent_id: parent_dept = await dept_dao.get(db, obj.parent_id) if not parent_dept: - raise errors.NotFoundError(msg='父级部门不存在') + raise errors.NotFoundError(msg=t('error.dept.not_found')) await dept_dao.create(db, obj) @staticmethod @@ -80,16 +81,16 @@ async def update(*, pk: int, obj: UpdateDeptParam) -> int: async with async_db_session.begin() as db: dept = await dept_dao.get(db, pk) if not dept: - raise errors.NotFoundError(msg='部门不存在') + raise errors.NotFoundError(msg=t('error.dept.not_found')) if dept.name != obj.name: if await dept_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg='部门名称已存在') + raise errors.ConflictError(msg=t('error.dept.exists')) if obj.parent_id: parent_dept = await dept_dao.get(db, obj.parent_id) if not parent_dept: - raise errors.NotFoundError(msg='父级部门不存在') + raise errors.NotFoundError(msg=t('error.dept.parent.not_found')) if obj.parent_id == dept.id: - raise errors.ForbiddenError(msg='禁止关联自身为父级') + raise errors.ForbiddenError(msg=t('error.dept.parent.related_self_not_allowed')) count = await dept_dao.update(db, pk, obj) return count @@ -104,10 +105,10 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: dept = await dept_dao.get_with_relation(db, pk) if dept.users: - raise errors.ConflictError(msg='部门下存在用户,无法删除') + raise errors.ConflictError(msg=t('error.dept.exists_users')) children = await dept_dao.get_children(db, pk) if children: - raise errors.ConflictError(msg='部门下存在子部门,无法删除') + raise errors.ConflictError(msg=t('error.dept.exists_children')) count = await dept_dao.delete(db, pk) for user in dept.users: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') diff --git a/backend/app/admin/service/menu_service.py b/backend/app/admin/service/menu_service.py index 28a0235b1..afd81b90e 100644 --- a/backend/app/admin/service/menu_service.py +++ b/backend/app/admin/service/menu_service.py @@ -8,6 +8,7 @@ from backend.app.admin.model import Menu from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -28,7 +29,7 @@ async def get(*, pk: int) -> Menu: async with async_db_session() as db: menu = await menu_dao.get(db, menu_id=pk) if not menu: - raise errors.NotFoundError(msg='菜单不存在') + raise errors.NotFoundError(msg=t('error.menu.not_found')) return menu @staticmethod @@ -78,11 +79,11 @@ async def create(*, obj: CreateMenuParam) -> None: async with async_db_session.begin() as db: title = await menu_dao.get_by_title(db, obj.title) if title: - raise errors.ConflictError(msg='菜单标题已存在') + raise errors.ConflictError(msg=t('error.menu.exists')) if obj.parent_id: parent_menu = await menu_dao.get(db, obj.parent_id) if not parent_menu: - raise errors.NotFoundError(msg='父级菜单不存在') + raise errors.NotFoundError(msg=t('error.menu.not_found')) await menu_dao.create(db, obj) @staticmethod @@ -97,16 +98,16 @@ async def update(*, pk: int, obj: UpdateMenuParam) -> int: async with async_db_session.begin() as db: menu = await menu_dao.get(db, pk) if not menu: - raise errors.NotFoundError(msg='菜单不存在') + raise errors.NotFoundError(msg=t('error.menu.not_found')) if menu.title != obj.title: if await menu_dao.get_by_title(db, obj.title): - raise errors.ConflictError(msg='菜单标题已存在') + raise errors.ConflictError(msg=t('error.menu.exists')) if obj.parent_id: parent_menu = await menu_dao.get(db, obj.parent_id) if not parent_menu: - raise errors.NotFoundError(msg='父级菜单不存在') + raise errors.NotFoundError(msg=t('error.menu.parent.not_found')) if obj.parent_id == menu.id: - raise errors.ForbiddenError(msg='禁止关联自身为父级') + raise errors.ForbiddenError(msg=t('error.menu.parent.related_self_not_allowed')) count = await menu_dao.update(db, pk, obj) for role in await menu.awaitable_attrs.roles: for user in await role.awaitable_attrs.users: @@ -124,7 +125,7 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: children = await menu_dao.get_children(db, pk) if children: - raise errors.ConflictError(msg='菜单下存在子菜单,无法删除') + raise errors.ConflictError(msg=t('error.menu.exists_children')) menu = await menu_dao.get(db, pk) count = await menu_dao.delete(db, pk) if menu: diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py index 57157dc5c..78008f8ae 100644 --- a/backend/app/admin/service/plugin_service.py +++ b/backend/app/admin/service/plugin_service.py @@ -12,6 +12,7 @@ from backend.common.enums import PluginType, StatusType from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client @@ -54,10 +55,10 @@ async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: """ if type == PluginType.zip: if not file: - raise errors.RequestError(msg='ZIP 压缩包不能为空') + raise errors.RequestError(msg=t('error.plugin.zip_invalid')) return await install_zip_plugin(file) if not repo_url: - raise errors.RequestError(msg='Git 仓库地址不能为空') + raise errors.RequestError(msg=t('error.plugin.git_url_invalid')) return await install_git_plugin(repo_url) @staticmethod @@ -70,7 +71,7 @@ async def uninstall(*, plugin: str): """ plugin_dir = os.path.join(PLUGIN_DIR, plugin) if not os.path.exists(plugin_dir): - raise errors.NotFoundError(msg='插件不存在') + raise errors.NotFoundError(msg=t('error.plugin.not_found')) await uninstall_requirements_async(plugin) bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') shutil.move(plugin_dir, bacup_dir) @@ -87,7 +88,7 @@ async def update_status(*, plugin: str): """ plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}') if not plugin_info: - raise errors.NotFoundError(msg='插件不存在') + raise errors.NotFoundError(msg=t('error.plugin.not_found')) plugin_info = json.loads(plugin_info) # 更新持久缓存状态 @@ -109,7 +110,7 @@ async def build(*, plugin: str) -> io.BytesIO: """ plugin_dir = os.path.join(PLUGIN_DIR, plugin) if not os.path.exists(plugin_dir): - raise errors.NotFoundError(msg='插件不存在') + raise errors.NotFoundError(msg=t('error.plugin.not_found')) bio = io.BytesIO() with zipfile.ZipFile(bio, 'w') as zf: diff --git a/backend/app/admin/service/role_service.py b/backend/app/admin/service/role_service.py index 760b666a4..ba1b7b0b7 100644 --- a/backend/app/admin/service/role_service.py +++ b/backend/app/admin/service/role_service.py @@ -16,6 +16,7 @@ UpdateRoleScopeParam, ) from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -36,7 +37,7 @@ async def get(*, pk: int) -> Role: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) return role @staticmethod @@ -68,7 +69,7 @@ async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) menu_tree = get_tree_data(role.menus) if role.menus else [] return menu_tree @@ -83,7 +84,7 @@ async def get_scopes(*, pk: int) -> list[int]: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) scope_ids = [scope.id for scope in role.scopes] return scope_ids @@ -98,7 +99,7 @@ async def create(*, obj: CreateRoleParam) -> None: async with async_db_session.begin() as db: role = await role_dao.get_by_name(db, obj.name) if role: - raise errors.ConflictError(msg='角色已存在') + raise errors.ConflictError(msg=t('error.role.exists')) await role_dao.create(db, obj) @staticmethod @@ -113,10 +114,10 @@ async def update(*, pk: int, obj: UpdateRoleParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) if role.name != obj.name: if await role_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg='角色已存在') + raise errors.ConflictError(msg=t('error.role.exists')) count = await role_dao.update(db, pk, obj) for user in await role.awaitable_attrs.users: await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') @@ -134,11 +135,11 @@ async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) for menu_id in menu_ids.menus: menu = await menu_dao.get(db, menu_id) if not menu: - raise errors.NotFoundError(msg='菜单不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) count = await role_dao.update_menus(db, pk, menu_ids) for user in await role.awaitable_attrs.users: await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') @@ -156,11 +157,11 @@ async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) for scope_id in scope_ids.scopes: scope = await data_scope_dao.get(db, scope_id) if not scope: - raise errors.NotFoundError(msg='数据范围不存在') + raise errors.NotFoundError(msg=t('error.data_scope.not_found')) count = await role_dao.update_scopes(db, pk, scope_ids) for user in await role.awaitable_attrs.users: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') diff --git a/backend/app/admin/service/user_service.py b/backend/app/admin/service/user_service.py index 666cee27b..044a73d11 100644 --- a/backend/app/admin/service/user_service.py +++ b/backend/app/admin/service/user_service.py @@ -18,6 +18,7 @@ ) from backend.common.enums import UserPermissionType from backend.common.exception import errors +from backend.common.i18n import t from backend.common.response.response_code import CustomErrorCode from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify from backend.core.conf import settings @@ -40,7 +41,7 @@ async def get_userinfo(*, pk: int | None = None, username: str | None = None) -> async with async_db_session() as db: user = await user_dao.get_with_relation(db, user_id=pk, username=username) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) return user @staticmethod @@ -54,7 +55,7 @@ async def get_roles(*, pk: int) -> Sequence[Role]: async with async_db_session() as db: user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) return user.roles @staticmethod @@ -82,15 +83,15 @@ async def create(*, request: Request, obj: AddUserParam) -> None: async with async_db_session.begin() as db: superuser_verify(request) if await user_dao.get_by_username(db, obj.username): - raise errors.ConflictError(msg='用户名已注册') + raise errors.ConflictError(msg=t('error.user.username_exists')) obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}' if not obj.password: - raise errors.RequestError(msg='密码不允许为空') + raise errors.RequestError(msg=t('error.password.required')) if not await dept_dao.get(db, obj.dept_id): - raise errors.NotFoundError(msg='部门不存在') + raise errors.NotFoundError(msg=t('error.dept.not_found')) for role_id in obj.roles: if not await role_dao.get(db, role_id): - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) await user_dao.add(db, obj) @staticmethod @@ -107,13 +108,13 @@ async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int: superuser_verify(request) user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) if obj.username != user.username: if await user_dao.get_by_username(db, obj.username): - raise errors.ConflictError(msg='用户名已注册') + raise errors.ConflictError(msg=t('error.user.username_exists')) for role_id in obj.roles: if not await role_dao.get(db, role_id): - raise errors.NotFoundError(msg='角色不存在') + raise errors.NotFoundError(msg=t('error.role.not_found')) count = await user_dao.update(db, user, obj) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -134,28 +135,28 @@ async def update_permission(*, request: Request, pk: int, type: UserPermissionTy case UserPermissionType.superuser: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) if pk == request.user.id: - raise errors.ForbiddenError(msg='禁止修改自身权限') + raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) count = await user_dao.set_super(db, pk, not user.status) case UserPermissionType.staff: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) if pk == request.user.id: - raise errors.ForbiddenError(msg='禁止修改自身权限') + raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) count = await user_dao.set_staff(db, pk, not user.is_staff) case UserPermissionType.status: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) if pk == request.user.id: - raise errors.ForbiddenError(msg='禁止修改自身权限') + raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1) case UserPermissionType.multi_login: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login new_multi_login = not multi_login count = await user_dao.set_multi_login(db, pk, new_multi_login) @@ -174,7 +175,7 @@ async def update_permission(*, request: Request, pk: int, type: UserPermissionTy key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}' await redis_client.delete_prefix(key_prefix) case _: - raise errors.RequestError(msg='权限类型不存在') + raise errors.RequestError(msg=t('error.perm.type_not_found')) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -193,7 +194,7 @@ async def reset_password(*, request: Request, pk: int, password: str) -> int: superuser_verify(request) user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) count = await user_dao.reset_password(db, user.id, password) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', @@ -218,7 +219,7 @@ async def update_nickname(*, request: Request, nickname: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) count = await user_dao.update_nickname(db, token_payload.id, nickname) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -237,7 +238,7 @@ async def update_avatar(*, request: Request, avatar: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) count = await user_dao.update_avatar(db, token_payload.id, avatar) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -257,10 +258,10 @@ async def update_email(*, request: Request, captcha: str, email: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') if not captcha_code: - raise errors.RequestError(msg='验证码已失效,请重新获取') + raise errors.RequestError(msg=t('error.captcha.expired')) if captcha != captcha_code: raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') @@ -282,11 +283,11 @@ async def update_password(*, request: Request, obj: ResetPasswordParam) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) if not password_verify(obj.old_password, user.password): - raise errors.RequestError(msg='原密码错误') + raise errors.RequestError(msg=t('error.password.old_error')) if obj.new_password != obj.confirm_password: - raise errors.RequestError(msg='密码输入不一致') + raise errors.RequestError(msg=t('error.password.mismatch')) count = await user_dao.reset_password(db, user.id, obj.new_password) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', @@ -308,7 +309,7 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg='用户不存在') + raise errors.NotFoundError(msg=t('error.user.not_found')) count = await user_dao.delete(db, user.id) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', diff --git a/backend/cli.py b/backend/cli.py index cedce1769..0facfd733 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -45,6 +45,8 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None: address=host, port=port, reload=not reload, + # https://github.com/emmett-framework/granian/issues/659 + # reload_filter=PythonFilter(extra_extensions=['.json', '.yaml', '.yml']), reload_filter=PythonFilter, workers=workers or 1, ).serve() diff --git a/backend/common/exception/errors.py b/backend/common/exception/errors.py index 64db8a03b..54ec02b17 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 ec28e77a7..5fa5e7165 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 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,7 +44,7 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation """ errors = [] for error in exc.errors(): - custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) + custom_message = t(f'pydantic.{error["type"]}') if custom_message: ctx = error.get('ctx') if not ctx: @@ -61,13 +59,13 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation errors.append(error) error = errors[0] if error.get('type') == 'json_invalid': - message = 'json解析失败' + message = t('error.json_parse_failed') else: error_input = error.get('input') field = str(error.get('loc')[-1]) error_msg = error.get('msg') - message = f'{field} {error_msg},输入:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg - msg = f'请求参数非法: {message}' + message = f'{field} {error_msg}:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg + msg = f'{t("request_params_invalid", message=message)}' data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None content = { 'code': StandardResponseCode.HTTP_422, diff --git a/backend/common/i18n.py b/backend/common/i18n.py new file mode 100644 index 000000000..1cc40d216 --- /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/i18n/README.md b/backend/common/i18n/README.md deleted file mode 100644 index e3dcd20aa..000000000 --- a/backend/common/i18n/README.md +++ /dev/null @@ -1,254 +0,0 @@ -# 国际化 (i18n) 模块 - -FastAPI 项目的完整国际化解决方案,支持多语言响应消息、验证错误消息和业务逻辑消息的自动翻译。 - -## 🌍 功能特性 - -- **自动语言检测**: 支持从 URL 参数、请求头、Accept-Language 等多种方式检测用户语言偏好 -- **响应码国际化**: 自动翻译所有响应状态码消息 -- **验证消息国际化**: 支持 100+ 条 Pydantic 验证错误消息的翻译 -- **业务消息国际化**: 支持业务逻辑中的错误和成功消息翻译 -- **灵活的翻译管理**: 基于 JSON 文件的翻译资源管理 -- **上下文感知**: 支持参数格式化的动态翻译 - -## 📁 文件结构 - -``` -backend/common/i18n/ -├── __init__.py # 模块导出 -├── manager.py # 国际化管理器 -├── middleware.py # 国际化中间件 -├── locales/ # 翻译文件目录 -│ ├── zh-CN.json # 中文翻译 -│ └── en-US.json # 英文翻译 -├── usage_example.py # 使用示例 -└── README.md # 文档说明 -``` - -## 🚀 快速开始 - -### 1. 启用国际化中间件 - -在 `main.py` 中添加国际化中间件: - -```python -from fastapi import FastAPI -from backend.common.i18n import I18nMiddleware - -app = FastAPI() - -# 添加国际化中间件 -app.add_middleware(I18nMiddleware, default_language='zh-CN') -``` - -### 2. 基本使用 - -```python -from backend.common.i18n.manager import t -from backend.common.response.response_code import CustomResponseCode - -# 使用响应码(自动国际化) -res = CustomResponseCode.HTTP_200 -print(res.msg) # 根据当前语言显示 "请求成功" 或 "Request successful" - -# 手动翻译 -message = t('error.user_not_found') -formatted_msg = t('error.invalid_request_params', message="用户名") -``` - -### 3. 语言切换方式 - -客户端可以通过以下方式指定语言: - -1. **URL 参数**: `GET /api/users?lang=en-US` -2. **请求头**: `X-Language: en-US` -3. **Accept-Language**: `Accept-Language: en-US,en;q=0.9` - -优先级: URL 参数 > X-Language 头 > Accept-Language 头 > 默认语言 - -## 📖 API 文档 - -### I18nManager - -国际化管理器,负责加载和管理翻译资源。 - -```python -from backend.common.i18n.manager import get_i18n_manager, t - -# 获取管理器实例 -i18n = get_i18n_manager() - -# 翻译方法 -def t(key: str, language: str = None, **kwargs) -> str: - """ - 翻译函数 - - Args: - key: 翻译键,支持点号分隔的嵌套键 - language: 目标语言,None 则使用当前语言 - **kwargs: 格式化参数 - - Returns: - 翻译后的文本 - """ -``` - -### I18nMiddleware - -国际化中间件,自动检测和设置请求语言。 - -```python -class I18nMiddleware(BaseHTTPMiddleware): - def __init__(self, app, default_language: str = 'zh-CN'): - """ - Args: - app: FastAPI 应用实例 - default_language: 默认语言 - """ -``` - -## 🔧 翻译文件格式 - -翻译文件使用 JSON 格式,支持嵌套结构: - -```json -{ - "response": { - "success": "请求成功", - "error": "请求错误" - }, - "error": { - "user_not_found": "用户不存在", - "invalid_request_params": "请求参数非法: {message}" - }, - "validation": { - "missing": "字段为必填项", - "string_too_short": "字符串应至少有 {min_length} 个字符" - } -} -``` - -## 💡 使用示例 - -### 在 API 端点中使用 - -```python -from fastapi import APIRouter -from backend.common.i18n.manager import t -from backend.common.response.response_code import CustomResponseCode - -router = APIRouter() - -@router.get("/users") -async def get_users(): - # 响应码会自动国际化 - res = CustomResponseCode.HTTP_200 - return { - "code": res.code, - "msg": res.msg, # 自动翻译 - "data": [] - } - -@router.post("/users") -async def create_user(user_data: dict): - if not user_data.get('username'): - # 手动翻译错误消息 - raise HTTPException( - status_code=400, - detail=t('error.user_not_found') - ) - - return { - "msg": t('success.create_success', name="用户") - } -``` - -### 在服务层中使用 - -```python -from backend.common.exception.errors import CustomError -from backend.common.response.response_code import CustomErrorCode -from backend.common.i18n.manager import t - -class UserService: - def get_user(self, user_id: int): - user = self.user_repository.get(user_id) - if not user: - # 使用预定义的错误码 - raise CustomError(error=CustomErrorCode.USER_NOT_FOUND) - - return user - - def validate_password(self, password: str): - if len(password) < 8: - # 使用动态翻译 - raise ValueError(t('error.password_too_short', min_length=8)) -``` - -### 在 Pydantic 模型中使用 - -```python -from pydantic import BaseModel, Field, validator -from backend.common.i18n.manager import t - -class UserCreateSchema(BaseModel): - username: str = Field(..., description="用户名") - password: str = Field(..., description="密码") - - @validator('username') - def validate_username(cls, v): - if not v or len(v) < 3: - raise ValueError(t('validation.string_too_short', min_length=3)) - return v -``` - -## 🔄 扩展新语言 - -1. 在 `locales/` 目录下创建新的语言文件,如 `ja-JP.json` -2. 复制现有翻译文件结构,翻译所有文本 -3. 在 `I18nManager` 中添加新语言到 `supported_languages` 列表 -4. 在中间件的 `_normalize_language` 方法中添加语言映射 - -## 📝 翻译键命名规范 - -- **响应码**: `response.{type}` (如: `response.success`) -- **错误消息**: `error.{error_type}` (如: `error.user_not_found`) -- **成功消息**: `success.{action}` (如: `success.login_success`) -- **验证消息**: `validation.{validation_type}` (如: `validation.missing`) -- **任务消息**: `task.{task_type}` (如: `task.execute_failed`) - -## ⚠️ 注意事项 - -1. **性能考虑**: 翻译文件在启动时加载到内存,避免频繁的文件 I/O -2. **缓存机制**: 使用 `@lru_cache` 缓存管理器实例 -3. **参数格式化**: 支持 Python 字符串格式化语法,如 `{name}`, `{count:d}` -4. **回退机制**: 如果翻译不存在,会回退到默认语言或返回翻译键 -5. **上下文变量**: 使用 `contextvars` 确保请求级别的语言隔离 - -## 🔍 故障排除 - -### 翻译不生效 -- 检查翻译文件是否存在且格式正确 -- 确认中间件已正确添加 -- 验证翻译键是否正确 - -### 语言检测不准确 -- 检查请求头格式 -- 确认支持的语言列表包含目标语言 -- 验证语言代码规范化映射 - -### 格式化参数错误 -- 确保参数名与翻译文件中的占位符匹配 -- 检查参数类型是否正确 -- 验证格式化语法 - -## 🤝 贡献指南 - -1. 添加新的翻译键时,请同时更新所有语言文件 -2. 保持翻译文件结构的一致性 -3. 为新功能编写相应的使用示例 -4. 更新文档说明 - ---- - -通过这个国际化模块,你的 FastAPI 项目可以轻松支持多语言,为全球用户提供本地化的体验。 \ No newline at end of file diff --git a/backend/common/i18n/__init__.py b/backend/common/i18n/__init__.py deleted file mode 100644 index 16aa37890..000000000 --- a/backend/common/i18n/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -国际化(i18n)模块 - -支持多语言的翻译系统 -""" - -from .manager import I18nManager, get_i18n_manager -from .middleware import I18nMiddleware - -__all__ = ['I18nManager', 'get_i18n_manager', 'I18nMiddleware'] diff --git a/backend/common/i18n/debug_i18n.py b/backend/common/i18n/debug_i18n.py deleted file mode 100644 index 262c82bde..000000000 --- a/backend/common/i18n/debug_i18n.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -国际化调试脚本 - -用于诊断翻译文件加载和路径问题 -""" - -import json -import os - -from backend.common.i18n.manager import get_i18n_manager -from backend.core.path_conf import BASE_PATH - - -def debug_translation_files(): - """调试翻译文件""" - print('🔍 国际化功能调试') - print('=' * 60) - print() - - # 1. 检查BASE_PATH - print(f'📁 BASE_PATH: {BASE_PATH}') - print(f' 存在: {os.path.exists(BASE_PATH)}') - print() - - # 2. 检查翻译文件目录 - translations_dir = os.path.join(BASE_PATH, 'backend', 'common', 'i18n', 'locales') - print(f'📁 翻译文件目录: {translations_dir}') - print(f' 存在: {os.path.exists(translations_dir)}') - - if os.path.exists(translations_dir): - files = os.listdir(translations_dir) - print(f' 文件列表: {files}') - print() - - # 3. 检查具体的翻译文件 - for lang in ['zh-CN', 'en-US']: - lang_file = os.path.join(translations_dir, f'{lang}.json') - print(f'📄 {lang}.json') - print(f' 路径: {lang_file}') - print(f' 存在: {os.path.exists(lang_file)}') - - if os.path.exists(lang_file): - try: - with open(lang_file, 'r', encoding='utf-8') as f: - data = json.load(f) - print(' JSON有效: ✅') - print(f' 顶级键: {list(data.keys())}') - - # 检查一些关键翻译 - if 'response' in data and 'success' in data['response']: - print(f" 示例翻译: response.success = '{data['response']['success']}'") - else: - print(' ❌ 缺少 response.success 翻译') - - except json.JSONDecodeError as e: - print(f' JSON错误: ❌ {e}') - except Exception as e: - print(f' 读取错误: ❌ {e}') - print() - - # 4. 测试翻译管理器 - print('🔧 测试翻译管理器') - print('-' * 40) - - try: - i18n = get_i18n_manager() - print('✅ 管理器创建成功') - print(f' 默认语言: {i18n.default_language}') - print(f' 支持语言: {i18n.supported_languages}') - print(f' 翻译缓存键: {list(i18n.translations.keys())}') - - # 检查翻译缓存内容 - for lang in i18n.supported_languages: - if lang in i18n.translations: - trans_data = i18n.translations[lang] - print(f' {lang} 缓存: {len(trans_data)} 个顶级键') - if trans_data: - print(f' 顶级键: {list(trans_data.keys())}') - - # 测试一个具体的翻译 - if 'response' in trans_data and isinstance(trans_data['response'], dict): - if 'success' in trans_data['response']: - print(f" response.success: '{trans_data['response']['success']}'") - else: - print(' ❌ response.success 不存在') - else: - print(' ❌ response 键不存在或格式错误') - else: - print(f' ❌ {lang} 翻译为空') - else: - print(f' ❌ {lang} 未加载到缓存') - - except Exception as e: - print(f'❌ 管理器创建失败: {e}') - import traceback - - traceback.print_exc() - - print() - - # 5. 测试翻译函数 - print('🧪 测试翻译函数') - print('-' * 40) - - from backend.common.i18n.manager import t - - test_keys = ['response.success', 'response.error', 'error.captcha_error'] - - for key in test_keys: - try: - zh_result = t(key, language='zh-CN') - en_result = t(key, language='en-US') - - print(f'📝 {key}') - print(f" 中文: '{zh_result}' {'✅' if zh_result != key else '❌'}") - print(f" 英文: '{en_result}' {'✅' if en_result != key else '❌'}") - - except Exception as e: - print(f'❌ 翻译 {key} 失败: {e}') - print() - - -def fix_common_issues(): - """尝试修复常见问题""" - print('🔧 尝试修复常见问题') - print('=' * 60) - print() - - translations_dir = os.path.join(BASE_PATH, 'backend', 'common', 'i18n', 'locales') - - # 确保目录存在 - if not os.path.exists(translations_dir): - print(f'📁 创建翻译目录: {translations_dir}') - os.makedirs(translations_dir, exist_ok=True) - - # 检查并重新创建翻译文件(如果有问题) - translations = { - 'zh-CN': { - 'response': {'success': '请求成功', 'error': '请求错误', 'server_error': '服务器内部错误'}, - 'error': {'captcha_error': '验证码错误', 'user_not_found': '用户不存在'}, - 'success': {'login_success': '登录成功'}, - 'validation': {'missing': '字段为必填项'}, - }, - 'en-US': { - 'response': { - 'success': 'Request successful', - 'error': 'Request error', - 'server_error': 'Internal server error', - }, - 'error': {'captcha_error': 'Captcha error', 'user_not_found': 'User not found'}, - 'success': {'login_success': 'Login successful'}, - 'validation': {'missing': 'Field required'}, - }, - } - - for lang, data in translations.items(): - lang_file = os.path.join(translations_dir, f'{lang}.json') - - # 检查文件是否需要重新创建 - needs_recreation = False - - if not os.path.exists(lang_file): - needs_recreation = True - print(f'📄 {lang}.json 不存在,需要创建') - else: - try: - with open(lang_file, 'r', encoding='utf-8') as f: - existing_data = json.load(f) - - # 检查关键键是否存在 - if not existing_data.get('response', {}).get('success') or not existing_data.get('error', {}).get( - 'captcha_error' - ): - needs_recreation = True - print(f'📄 {lang}.json 内容不完整,需要重新创建') - - except Exception as e: - needs_recreation = True - print(f'📄 {lang}.json 有问题,需要重新创建: {e}') - - if needs_recreation: - try: - with open(lang_file, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - print(f'✅ 成功创建/修复 {lang}.json') - except Exception as e: - print(f'❌ 创建 {lang}.json 失败: {e}') - - print() - print('🔄 重新测试翻译功能...') - - # 重新加载翻译管理器 - try: - # 清除缓存 - import backend.common.i18n.manager - - if hasattr(backend.common.i18n.manager, '_i18n_manager'): - backend.common.i18n.manager._i18n_manager = None - - from backend.common.i18n.manager import get_i18n_manager, t - - # 强制重新加载 - i18n = get_i18n_manager() - i18n._load_translations() - - # 测试翻译 - zh_msg = t('response.success', language='zh-CN') - en_msg = t('response.success', language='en-US') - - print('🧪 测试结果:') - print(f" 中文: '{zh_msg}' {'✅' if zh_msg == '请求成功' else '❌'}") - print(f" 英文: '{en_msg}' {'✅' if en_msg == 'Request successful' else '❌'}") - - if zh_msg == '请求成功' and en_msg == 'Request successful': - print('🎉 翻译功能修复成功!') - return True - else: - print('❌ 翻译功能仍有问题') - return False - - except Exception as e: - print(f'❌ 重新测试失败: {e}') - import traceback - - traceback.print_exc() - return False - - -if __name__ == '__main__': - print('🚀 开始国际化问题诊断...') - print() - - # 先进行诊断 - debug_translation_files() - - print() - print('🔧 是否尝试自动修复?(y/n): ', end='') - try: - response = input().lower().strip() - if response in ['y', 'yes', '']: - if fix_common_issues(): - print('\n✅ 问题已修复!现在可以重新运行测试:') - print(' python backend/common/i18n/run_example.py') - else: - print('\n❌ 自动修复失败,请检查错误信息') - else: - print('\n📝 请根据诊断信息手动修复问题') - except (EOFError, KeyboardInterrupt): - print('\n📝 请根据诊断信息手动修复问题') diff --git a/backend/common/i18n/locales/zh-CN.json b/backend/common/i18n/locales/zh-CN.json deleted file mode 100644 index 70e6a1481..000000000 --- a/backend/common/i18n/locales/zh-CN.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "response": { - "success": "请求成功", - "error": "请求错误", - "server_error": "服务器内部错误" - }, - "error": { - "captcha_error": "验证码错误", - "json_parse_failed": "json解析失败", - "invalid_request_params": "请求参数非法: {message}", - "user_not_found": "用户不存在", - "username_or_password_error": "用户名或密码有误", - "user_locked": "用户已被锁定, 请联系统管理员", - "user_forbidden": "用户已被禁止后台管理操作,请联系系统管理员", - "user_no_role": "用户未分配角色,请联系系统管理员", - "user_no_menu": "用户未分配菜单,请联系系统管理员", - "username_already_exists": "用户名已注册", - "password_required": "密码不允许为空", - "old_password_error": "原密码错误", - "password_mismatch": "密码输入不一致", - "refresh_token_expired": "Refresh Token 已过期,请重新登录", - "user_login_elsewhere": "此用户已在异地登录,请重新登录并及时修改密码", - "dept_has_users": "部门下存在用户,无法删除", - "task_params_invalid": "执行失败,任务参数非法", - "upload_file_failed": "上传文件失败", - "plugin_install_failed": "插件安装失败,请稍后重试", - "model_parse_failed": "数据模型列动态解析失败,请联系系统超级管理员", - "permission_check_failed": "权限校验失败,请联系系统管理员" - }, - "success": { - "login_success": "登录成功", - "login_success_oauth2": "登录成功(OAuth2)", - "task_execute_success": "任务 {task_id} 执行成功", - "plugin_install_success": "插件 {plugin_name} 安装成功" - }, - "validation": { - "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} 位数字", - "email_type": "输入应为有效的邮箱地址", - "email_parsing": "输入应为有效的邮箱地址,{error}" - }, - "task": { - "clean_login_log": "清理登录日志", - "execute_failed": "任务 {task_id} 执行失败", - "save_status_failed": "保存任务 {name} 最新状态失败:{error}", - "add_to_db_failed": "添加任务 {name} 到数据库失败" - }, - "websocket": { - "no_auth": "WebSocket 连接失败:无授权", - "auth_failed": "WebSocket 连接失败:授权失败,请检查", - "connection_failed": "WebSocket 连接失败:{error}" - }, - "database": { - "redis_auth_failed": "❌ 数据库 redis 连接认证失败", - "connection_failed": "❌ 数据库链接失败 {error}" - } -} \ No newline at end of file diff --git a/backend/common/i18n/manager.py b/backend/common/i18n/manager.py deleted file mode 100644 index cd2fcd478..000000000 --- a/backend/common/i18n/manager.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -import json -import os - -from functools import lru_cache -from typing import Any, Dict - -from backend.core.path_conf import BASE_PATH - - -class I18nManager: - """国际化管理器""" - - def __init__(self): - self.translations: Dict[str, Dict[str, Any]] = {} - self.default_language = 'zh-CN' - self.supported_languages = ['zh-CN', 'en-US'] - self._load_translations() - - def _load_translations(self): - """加载翻译文件""" - translations_dir = os.path.join(BASE_PATH, 'common', 'i18n', 'locales') - - for lang in self.supported_languages: - lang_file = os.path.join(translations_dir, f'{lang}.json') - if os.path.exists(lang_file): - with open(lang_file, 'r', encoding='utf-8') as f: - self.translations[lang] = json.load(f) - else: - self.translations[lang] = {} - - def t(self, key: str, language: str = None, **kwargs) -> str: - """ - 翻译函数 - - :param key: 翻译键,支持点号分隔的嵌套键,如 'response.success' - :param language: 目标语言,如果不指定则使用默认语言 - :param kwargs: 格式化参数 - :return: 翻译后的文本 - """ - if language is None: - language = self.default_language - - if language not in self.translations: - language = self.default_language - - # 获取翻译文本 - translation = self._get_nested_value(self.translations[language], key) - - if translation is None: - # 如果在指定语言中找不到,尝试默认语言 - if language != self.default_language: - translation = self._get_nested_value(self.translations[self.default_language], key) - - # 如果仍然找不到,返回键名 - if translation is None: - return key - - # 格式化参数 - if kwargs: - try: - return translation.format(**kwargs) - except (KeyError, ValueError): - return translation - - return translation - - @staticmethod - def _get_nested_value(data: Dict[str, Any], key: str) -> str | None: - """获取嵌套字典的值""" - keys = key.split('.') - current = data - - for k in keys: - if isinstance(current, dict) and k in current: - current = current[k] - else: - return None - - return current if isinstance(current, str) else None - - def set_language(self, language: str): - """设置默认语言""" - if language in self.supported_languages: - self.default_language = language - - -# 全局单例实例 -_i18n_manager = None - - -@lru_cache() -def get_i18n_manager() -> I18nManager: - """获取国际化管理器实例""" - global _i18n_manager - if _i18n_manager is None: - _i18n_manager = I18nManager() - return _i18n_manager - - -# 便捷地翻译函数 -def t(key: str, language: str = None, **kwargs) -> str: - """便捷地翻译函数""" - return get_i18n_manager().t(key, language, **kwargs) diff --git a/backend/common/i18n/middleware.py b/backend/common/i18n/middleware.py deleted file mode 100644 index 058a9003e..000000000 --- a/backend/common/i18n/middleware.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from contextvars import ContextVar -from typing import Callable - -from fastapi import Request, Response -from starlette.middleware.base import BaseHTTPMiddleware - -from .manager import get_i18n_manager - -# 上下文变量,用于在请求中存储当前语言 -current_language: ContextVar[str] = ContextVar('current_language', default='zh-CN') - - -class I18nMiddleware(BaseHTTPMiddleware): - """国际化中间件""" - - def __init__(self, app, default_language: str = 'zh-CN'): - super().__init__(app) - self.default_language = default_language - self.i18n_manager = get_i18n_manager() - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - """处理请求,设置语言""" - - # 从多个来源检测语言偏好 - language = self._detect_language(request) - - # 设置上下文变量 - current_language.set(language) - - # 设置管理器的默认语言 - self.i18n_manager.set_language(language) - - # 继续处理请求 - response = await call_next(request) - - # 可选:在响应头中添加语言信息 - response.headers['Content-Language'] = language - - return response - - def _detect_language(self, request: Request) -> str: - """检测请求的语言偏好""" - - # 1. 优先检查 URL 参数 - lang_param = request.query_params.get('lang') - if lang_param and lang_param in self.i18n_manager.supported_languages: - return lang_param - - # 2. 检查请求头中的自定义语言字段 - lang_header = request.headers.get('X-Language') - if lang_header and lang_header in self.i18n_manager.supported_languages: - return lang_header - - # 3. 检查 Accept-Language 头 - accept_language = request.headers.get('Accept-Language', '') - if accept_language: - # 简单解析 Accept-Language,取第一个支持的语言 - languages = [lang.strip().split(';')[0] for lang in accept_language.split(',')] - for lang in languages: - # 处理语言代码的变体,如 'en' -> 'en-US', 'zh' -> 'zh-CN' - normalized_lang = self._normalize_language(lang) - if normalized_lang in self.i18n_manager.supported_languages: - return normalized_lang - - # 4. 返回默认语言 - return self.default_language - - @staticmethod - def _normalize_language(lang: str) -> str: - """规范化语言代码""" - lang = lang.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) - - -def get_current_language() -> str: - """获取当前请求的语言""" - return current_language.get('zh-CN') diff --git a/backend/common/i18n/run_example.py b/backend/common/i18n/run_example.py deleted file mode 100644 index dd7e065a5..000000000 --- a/backend/common/i18n/run_example.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -国际化功能运行示例 - -可以直接运行的国际化演示脚本 -""" - -from backend.common.i18n.manager import get_i18n_manager, t -from backend.common.response.response_code import CustomErrorCode, CustomResponseCode - - -def test_basic_translation(): - """测试基本翻译功能""" - print('🌍 基本翻译测试') - print('-' * 50) - - # 测试不同语言的基本消息 - test_keys = [ - 'response.success', - 'response.error', - 'error.user_not_found', - 'error.captcha_error', - 'success.login_success', - 'validation.missing', - ] - - for key in test_keys: - zh_msg = t(key, language='zh-CN') - en_msg = t(key, language='en-US') - print(f'📝 {key}') - print(f' 🇨🇳 中文: {zh_msg}') - print(f' 🇺🇸 英文: {en_msg}') - print() - - -def test_parameter_formatting(): - """测试参数格式化""" - print('🔧 参数格式化测试') - print('-' * 50) - - # 测试带参数的翻译 - test_cases = [ - {'key': 'error.invalid_request_params', 'params': {'message': '用户名格式错误'}}, - {'key': 'validation.string_too_short', 'params': {'min_length': 8}}, - {'key': 'validation.string_too_long', 'params': {'max_length': 20}}, - ] - - for case in test_cases: - key = case['key'] - params = case['params'] - - zh_msg = t(key, language='zh-CN', **params) - en_msg = t(key, language='en-US', **params) - - print(f'📝 {key} (参数: {params})') - print(f' 🇨🇳 中文: {zh_msg}') - print(f' 🇺🇸 英文: {en_msg}') - print() - - -def test_response_codes(): - """测试响应码自动翻译""" - print('📋 响应码翻译测试') - print('-' * 50) - - i18n = get_i18n_manager() - - # 测试不同响应码 - response_codes = [CustomResponseCode.HTTP_200, CustomResponseCode.HTTP_400, CustomResponseCode.HTTP_500] - - error_codes = [CustomErrorCode.CAPTCHA_ERROR] - - for lang_code, lang_name in [('zh-CN', '中文'), ('en-US', '英文')]: - print(f'🌐 {lang_name} ({lang_code})') - i18n.set_language(lang_code) - - print(' 📊 响应码:') - for code in response_codes: - print(f' {code.code}: {code.msg}') - - print(' ❌ 错误码:') - for code in error_codes: - print(f' {code.code}: {code.msg}') - print() - - -def test_language_detection_simulation(): - """模拟语言检测过程""" - print('🔍 语言检测模拟') - print('-' * 50) - - # 模拟不同的请求场景 - scenarios = [ - { - 'name': 'URL参数优先', - 'url_lang': 'en-US', - 'header_lang': 'zh-CN', - 'accept_lang': 'ja-JP', - 'expected': 'en-US', - }, - {'name': '请求头次优先', 'url_lang': None, 'header_lang': 'en-US', 'accept_lang': 'zh-CN', 'expected': 'en-US'}, - { - 'name': 'Accept-Language兜底', - 'url_lang': None, - 'header_lang': None, - 'accept_lang': 'en-US,en;q=0.9', - 'expected': 'en-US', - }, - {'name': '默认语言', 'url_lang': None, 'header_lang': None, 'accept_lang': None, 'expected': 'zh-CN'}, - ] - - for scenario in scenarios: - print(f'📌 场景: {scenario["name"]}') - print(f' URL参数: {scenario["url_lang"]}') - print(f' X-Language: {scenario["header_lang"]}') - print(f' Accept-Language: {scenario["accept_lang"]}') - print(f' 预期语言: {scenario["expected"]}') - print(f' 结果消息: {t("response.success", language=scenario["expected"])}') - print() - - -def test_error_scenarios(): - """测试错误场景""" - print('⚠️ 错误场景测试') - print('-' * 50) - - # 测试不存在的翻译键 - print('🔍 测试不存在的翻译键') - nonexistent_key = 'nonexistent.test.key' - result = t(nonexistent_key) - print(f' 键: {nonexistent_key}') - print(f' 结果: {result} (应该返回键名本身)') - print() - - # 测试不支持的语言 - print('🔍 测试不支持的语言') - unsupported_lang = 'ja-JP' - result = t('response.success', language=unsupported_lang) - print(f' 语言: {unsupported_lang}') - print(f' 结果: {result} (应该回退到默认语言)') - print() - - # 测试参数格式化错误 - print('🔍 测试参数格式化错误') - try: - result = t('validation.string_too_short', min_length_wrong='wrong') - print(f' 结果: {result} (参数名错误,应该正常处理)') - except Exception as e: - print(f' 异常: {e}') - print() - - -def test_performance(): - """简单的性能测试""" - print('⚡ 性能测试') - print('-' * 50) - - import time - - # 测试翻译性能 - start_time = time.time() - iterations = 1000 - - for _ in range(iterations): - t('response.success') - t('error.user_not_found') - t('validation.missing') - - end_time = time.time() - total_time = end_time - start_time - avg_time = (total_time / iterations) * 1000 # 毫秒 - - print(f'📊 执行 {iterations} 次翻译') - print(f' 总时间: {total_time:.4f} 秒') - print(f' 平均时间: {avg_time:.4f} 毫秒/次') - print(f' TPS: {iterations / total_time:.0f} 次/秒') - print() - - -def main(): - """主函数""" - print('🎉 FastAPI 国际化功能演示') - print('=' * 60) - print() - - try: - # 运行各种测试 - test_basic_translation() - test_parameter_formatting() - test_response_codes() - test_language_detection_simulation() - test_error_scenarios() - test_performance() - - print('✅ 所有测试完成!国际化功能正常工作。') - print() - print('📝 如需运行 FastAPI 服务器测试,请使用:') - print(' python backend/common/i18n/usage_example.py') - print() - print('📝 如需运行完整测试套件,请使用:') - print(' pytest backend/common/i18n/test_i18n.py -v') - - except Exception as e: - print(f'❌ 测试过程中出现错误: {e}') - import traceback - - traceback.print_exc() - - -if __name__ == '__main__': - main() diff --git a/backend/common/i18n/test_i18n.py b/backend/common/i18n/test_i18n.py deleted file mode 100644 index bbbe29f5f..000000000 --- a/backend/common/i18n/test_i18n.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -国际化功能测试 - -专门用于pytest测试的国际化功能验证 -""" - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from backend.common.i18n import I18nMiddleware, get_i18n_manager -from backend.common.i18n.manager import t -from backend.common.response.response_code import CustomErrorCode, CustomResponseCode - - -class TestI18nManager: - """测试国际化管理器""" - - def setup_method(self): - """测试前准备""" - self.i18n = get_i18n_manager() - - def test_basic_translation(self): - """测试基本翻译功能""" - # 测试中文翻译 - msg_zh = t('response.success', language='zh-CN') - assert msg_zh == '请求成功' - - # 测试英文翻译 - msg_en = t('response.success', language='en-US') - assert msg_en == 'Request successful' - - def test_parameter_formatting(self): - """测试参数格式化""" - # 测试带参数的翻译 - msg_zh = t('error.invalid_request_params', language='zh-CN', message='用户名') - assert '用户名' in msg_zh - - msg_en = t('error.invalid_request_params', language='en-US', message='username') - assert 'username' in msg_en - - def test_nested_keys(self): - """测试嵌套键翻译""" - # 测试验证消息 - msg_zh = t('validation.missing', language='zh-CN') - assert msg_zh == '字段为必填项' - - msg_en = t('validation.missing', language='en-US') - assert msg_en == 'Field required' - - def test_fallback_mechanism(self): - """测试回退机制""" - # 测试不存在的键 - result = t('non_existent.key') - assert result == 'non_existent.key' # 应该返回键名本身 - - def test_supported_languages(self): - """测试支持的语言""" - assert 'zh-CN' in self.i18n.supported_languages - assert 'en-US' in self.i18n.supported_languages - - -class TestResponseCodeI18n: - """测试响应码国际化""" - - def test_response_code_translation(self): - """测试响应码自动翻译""" - # 设置不同语言并测试 - i18n = get_i18n_manager() - - # 测试中文 - i18n.set_language('zh-CN') - res = CustomResponseCode.HTTP_200 - assert res.msg == '请求成功' - - # 测试英文 - i18n.set_language('en-US') - res = CustomResponseCode.HTTP_200 - assert res.msg == 'Request successful' - - def test_error_code_translation(self): - """测试错误码翻译""" - i18n = get_i18n_manager() - - # 测试中文 - i18n.set_language('zh-CN') - error = CustomErrorCode.CAPTCHA_ERROR - assert error.msg == '验证码错误' - - # 测试英文 - i18n.set_language('en-US') - error = CustomErrorCode.CAPTCHA_ERROR - assert error.msg == 'Captcha error' - - -class TestI18nMiddleware: - """测试国际化中间件""" - - def setup_method(self): - """设置测试应用""" - self.app = FastAPI() - self.app.add_middleware(I18nMiddleware, default_language='zh-CN') - - @self.app.get('/test') - async def test_endpoint(): - res = CustomResponseCode.HTTP_200 - return {'code': res.code, 'msg': res.msg} - - self.client = TestClient(self.app) - - def test_default_language(self): - """测试默认语言""" - response = self.client.get('/test') - assert response.status_code == 200 - data = response.json() - assert data['msg'] == '请求成功' # 默认中文 - - def test_url_parameter_language(self): - """测试URL参数语言切换""" - response = self.client.get('/test?lang=en-US') - assert response.status_code == 200 - data = response.json() - assert data['msg'] == 'Request successful' # 英文 - - def test_header_language(self): - """测试请求头语言切换""" - headers = {'X-Language': 'en-US'} - response = self.client.get('/test', headers=headers) - assert response.status_code == 200 - data = response.json() - assert data['msg'] == 'Request successful' # 英文 - - def test_accept_language_header(self): - """测试Accept-Language头""" - headers = {'Accept-Language': 'en-US,en;q=0.9'} - response = self.client.get('/test', headers=headers) - assert response.status_code == 200 - data = response.json() - assert data['msg'] == 'Request successful' # 英文 - - def test_language_priority(self): - """测试语言优先级""" - # URL参数应该优先于请求头 - headers = {'X-Language': 'zh-CN', 'Accept-Language': 'zh-CN'} - response = self.client.get('/test?lang=en-US', headers=headers) - assert response.status_code == 200 - data = response.json() - assert data['msg'] == 'Request successful' # URL参数的英文优先 - - def test_unsupported_language_fallback(self): - """测试不支持语言的回退""" - headers = {'X-Language': 'ja-JP'} # 不支持的日语 - response = self.client.get('/test', headers=headers) - assert response.status_code == 200 - data = response.json() - assert data['msg'] == '请求成功' # 回退到默认中文 - - def test_response_language_header(self): - """测试响应语言头""" - headers = {'X-Language': 'en-US'} - response = self.client.get('/test', headers=headers) - assert response.headers.get('Content-Language') == 'en-US' - - -class TestValidationI18n: - """测试验证消息国际化""" - - def test_validation_messages(self): - """测试验证消息翻译""" - # 测试几个常用的验证消息 - validation_keys = [ - 'validation.missing', - 'validation.string_too_short', - 'validation.string_too_long', - 'validation.int_type', - 'validation.email_type', - ] - - for key in validation_keys: - # 测试中文 - msg_zh = t(key, language='zh-CN') - assert msg_zh != key # 应该有翻译 - assert isinstance(msg_zh, str) - assert len(msg_zh) > 0 - - # 测试英文 - msg_en = t(key, language='en-US') - assert msg_en != key # 应该有翻译 - assert isinstance(msg_en, str) - assert len(msg_en) > 0 - - # 中英文不应该相同 - assert msg_zh != msg_en - - -if __name__ == '__main__': - # 如果直接运行此文件,执行基本测试 - print('🧪 运行国际化功能基础测试...') - - # 测试基本翻译 - print('✅ 测试基本翻译:') - print(f' 中文: {t("response.success", language="zh-CN")}') - print(f' 英文: {t("response.success", language="en-US")}') - - # 测试参数化翻译 - print('✅ 测试参数化翻译:') - print(f' 中文: {t("error.invalid_request_params", language="zh-CN", message="用户名")}') - print(f' 英文: {t("error.invalid_request_params", language="en-US", message="username")}') - - # 测试响应码 - print('✅ 测试响应码翻译:') - i18n = get_i18n_manager() - i18n.set_language('zh-CN') - res_zh = CustomResponseCode.HTTP_200 - print(f' 中文: {res_zh.msg}') - - i18n.set_language('en-US') - res_en = CustomResponseCode.HTTP_200 - print(f' 英文: {res_en.msg}') - - print('🎉 基础测试完成!所有功能正常工作。') - print('\n📝 运行完整测试套件:') - print(' pytest backend/common/i18n/test_i18n.py -v') diff --git a/backend/common/i18n/usage_example.py b/backend/common/i18n/usage_example.py deleted file mode 100644 index 45ce2c8f2..000000000 --- a/backend/common/i18n/usage_example.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -国际化使用示例 - -展示如何在 FastAPI 项目中使用 i18n 功能 -""" - -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel, Field, field_validator - -from backend.common.exception.errors import CustomError -from backend.common.i18n import I18nMiddleware -from backend.common.i18n.manager import t -from backend.common.response.response_code import CustomErrorCode, CustomResponseCode - -app = FastAPI() - -# 添加国际化中间件 -app.add_middleware(I18nMiddleware, default_language='zh-CN') - - -@app.get('/api/test') -async def test_endpoint(): - """测试端点 - 展示基本的国际化响应""" - # 使用响应码(会自动国际化) - res = CustomResponseCode.HTTP_200 - return { - 'code': res.code, - 'msg': res.msg, # 会根据请求语言自动翻译 - 'data': {'test': 'success'}, - } - - -@app.get('/api/error') -async def error_endpoint(): - """错误端点 - 展示错误消息的国际化""" - # 抛出自定义错误(使用翻译键) - raise CustomError(error=CustomErrorCode.CAPTCHA_ERROR) - - -@app.get('/api/manual') -async def manual_translation(): - """手动翻译示例""" - # 手动使用翻译函数 - success_msg = t('success.login_success') - error_msg = t('error.user_not_found') - - return { - 'success': success_msg, - 'error': error_msg, - 'formatted': t('error.invalid_request_params', message='test parameter'), - } - - -@app.get('/api/lang/{lang}') -async def change_language(lang: str): - """切换语言示例""" - # 手动指定语言进行翻译 - messages = { - 'zh': t('response.success', language='zh-CN'), - 'en': t('response.success', language='en-US'), - } - - return {'current_lang': lang, 'messages': messages} - - -# 如何在业务逻辑中使用 -class UserService: - """用户服务示例""" - - def validate_user(self, username: str) -> dict: - if not username: - # 使用翻译键抛出错误 - raise HTTPException(status_code=400, detail=t('error.user_not_found')) - - return {'msg': t('success.login_success'), 'user': {'username': username}} - - -# 在 Pydantic 模型中使用(需要动态获取) -class UserModel(BaseModel): - username: str = Field(..., description='用户名') - - @field_validator('username') - @classmethod - def validate_username(cls, v): - if not v: - # 在验证器中使用翻译 - raise ValueError(t('validation.missing')) - return v - - -def test_basic_functionality(): - """测试基本功能(非异步)""" - from backend.common.i18n.manager import get_i18n_manager, t - - print('🧪 测试国际化基本功能') - print('-' * 40) - - # 测试基本翻译 - zh_msg = t('response.success', language='zh-CN') - en_msg = t('response.success', language='en-US') - - print('✅ 基本翻译测试:') - print(f' 中文: {zh_msg}') - print(f' 英文: {en_msg}') - - # 测试响应码翻译 - i18n = get_i18n_manager() - - i18n.set_language('zh-CN') - res_zh = CustomResponseCode.HTTP_200 - - i18n.set_language('en-US') - res_en = CustomResponseCode.HTTP_200 - - print('✅ 响应码翻译测试:') - print(f' 中文: {res_zh.msg}') - print(f' 英文: {res_en.msg}') - - print('🎉 基本功能测试完成!') - return True - - -if __name__ == '__main__': - import sys - - if len(sys.argv) > 1 and sys.argv[1] == 'server': - # 运行FastAPI服务器 - import uvicorn - - print('🚀 启动国际化测试服务器...') - print('📝 测试不同语言:') - print(' curl http://localhost:8000/api/test') - print(' curl -H "X-Language: en-US" http://localhost:8000/api/test') - print(' curl "http://localhost:8000/api/test?lang=en-US"') - print() - uvicorn.run(app, host='0.0.0.0', port=8000) - else: - # 运行基本功能测试 - test_basic_functionality() - print() - print('📝 运行服务器测试:') - print(' python backend/common/i18n/usage_example.py server') - print() - print('📝 运行完整测试:') - print(' python backend/common/i18n/run_example.py') - print(' pytest backend/common/i18n/test_i18n.py -v') diff --git a/backend/common/response/response_code.py b/backend/common/response/response_code.py index 9b3a7152a..f4e84d36b 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_500 = (500, '服务器内部错误') + HTTP_200 = (200, 'response.success') + HTTP_400 = (400, 'response.error') + HTTP_500 = (500, 'response.server_error') 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 924d71e6b..37536e44d 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 7fdb8e581..9d40bcae4 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 8b9808b2e..d05fc683b 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 bc96457c4..5a38e48d7 100644 --- a/backend/core/registrar.py +++ b/backend/core/registrar.py @@ -16,13 +16,13 @@ from starlette.staticfiles import StaticFiles from backend.common.exception.exception_handler import register_exception -from backend.common.i18n.middleware import I18nMiddleware from backend.common.log import set_custom_logfile, setup_logging from backend.core.conf import settings from backend.core.path_conf import STATIC_DIR, UPLOAD_DIR 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 @@ -43,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: @@ -129,10 +132,7 @@ def register_middleware(app: FastAPI) -> None: ) # I18n - app.add_middleware(I18nMiddleware, default_language='zh-CN') - - # Access log - app.add_middleware(AccessMiddleware) + app.add_middleware(I18nMiddleware) # CORS if settings.MIDDLEWARE_CORS: @@ -147,6 +147,9 @@ def register_middleware(app: FastAPI) -> None: expose_headers=settings.CORS_EXPOSE_HEADERS, ) + # Access log + app.add_middleware(AccessMiddleware) + # Trace ID app.add_middleware(CorrelationIdMiddleware, validator=False) diff --git a/backend/common/i18n/locales/en-US.json b/backend/locale/en-US.json similarity index 83% rename from backend/common/i18n/locales/en-US.json rename to backend/locale/en-US.json index d3b7edd69..b57eafbe7 100644 --- a/backend/common/i18n/locales/en-US.json +++ b/backend/locale/en-US.json @@ -6,6 +6,7 @@ }, "error": { "captcha_error": "Captcha error", + "captcha_expired": "The captcha has expired, please get it again", "json_parse_failed": "JSON parsing failed", "invalid_request_params": "Invalid request parameters: {message}", "user_not_found": "User not found", @@ -25,7 +26,30 @@ "upload_file_failed": "File upload failed", "plugin_install_failed": "Plugin installation failed, please try again later", "model_parse_failed": "Data model column dynamic parsing failed, please contact system super administrator", - "permission_check_failed": "Permission check failed, please contact system administrator" + "permission_check_failed": "Permission check failed, please contact system administrator", + "language_not_found": "Current language pack is not initialized or does not exist", + "email_config_missing": "Missing email dynamic configuration, please check system parameter configuration - email configuration", + "crontab_invalid": "Crontab expression is invalid", + "snowflake_cluster_id_invalid": "Cluster ID must be between 0-{SnowflakeConfig.MAX_DATACENTER_ID}", + "snowflake_node_id_invalid": "Node ID must be between 0-{SnowflakeConfig.MAX_WORKER_ID}", + "celery_worker_unavailable": "Celery Worker is temporarily unavailable, please try again later", + "token_invalid": "Token is invalid", + "token_expired": "Token has expired", + "rate_limit_exceeded": "Request too frequent, please try again later", + "file_type_unknown": "Unknown file type", + "image_format_not_supported": "This image format is not supported", + "image_size_exceeded": "Image exceeds maximum limit, please select again", + "video_format_not_supported": "This video format is not supported", + "video_size_exceeded": "Video exceeds maximum limit, please select again", + "plugin_zip_invalid": "Plugin zip package format is invalid", + "plugin_zip_content_invalid": "Plugin zip package content is invalid", + "plugin_zip_missing_files": "Required files are missing in the plugin zip package", + "plugin_already_installed": "This plugin is already installed", + "git_url_invalid": "Git repository URL format is invalid", + "sql_file_not_found": "SQL script file does not exist", + "sql_illegal_operation": "Illegal operations found in SQL script file, only SELECT and INSERT are allowed", + "demo_mode_forbidden": "This operation is forbidden in demo mode" + }, "success": { "login_success": "Login successful", @@ -147,4 +171,4 @@ "redis_auth_failed": "❌ Database redis connection authentication failed", "connection_failed": "❌ Database connection failed {error}" } -} \ No newline at end of file +} diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml new file mode 100644 index 000000000..8f44bcfde --- /dev/null +++ b/backend/locale/zh-CN.yml @@ -0,0 +1,205 @@ +database: + connection_failed: '❌ 数据库连接失败 {error}' + redis: + auth_failed: ❌ Redis 连接认证失败 +error: + captcha: + error: 验证码错误 + expired: 验证码已过期,请重新获取 + celery_worker_unavailable: Celery Worker 暂不可用,请稍后重试 + crontab_invalid: Crontab 表达式非法 + demo_mode_forbidden: 演示环境下禁止执行此操作 + data_rule: + not_found: 数据规则不存在 + exists: 数据规则已存在 + available_models: 数据规则可用模型不存在 + data_scope: + not_found: 数据范围不存在 + exists: 数据范围已存在 + dept: + not_found: 部门不存在 + parent: + not_found: 父部门不存在 + related_self_not_allowed: 禁止关联自身为父级 + exists: 部门已存在 + exists_children: 部门存在子部门,无法删除 + exists_users: 部门存在用户,无法删除 + menu: + not_found: 菜单不存在 + exists: 菜单已存在 + parent: + not_found: 父菜单不存在 + related_self_not_allowed: 禁止关联自身为父级 + exists_children: 菜单存在子菜单,无法删除 + role: + not_found: 角色不存在 + exists: 角色已存在 + email_config_missing: 缺少邮件动态配置,请检查系统参数配置-邮件配置 + file_type_unknown: 未知的文件类型 + image_format_not_supported: 此图片格式暂不支持 + image_size_exceeded: 图片超出最大限制,请重新选择 + json_parse_failed: json 解析失败 + language_not_found: Current language pack is not initialized or does not exist + limit_reached: 请求过于频繁,请稍后重试 + model_parse_failed: 数据模型列动态解析失败,请联系系统超级管理员 + password: + required: 密码不允许为空 + mismatch: 密码输入不一致 + old_error: 原密码错误 + permission_check_failed: 权限校验失败,请联系系统管理员 + plugin: + not_found: 插件不存在 + zip_invalid: 插件压缩包格式非法 + git_url_invalid: Git 仓库地址格式非法 + install_failed: 插件安装失败,请稍后重试 + zip_content_invalid: 插件压缩包内容非法 + zip_missing_files: 插件压缩包内缺少必要文件 + already_installed: 此插件已安装 + rate_limit_exceeded: 请求过于频繁,请稍后重试 + refresh_token_expired: Refresh Token 已过期,请重新登录 + request_params_invalid: '请求参数非法: {message}' + snowflake_cluster_id_invalid: '集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间' + snowflake_node_id_invalid: '节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间' + sql_file_not_found: SQL 脚本文件不存在 + sql_illegal_operation: SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT + token_expired: Token 已过期 + token_invalid: Token 无效 + upload_file_failed: 上传文件失败 + user: + not_found: 用户不存在 + locked: '用户已被锁定, 请联系统管理员' + forbidden: 用户已被禁止后台管理操作,请联系系统管理员 + no_role: 用户未分配角色,请联系系统管理员 + no_menu: 用户未分配菜单,请联系系统管理员 + login_elsewhere: 此用户已在异地登录,请重新登录并及时修改密码 + perm: + type_not_found: 权限类型不存在 + edit_self_not_allowed: 禁止修改自身权限 + username_or_password_error: 用户名或密码有误 + username_exists: 用户名已存在 + video_format_not_supported: 此视频格式暂不支持 + video_size_exceeded: 视频超出最大限制,请重新选择 +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: + success: 请求成功 + error: 请求错误 + server_error: 服务器内部错误 +success: + login: + success: 登录成功 + oauth2_success: 登录成功(OAuth2) + plugin: + install_success: '插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' + uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' + task: + execute_success: '任务 {task_id} 执行成功' +task: + add_to_db_failed: '添加任务 {name} 到数据库失败' + clean_login_log: 清理登录日志 + execute_failed: '任务 {task_id} 执行失败' + params_invalid: 执行失败,任务参数非法 + save_status_failed: '保存任务 {name} 最新状态失败:{error}' +websocket: + auth_failed: WebSocket 连接失败:授权失败,请检查 + connection_failed: 'WebSocket 连接失败:{error}' + no_auth: WebSocket 连接失败:无授权 diff --git a/backend/middleware/i18n_middleware.py b/backend/middleware/i18n_middleware.py new file mode 100644 index 000000000..cd9edf6c0 --- /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/tools.py b/backend/plugin/tools.py index 5d634171d..11f8bb3be 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)( diff --git a/backend/utils/health_check.py b/backend/utils/health_check.py index 8187eebe0..473206e8c 100644 --- a/backend/utils/health_check.py +++ b/backend/utils/health_check.py @@ -11,6 +11,7 @@ from fastapi.routing import APIRoute 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 StandardResponseCode @@ -41,7 +42,7 @@ async def http_limit_callback(request: Request, response: Response, expire: int) """ expires = ceil(expire / 1000) raise errors.HTTPError( - code=StandardResponseCode.HTTP_429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)} + code=StandardResponseCode.HTTP_429, msg=t('error.limit_reached'), headers={'Retry-After': str(expires)} ) From ca57717c65b872fb2ce17c34bd25b11f513222e3 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 21:18:24 +0800 Subject: [PATCH 03/11] Update the locale in the code --- backend/app/task/api/v1/control.py | 5 +- backend/app/task/model/scheduler.py | 3 +- backend/app/task/service/result_service.py | 3 +- backend/app/task/service/scheduler_service.py | 19 ++-- backend/app/task/utils/schedulers.py | 5 +- backend/app/task/utils/tzcrontab.py | 5 +- backend/common/security/jwt.py | 25 +++--- backend/common/security/permission.py | 5 +- backend/common/security/rbac.py | 9 +- backend/locale/zh-CN.yml | 87 ++++++++++++------- .../service/business_service.py | 5 +- .../code_generator/service/code_service.py | 15 ++-- .../code_generator/service/column_service.py | 7 +- .../plugin/config/service/config_service.py | 9 +- .../plugin/dict/service/dict_data_service.py | 13 +-- .../plugin/dict/service/dict_type_service.py | 9 +- backend/plugin/email/utils/send.py | 3 +- .../plugin/notice/service/notice_service.py | 5 +- .../plugin/oauth2/service/oauth2_service.py | 3 +- backend/plugin/tools.py | 3 +- backend/utils/demo_site.py | 3 +- backend/utils/file_ops.py | 31 +++---- backend/utils/import_parse.py | 3 +- backend/utils/snowflake.py | 9 +- 24 files changed, 167 insertions(+), 117 deletions(-) diff --git a/backend/app/task/api/v1/control.py b/backend/app/task/api/v1/control.py index 7bcb30c07..572fe40a6 100644 --- a/backend/app/task/api/v1/control.py +++ b/backend/app/task/api/v1/control.py @@ -8,6 +8,7 @@ from backend.app.task import celery_app from backend.app.task.schema.control import TaskRegisteredDetail from backend.common.exception import errors +from backend.common.i18n import t from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission @@ -21,7 +22,7 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail inspector = celery_app.control.inspect(timeout=0.5) registered = await run_in_threadpool(inspector.registered) if not registered: - raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') + raise errors.ServerError(msg=t('error.celery_worker_unavailable')) task_registered = [] celery_app_tasks = celery_app.tasks for _, tasks in registered.items(): @@ -46,6 +47,6 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail async def revoke_task(task_id: Annotated[str, Path(description='任务 UUID')]) -> ResponseModel: workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5) if not workers: - raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') + raise errors.ServerError(msg=t('error.celery_worker_unavailable')) celery_app.control.revoke(task_id) return response_base.success() diff --git a/backend/app/task/model/scheduler.py b/backend/app/task/model/scheduler.py index 45488638a..428001545 100644 --- a/backend/app/task/model/scheduler.py +++ b/backend/app/task/model/scheduler.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import Mapped, mapped_column from backend.common.exception import errors +from backend.common.i18n import t from backend.common.model import Base, id_key from backend.core.conf import settings from backend.database.redis import redis_client @@ -61,7 +62,7 @@ class TaskScheduler(Base): @staticmethod def before_insert_or_update(mapper, connection, target): if target.expire_seconds is not None and target.expire_time: - raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个') + raise errors.ConflictError(msg=t('error.expires_and_expire_seconds_conflict')) @classmethod def changed(cls, mapper, connection, target): diff --git a/backend/app/task/service/result_service.py b/backend/app/task/service/result_service.py index bb7391370..e09dd07e5 100644 --- a/backend/app/task/service/result_service.py +++ b/backend/app/task/service/result_service.py @@ -6,6 +6,7 @@ from backend.app.task.model.result import TaskResult from backend.app.task.schema.result import DeleteTaskResultParam from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session @@ -21,7 +22,7 @@ async def get(*, pk: int) -> TaskResult: async with async_db_session() as db: result = await task_result_dao.get(db, pk) if not result: - raise errors.NotFoundError(msg='任务结果不存在') + raise errors.NotFoundError(msg=t('error.task.result_not_found')) return result @staticmethod diff --git a/backend/app/task/service/scheduler_service.py b/backend/app/task/service/scheduler_service.py index ea3357ee7..ebe73dc71 100644 --- a/backend/app/task/service/scheduler_service.py +++ b/backend/app/task/service/scheduler_service.py @@ -14,6 +14,7 @@ from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam from backend.app.task.utils.tzcrontab import crontab_verify from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session @@ -31,7 +32,7 @@ async def get(*, pk) -> TaskScheduler | None: async with async_db_session() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg='任务调度不存在') + raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) return task_scheduler @staticmethod @@ -63,7 +64,7 @@ async def create(*, obj: CreateTaskSchedulerParam) -> None: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get_by_name(db, obj.name) if task_scheduler: - raise errors.ConflictError(msg='任务调度已存在') + raise errors.ConflictError(msg=t('error.task.scheduler_exists')) if obj.type == TaskSchedulerType.CRONTAB: crontab_verify(obj.crontab) await task_scheduler_dao.create(db, obj) @@ -80,10 +81,10 @@ async def update(*, pk: int, obj: UpdateTaskSchedulerParam) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg='任务调度不存在') + raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) if task_scheduler.name != obj.name: if await task_scheduler_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg='任务调度已存在') + raise errors.ConflictError(msg=t('error.task.scheduler_exists')) if task_scheduler.type == TaskSchedulerType.CRONTAB: crontab_verify(obj.crontab) count = await task_scheduler_dao.update(db, pk, obj) @@ -100,7 +101,7 @@ async def update_status(*, pk: int) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg='任务调度不存在') + raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) count = await task_scheduler_dao.set_status(db, pk, not task_scheduler.enabled) return count @@ -115,7 +116,7 @@ async def delete(*, pk) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg='任务调度不存在') + raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) count = await task_scheduler_dao.delete(db, pk) return count @@ -130,15 +131,15 @@ async def execute(*, pk: int) -> None: async with async_db_session() as db: workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5) if not workers: - raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') + raise errors.ServerError(msg=t('error.celery_worker_unavailable')) task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg='任务调度不存在') + raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) try: args = json.loads(task_scheduler.args) if task_scheduler.args else None kwargs = json.loads(task_scheduler.kwargs) if task_scheduler.kwargs else None except (TypeError, json.JSONDecodeError): - raise errors.RequestError(msg='执行失败,任务参数非法') + raise errors.RequestError(msg=t('error.task.params_invalid')) else: celery_app.send_task(name=task_scheduler.task, args=args, kwargs=kwargs) diff --git a/backend/app/task/utils/schedulers.py b/backend/app/task/utils/schedulers.py index 2bb410df9..84e613feb 100644 --- a/backend/app/task/utils/schedulers.py +++ b/backend/app/task/utils/schedulers.py @@ -20,6 +20,7 @@ from backend.app.task.schema.scheduler import CreateTaskSchedulerParam from backend.app.task.utils.tzcrontab import TzAwareCrontab, crontab_verify from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -91,7 +92,7 @@ def __init__(self, model: TaskScheduler, app=None): month_of_year=crontab_split[4], ) else: - raise errors.NotFoundError(msg=f'{self.name} 计划为空!') + raise errors.NotFoundError(msg=t('error.task.schedule_not_found', name=self.name)) # logger.debug('Schedule: {}'.format(self.schedule)) except Exception as e: logger.error(f'禁用计划为空的任务 {self.name},详情:{e}') @@ -235,7 +236,7 @@ async def to_model_schedule(name: str, task: str, schedule: schedules.schedule | if not obj: obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump()) else: - raise errors.NotFoundError(msg=f'暂不支持的计划类型:{schedule}') + raise errors.NotFoundError(msg=t('error.task.scheduler_type_invalid', type=schedule)) return obj diff --git a/backend/app/task/utils/tzcrontab.py b/backend/app/task/utils/tzcrontab.py index 2260efc17..2297f9439 100644 --- a/backend/app/task/utils/tzcrontab.py +++ b/backend/app/task/utils/tzcrontab.py @@ -6,6 +6,7 @@ from celery.schedules import ParseException, crontab_parser from backend.common.exception import errors +from backend.common.i18n import t from backend.utils.timezone import timezone @@ -61,7 +62,7 @@ def crontab_verify(crontab: str) -> None: """ crontab_split = crontab.split(' ') if len(crontab_split) != 5: - raise errors.RequestError(msg='Crontab 表达式非法') + raise errors.RequestError(msg=t('error.crontab_invalid')) try: crontab_parser(60, 0).parse(crontab_split[0]) # minute @@ -70,4 +71,4 @@ def crontab_verify(crontab: str) -> None: crontab_parser(31, 1).parse(crontab_split[3]) # day_of_month crontab_parser(12, 1).parse(crontab_split[4]) # month_of_year except ParseException: - raise errors.RequestError(msg='Crontab 表达式非法') + raise errors.RequestError(msg=t('error.crontab_invalid')) diff --git a/backend/common/security/jwt.py b/backend/common/security/jwt.py index 296f09100..eec3e8fd5 100644 --- a/backend/common/security/jwt.py +++ b/backend/common/security/jwt.py @@ -21,6 +21,7 @@ from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload from backend.common.exception import errors from backend.common.exception.errors import TokenError +from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -100,11 +101,11 @@ def jwt_decode(token: str) -> TokenPayload: user_id = payload.get('sub') expire = payload.get('exp') if not session_uuid or not user_id or not expire: - raise errors.TokenError(msg='Token 无效') + raise errors.TokenError(msg=t('error.token.invalid')) except ExpiredSignatureError: - raise errors.TokenError(msg='Token 已过期') + raise errors.TokenError(msg=t('error.token.expired')) except (JWTError, Exception): - raise errors.TokenError(msg='Token 无效') + raise errors.TokenError(msg=t('error.token.invalid')) return TokenPayload( id=int(user_id), session_uuid=session_uuid, expire_time=timezone.from_datetime(timezone.to_utc(expire)) ) @@ -189,7 +190,7 @@ async def create_new_token( """ redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') if not redis_refresh_token or redis_refresh_token != refresh_token: - raise errors.TokenError(msg='Refresh Token 已过期,请重新登录') + raise errors.TokenError(msg=t('error.refresh_token_expired')) await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}') @@ -227,7 +228,7 @@ def get_token(request: Request) -> str: authorization = request.headers.get('Authorization') scheme, token = get_authorization_scheme_param(authorization) if not authorization or scheme.lower() != 'bearer': - raise errors.TokenError(msg='Token 无效') + raise errors.TokenError(msg=t('error.token.invalid')) return token @@ -243,18 +244,18 @@ async def get_current_user(db: AsyncSession, pk: int) -> User: user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.TokenError(msg='Token 无效') + raise errors.TokenError(msg=t('error.token.invalid')) if not user.status: - raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.locked')) if user.dept_id: if not user.dept.status: - raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.dept_locked')) if user.dept.del_flag: - raise errors.AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.dept_deleted')) if user.roles: role_status = [role.status for role in user.roles] if all(status == 0 for status in role_status): - raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.role_locked')) return user @@ -282,10 +283,10 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail: user_id = token_payload.id redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') if not redis_token: - raise errors.TokenError(msg='Token 已过期') + raise errors.TokenError(msg=t('error.token.expired')) if token != redis_token: - raise errors.TokenError(msg='Token 已失效') + raise errors.TokenError(msg=t('error.token.invalid')) cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') if not cache_user: diff --git a/backend/common/security/permission.py b/backend/common/security/permission.py index afa182f83..aaab324c7 100644 --- a/backend/common/security/permission.py +++ b/backend/common/security/permission.py @@ -8,6 +8,7 @@ from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings from backend.utils.import_parse import dynamic_import_data_model @@ -91,7 +92,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl # 验证规则模型 rule_model = data_rule.model if rule_model not in settings.DATA_PERMISSION_MODELS: - raise errors.NotFoundError(msg='数据规则模型不存在') + raise errors.NotFoundError(msg=t('error.data_rule.model_not_found')) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) # 验证规则列 @@ -100,7 +101,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl ] column = data_rule.column if column not in model_columns: - raise errors.NotFoundError(msg='数据规则模型列不存在') + raise errors.NotFoundError(msg=t('error.data_rule.model_column_not_found')) # 构建过滤条件 column_obj = getattr(model_ins, column) diff --git a/backend/common/security/rbac.py b/backend/common/security/rbac.py index e40f4217e..4a080d42f 100644 --- a/backend/common/security/rbac.py +++ b/backend/common/security/rbac.py @@ -4,6 +4,7 @@ from backend.common.enums import MethodType, StatusType from backend.common.exception import errors +from backend.common.i18n import t from backend.common.log import log from backend.common.security.jwt import DependsJwtAuth from backend.core.conf import settings @@ -38,17 +39,17 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # 检测用户角色 user_roles = request.user.roles if not user_roles or all(status == 0 for status in user_roles): - raise errors.AuthorizationError(msg='用户未分配角色,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.no_role')) # 检测用户所属角色菜单 if not any(len(role.menus) > 0 for role in user_roles): - raise errors.AuthorizationError(msg='用户未分配菜单,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.no_menu')) # 检测后台管理操作权限 method = request.method if method != MethodType.GET or method != MethodType.OPTIONS: if not request.user.is_staff: - raise errors.AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员') + raise errors.AuthorizationError(msg=t('error.user.not_staff')) # RBAC 鉴权 if settings.RBAC_ROLE_MENU_MODE: @@ -81,7 +82,7 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: casbin_verify = getattr(casbin_rbac, 'casbin_verify') except (ImportError, AttributeError) as e: log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}') - raise errors.ServerError(msg='权限校验失败,请联系系统管理员') + raise errors.ServerError(msg=t('error.permission_check_failed')) await casbin_verify(request) diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml index 8f44bcfde..f59bf79a4 100644 --- a/backend/locale/zh-CN.yml +++ b/backend/locale/zh-CN.yml @@ -1,17 +1,13 @@ -database: - connection_failed: '❌ 数据库连接失败 {error}' - redis: - auth_failed: ❌ Redis 连接认证失败 error: captcha: error: 验证码错误 expired: 验证码已过期,请重新获取 - celery_worker_unavailable: Celery Worker 暂不可用,请稍后重试 - crontab_invalid: Crontab 表达式非法 - demo_mode_forbidden: 演示环境下禁止执行此操作 + demo_mode: 演示环境下禁止执行此操作 data_rule: not_found: 数据规则不存在 exists: 数据规则已存在 + model_not_found: 数据规则模型不存在 + model_column_not_found: 数据规则模型列不存在 available_models: 数据规则可用模型不存在 data_scope: not_found: 数据范围不存在 @@ -36,10 +32,11 @@ error: exists: 角色已存在 email_config_missing: 缺少邮件动态配置,请检查系统参数配置-邮件配置 file_type_unknown: 未知的文件类型 - image_format_not_supported: 此图片格式暂不支持 - image_size_exceeded: 图片超出最大限制,请重新选择 + image: + format_not_supported: 此图片格式暂不支持 + size_exceeded: 图片超出最大限制,请重新选择 json_parse_failed: json 解析失败 - language_not_found: Current language pack is not initialized or does not exist + language_not_found: 当前语言包未初始化或不存在 limit_reached: 请求过于频繁,请稍后重试 model_parse_failed: 数据模型列动态解析失败,请联系系统超级管理员 password: @@ -55,30 +52,70 @@ error: zip_content_invalid: 插件压缩包内容非法 zip_missing_files: 插件压缩包内缺少必要文件 already_installed: 此插件已安装 + disabled: 插件 {plugin} 未启用,请联系系统管理员 + code_generator: + business_not_found: 代码生成业务不存在 + business_exists: 代码生成业务已存在 + table_not_found: 数据库表不存在 + table_business_exists: 已存在相同数据库表业务 + column_table_not_found: 代码生成模型列表不存在 + column_not_found: 代码生成模型列不存在 + column_exists: 代码生成模型列已存在 + column_name_exists: 代码生成模型列名称已存在 + config: + not_found: 参数配置不存在 + exists: 参数配置已存在 + dict: + data: + not_found: 字典数据不存在 + exists: 字典数据已存在 + type: + not_found: 字典类型不存在 + exists: 字典类型已存在 + notice: + not_found: 通知公告不存在 rate_limit_exceeded: 请求过于频繁,请稍后重试 refresh_token_expired: Refresh Token 已过期,请重新登录 request_params_invalid: '请求参数非法: {message}' - snowflake_cluster_id_invalid: '集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间' - snowflake_node_id_invalid: '节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间' - sql_file_not_found: SQL 脚本文件不存在 - sql_illegal_operation: SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT - token_expired: Token 已过期 - token_invalid: Token 无效 + snowflake: + cluster_id_invalid: '集群编号必须在 0-{max} 之间' + node_id_invalid: '节点编号必须在 0-{max} 之间' + system_time_error: 系统时间倒退,拒绝生成 ID 直到 {last_timestamp}' + sql: + file_not_found: SQL 脚本文件不存在 + syntax_not_allowed: SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT + task: + celery_worker_unavailable: Celery Worker 暂不可用,请稍后重试 + crontab_invalid: Crontab 表达式非法 + result_not_found: 任务结果不存在 + scheduler_not_found: 任务调度不存在 + scheduler_exists: 任务调度已存在 + params_invalid: 执行失败,任务参数非法 + scheduler_type_invalid: 暂不支持的计划类型:{type} + schedule_not_found: '{name} 计划为空!' + token: + expired: Token 已过期 + invalid: Token 无效 upload_file_failed: 上传文件失败 user: not_found: 用户不存在 locked: '用户已被锁定, 请联系统管理员' - forbidden: 用户已被禁止后台管理操作,请联系系统管理员 + not_staff: 用户已被禁止后台管理操作,请联系系统管理员 no_role: 用户未分配角色,请联系系统管理员 no_menu: 用户未分配菜单,请联系系统管理员 login_elsewhere: 此用户已在异地登录,请重新登录并及时修改密码 perm: type_not_found: 权限类型不存在 edit_self_not_allowed: 禁止修改自身权限 + dept_locked: 用户所属部门已被锁定,请联系系统管理员 + dept_deleted: 用户所属部门已被删除,请联系系统管理员 + role_locked: 用户所属角色已被锁定,请联系系统管理员 username_or_password_error: 用户名或密码有误 username_exists: 用户名已存在 - video_format_not_supported: 此视频格式暂不支持 - video_size_exceeded: 视频超出最大限制,请重新选择 + video: + format_not_supported: 此视频格式暂不支持 + size_exceeded: 视频超出最大限制,请重新选择 + expires_and_expire_seconds_conflict: expires 和 expire_seconds 只能设置一个 pydantic: # 自定义验证错误信息,参考: # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 @@ -191,15 +228,3 @@ success: plugin: install_success: '插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' - task: - execute_success: '任务 {task_id} 执行成功' -task: - add_to_db_failed: '添加任务 {name} 到数据库失败' - clean_login_log: 清理登录日志 - execute_failed: '任务 {task_id} 执行失败' - params_invalid: 执行失败,任务参数非法 - save_status_failed: '保存任务 {name} 最新状态失败:{error}' -websocket: - auth_failed: WebSocket 连接失败:授权失败,请检查 - connection_failed: 'WebSocket 连接失败:{error}' - no_auth: WebSocket 连接失败:无授权 diff --git a/backend/plugin/code_generator/service/business_service.py b/backend/plugin/code_generator/service/business_service.py index 7c829615e..39e489e70 100644 --- a/backend/plugin/code_generator/service/business_service.py +++ b/backend/plugin/code_generator/service/business_service.py @@ -5,6 +5,7 @@ from sqlalchemy import Select from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_business import gen_business_dao from backend.plugin.code_generator.model import GenBusiness @@ -25,7 +26,7 @@ async def get(*, pk: int) -> GenBusiness: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg='代码生成业务不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) return business @staticmethod @@ -55,7 +56,7 @@ async def create(*, obj: CreateGenBusinessParam) -> None: async with async_db_session.begin() as db: business = await gen_business_dao.get_by_name(db, obj.table_name) if business: - raise errors.ConflictError(msg='代码生成业务已存在') + raise errors.ConflictError(msg=t('error.plugin.code_generator.business_exists')) await gen_business_dao.create(db, obj) @staticmethod diff --git a/backend/plugin/code_generator/service/code_service.py b/backend/plugin/code_generator/service/code_service.py index f93fe0535..a8ef95c21 100644 --- a/backend/plugin/code_generator/service/code_service.py +++ b/backend/plugin/code_generator/service/code_service.py @@ -13,6 +13,7 @@ from sqlalchemy import RowMapping from backend.common.exception import errors +from backend.common.i18n import t from backend.core.path_conf import BASE_PATH from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_business import gen_business_dao @@ -52,11 +53,11 @@ async def import_business_and_model(*, obj: ImportParam) -> None: async with async_db_session.begin() as db: table_info = await gen_dao.get_table(db, obj.table_name) if not table_info: - raise errors.NotFoundError(msg='数据库表不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.table_not_found')) business_info = await gen_business_dao.get_by_name(db, obj.table_name) if business_info: - raise errors.ConflictError(msg='已存在相同数据库表业务') + raise errors.ConflictError(msg=t('error.plugin.code_generator.table_business_exists')) table_name = table_info[0] new_business = GenBusiness( @@ -102,7 +103,7 @@ async def render_tpl_code(*, business: GenBusiness) -> dict[str, str]: """ gen_models = await gen_column_service.get_columns(business_id=business.id) if not gen_models: - raise errors.NotFoundError(msg='代码生成模型表为空') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.column_table_not_found')) gen_vars = gen_template.get_vars(business, gen_models) return { @@ -120,7 +121,7 @@ async def preview(self, *, pk: int) -> dict[str, bytes]: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg='业务不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) tpl_code_map = await self.render_tpl_code(business=business) @@ -155,7 +156,7 @@ async def get_generate_path(*, pk: int) -> list[str]: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg='业务不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) gen_path = business.gen_path or 'fba-backend-app-dir' target_files = gen_template.get_code_gen_paths(business) @@ -172,7 +173,7 @@ async def generate(self, *, pk: int) -> None: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg='业务不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) tpl_code_map = await self.render_tpl_code(business=business) gen_path = business.gen_path or os.path.join(BASE_PATH, 'app') @@ -227,7 +228,7 @@ async def download(self, *, pk: int) -> io.BytesIO: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg='业务不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) bio = io.BytesIO() with zipfile.ZipFile(bio, 'w') as zf: diff --git a/backend/plugin/code_generator/service/column_service.py b/backend/plugin/code_generator/service/column_service.py index ef501c55e..f674610d2 100644 --- a/backend/plugin/code_generator/service/column_service.py +++ b/backend/plugin/code_generator/service/column_service.py @@ -3,6 +3,7 @@ from typing import Sequence from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_column import gen_column_dao from backend.plugin.code_generator.enums import GenMySQLColumnType @@ -25,7 +26,7 @@ async def get(*, pk: int) -> GenColumn: async with async_db_session() as db: column = await gen_column_dao.get(db, pk) if not column: - raise errors.NotFoundError(msg='代码生成模型列不存在') + raise errors.NotFoundError(msg=t('error.plugin.code_generator.column_not_found')) return column @staticmethod @@ -57,7 +58,7 @@ async def create(*, obj: CreateGenColumnParam) -> None: async with async_db_session.begin() as db: gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) if obj.name in [gen_column.name for gen_column in gen_columns]: - raise errors.ForbiddenError(msg='模型列已存在') + raise errors.ForbiddenError(msg=t('error.plugin.code_generator.column_exists')) pd_type = sql_type_to_pydantic(obj.type) await gen_column_dao.create(db, obj, pd_type=pd_type) @@ -76,7 +77,7 @@ async def update(*, pk: int, obj: UpdateGenColumnParam) -> int: if obj.name != column.name: gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) if obj.name in [gen_column.name for gen_column in gen_columns]: - raise errors.ConflictError(msg='模型列名已存在') + raise errors.ConflictError(msg=t('error.plugin.code_generator.column_name_exists')) pd_type = sql_type_to_pydantic(obj.type) return await gen_column_dao.update(db, pk, obj, pd_type=pd_type) diff --git a/backend/plugin/config/service/config_service.py b/backend/plugin/config/service/config_service.py index d94228f0e..016732d17 100644 --- a/backend/plugin/config/service/config_service.py +++ b/backend/plugin/config/service/config_service.py @@ -4,6 +4,7 @@ from sqlalchemy import Select from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.config.crud.crud_config import config_dao from backend.plugin.config.model import Config @@ -27,7 +28,7 @@ async def get(*, pk: int) -> Config: async with async_db_session() as db: config = await config_dao.get(db, pk) if not config: - raise errors.NotFoundError(msg='参数配置不存在') + raise errors.NotFoundError(msg=t('error.plugin.config.not_found')) return config @staticmethod @@ -52,7 +53,7 @@ async def create(*, obj: CreateConfigParam) -> None: async with async_db_session.begin() as db: config = await config_dao.get_by_key(db, obj.key) if config: - raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在') + raise errors.ConflictError(msg=t('error.plugin.config.exists')) await config_dao.create(db, obj) @staticmethod @@ -67,11 +68,11 @@ async def update(*, pk: int, obj: UpdateConfigParam) -> int: async with async_db_session.begin() as db: config = await config_dao.get(db, pk) if not config: - raise errors.NotFoundError(msg='参数配置不存在') + raise errors.NotFoundError(msg=t('error.plugin.config.not_found')) if config.key != obj.key: config = await config_dao.get_by_key(db, obj.key) if config: - raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在') + raise errors.ConflictError(msg=t('error.plugin.config.exists')) count = await config_dao.update(db, pk, obj) return count diff --git a/backend/plugin/dict/service/dict_data_service.py b/backend/plugin/dict/service/dict_data_service.py index 0fba2a523..31ff6062a 100644 --- a/backend/plugin/dict/service/dict_data_service.py +++ b/backend/plugin/dict/service/dict_data_service.py @@ -5,6 +5,7 @@ from sqlalchemy import Select from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.dict.crud.crud_dict_data import dict_data_dao from backend.plugin.dict.crud.crud_dict_type import dict_type_dao @@ -26,7 +27,7 @@ async def get(*, pk: int) -> DictData: async with async_db_session() as db: dict_data = await dict_data_dao.get(db, pk) if not dict_data: - raise errors.NotFoundError(msg='字典数据不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) return dict_data @staticmethod @@ -64,10 +65,10 @@ async def create(*, obj: CreateDictDataParam) -> None: async with async_db_session.begin() as db: dict_data = await dict_data_dao.get_by_label(db, obj.label) if dict_data: - raise errors.ConflictError(msg='字典数据已存在') + raise errors.ConflictError(msg=t('error.plugin.dict.data.exists')) dict_type = await dict_type_dao.get(db, obj.type_id) if not dict_type: - raise errors.NotFoundError(msg='字典类型不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) await dict_data_dao.create(db, obj, dict_type.code) @staticmethod @@ -82,13 +83,13 @@ async def update(*, pk: int, obj: UpdateDictDataParam) -> int: async with async_db_session.begin() as db: dict_data = await dict_data_dao.get(db, pk) if not dict_data: - raise errors.NotFoundError(msg='字典数据不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) if dict_data.label != obj.label: if await dict_data_dao.get_by_label(db, obj.label): - raise errors.ConflictError(msg='字典数据已存在') + raise errors.ConflictError(msg=t('error.plugin.dict.data.exists')) dict_type = await dict_type_dao.get(db, obj.type_id) if not dict_type: - raise errors.NotFoundError(msg='字典类型不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) count = await dict_data_dao.update(db, pk, obj, dict_type.code) return count diff --git a/backend/plugin/dict/service/dict_type_service.py b/backend/plugin/dict/service/dict_type_service.py index 827276ee1..0c2e0228d 100644 --- a/backend/plugin/dict/service/dict_type_service.py +++ b/backend/plugin/dict/service/dict_type_service.py @@ -3,6 +3,7 @@ from sqlalchemy import Select from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.dict.crud.crud_dict_type import dict_type_dao from backend.plugin.dict.model import DictType @@ -23,7 +24,7 @@ async def get(*, pk) -> DictType: async with async_db_session() as db: dict_type = await dict_type_dao.get(db, pk) if not dict_type: - raise errors.NotFoundError(msg='字典类型不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) return dict_type @staticmethod @@ -49,7 +50,7 @@ async def create(*, obj: CreateDictTypeParam) -> None: async with async_db_session.begin() as db: dict_type = await dict_type_dao.get_by_code(db, obj.code) if dict_type: - raise errors.ConflictError(msg='字典类型已存在') + raise errors.ConflictError(msg=t('error.plugin.dict.type.exists')) await dict_type_dao.create(db, obj) @staticmethod @@ -64,10 +65,10 @@ async def update(*, pk: int, obj: UpdateDictTypeParam) -> int: async with async_db_session.begin() as db: dict_type = await dict_type_dao.get(db, pk) if not dict_type: - raise errors.NotFoundError(msg='字典类型不存在') + raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) if dict_type.code != obj.code: if await dict_type_dao.get_by_code(db, obj.code): - raise errors.ConflictError(msg='字典类型已存在') + raise errors.ConflictError(msg=t('error.plugin.dict.type.exists')) count = await dict_type_dao.update(db, pk, obj) return count diff --git a/backend/plugin/email/utils/send.py b/backend/plugin/email/utils/send.py index 03353071f..24ff26209 100644 --- a/backend/plugin/email/utils/send.py +++ b/backend/plugin/email/utils/send.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.common.exception import errors +from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR @@ -86,7 +87,7 @@ def get_config_table(conn): configs = {d['key']: d for d in select_list_serialize(dynamic_email_config)} if configs.get('EMAIL_STATUS'): if len(dynamic_email_config) < 6: - raise errors.NotFoundError(msg='缺少邮件动态配置,请检查系统参数配置-邮件配置') + raise errors.NotFoundError(msg=t('error.email_config_missing')) smtp_client = SMTP( hostname=configs.get('EMAIL_HOST'), port=configs.get('EMAIL_PORT'), diff --git a/backend/plugin/notice/service/notice_service.py b/backend/plugin/notice/service/notice_service.py index fa6c88263..e0cccc88e 100644 --- a/backend/plugin/notice/service/notice_service.py +++ b/backend/plugin/notice/service/notice_service.py @@ -5,6 +5,7 @@ from sqlalchemy import Select from backend.common.exception import errors +from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.notice.crud.crud_notice import notice_dao from backend.plugin.notice.model import Notice @@ -25,7 +26,7 @@ async def get(*, pk: int) -> Notice: async with async_db_session() as db: notice = await notice_dao.get(db, pk) if not notice: - raise errors.NotFoundError(msg='通知公告不存在') + raise errors.NotFoundError(msg=t('error.plugin.notice.not_found')) return notice @staticmethod @@ -63,7 +64,7 @@ async def update(*, pk: int, obj: UpdateNoticeParam) -> int: async with async_db_session.begin() as db: notice = await notice_dao.get(db, pk) if not notice: - raise errors.NotFoundError(msg='通知公告不存在') + raise errors.NotFoundError(msg=t('error.plugin.notice.not_found')) count = await notice_dao.update(db, pk, obj) return count diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index 95bb4d305..aba6dcb98 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 11f8bb3be..89c9de9d1 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -19,6 +19,7 @@ from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType from backend.common.exception import errors +from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR @@ -388,4 +389,4 @@ async def __call__(self, request: Request) -> None: raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员') if not int(json.loads(plugin_info)['plugin']['enable']): - raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员') + raise errors.ServerError(msg=t('error.plugin.disabled', plugin=self.plugin)) diff --git a/backend/utils/demo_site.py b/backend/utils/demo_site.py index 039577c03..1c2e51fb0 100644 --- a/backend/utils/demo_site.py +++ b/backend/utils/demo_site.py @@ -3,6 +3,7 @@ from fastapi import Request from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings @@ -21,4 +22,4 @@ async def demo_site(request: Request) -> None: and method != 'OPTIONS' and (method, path) not in settings.DEMO_MODE_EXCLUDE ): - raise errors.ForbiddenError(msg='演示环境下禁止执行此操作') + raise errors.ForbiddenError(msg=t('error.demo_mode')) diff --git a/backend/utils/file_ops.py b/backend/utils/file_ops.py index 3d074b9d5..642f54ac6 100644 --- a/backend/utils/file_ops.py +++ b/backend/utils/file_ops.py @@ -13,6 +13,7 @@ from backend.common.enums import FileType from backend.common.exception import errors +from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR @@ -46,18 +47,18 @@ def upload_file_verify(file: UploadFile) -> None: filename = file.filename file_ext = filename.split('.')[-1].lower() if not file_ext: - raise errors.RequestError(msg='未知的文件类型') + raise errors.RequestError(msg=t('error.file_type_unknown')) if file_ext == FileType.image: if file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE: - raise errors.RequestError(msg='此图片格式暂不支持') + raise errors.RequestError(msg=t('error.image.format_not_supported')) if file.size > settings.UPLOAD_IMAGE_SIZE_MAX: - raise errors.RequestError(msg='图片超出最大限制,请重新选择') + raise errors.RequestError(msg=t('error.image.size_exceeded')) elif file_ext == FileType.video: if file_ext not in settings.UPLOAD_VIDEO_EXT_INCLUDE: - raise errors.RequestError(msg='此视频格式暂不支持') + raise errors.RequestError(msg=t('error.video.format_not_supported')) if file.size > settings.UPLOAD_VIDEO_SIZE_MAX: - raise errors.RequestError(msg='视频超出最大限制,请重新选择') + raise errors.RequestError(msg=t('error.video.size_exceeded')) async def upload_file(file: UploadFile) -> str: @@ -77,7 +78,7 @@ async def upload_file(file: UploadFile) -> str: await fb.write(content) except Exception as e: log.error(f'上传文件 {filename} 失败:{str(e)}') - raise errors.RequestError(msg='上传文件失败') + raise errors.RequestError(msg=t('error.upload_file_failed')) await file.close() return filename @@ -96,19 +97,19 @@ async def install_zip_plugin(file: UploadFile | str) -> str: contents = await file.read() file_bytes = io.BytesIO(contents) if not zipfile.is_zipfile(file_bytes): - raise errors.RequestError(msg='插件压缩包格式非法') + raise errors.RequestError(msg=t('error.plugin.zip_invalid')) with zipfile.ZipFile(file_bytes) as zf: # 校验压缩包 plugin_namelist = zf.namelist() plugin_dir_name = plugin_namelist[0].split('/')[0] if not plugin_namelist: - raise errors.RequestError(msg='插件压缩包内容非法') + raise errors.RequestError(msg=t('error.plugin.zip_content_invalid')) if ( len(plugin_namelist) <= 3 or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist or f'{plugin_dir_name}/README.md' not in plugin_namelist ): - raise errors.RequestError(msg='插件压缩包内缺少必要文件') + raise errors.RequestError(msg=t('error.plugin.zip_missing_files')) # 插件是否可安装 plugin_name = re.match( @@ -119,7 +120,7 @@ async def install_zip_plugin(file: UploadFile | str) -> str: ).group() full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) if os.path.exists(full_plugin_path): - raise errors.ConflictError(msg='此插件已安装') + raise errors.ConflictError(msg=t('error.plugin.already_installed')) else: os.makedirs(full_plugin_path, exist_ok=True) @@ -148,15 +149,15 @@ async def install_git_plugin(repo_url: str) -> str: """ match = is_git_url(repo_url) if not match: - raise errors.RequestError(msg='Git 仓库地址格式非法') + raise errors.RequestError(msg=t('error.plugin.git_url_invalid')) repo_name = match.group('repo') if os.path.exists(os.path.join(PLUGIN_DIR, repo_name)): - raise errors.ConflictError(msg=f'{repo_name} 插件已安装') + raise errors.ConflictError(msg=t('error.plugin.already_installed')) try: porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) except Exception as e: log.error(f'插件安装失败: {e}') - raise errors.ServerError(msg='插件安装失败,请稍后重试') from e + raise errors.ServerError(msg=t('error.plugin.install_failed')) from e await install_requirements_async(repo_name) await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') @@ -172,7 +173,7 @@ async def parse_sql_script(filepath: str) -> list[str]: :return: """ if not os.path.exists(filepath): - raise errors.NotFoundError(msg='SQL 脚本文件不存在') + raise errors.NotFoundError(msg=t('error.sql.file_not_found')) async with aiofiles.open(filepath, mode='r', encoding='utf-8') as f: contents = await f.read(1024) @@ -182,6 +183,6 @@ async def parse_sql_script(filepath: str) -> list[str]: statements = split(contents) for statement in statements: if not any(statement.lower().startswith(_) for _ in ['select', 'insert']): - raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT') + raise errors.RequestError(msg=t('error.sql.syntax_not_allowed')) return statements diff --git a/backend/utils/import_parse.py b/backend/utils/import_parse.py index 55a3e26ef..d1e535324 100644 --- a/backend/utils/import_parse.py +++ b/backend/utils/import_parse.py @@ -6,6 +6,7 @@ from typing import Any, Type, TypeVar from backend.common.exception import errors +from backend.common.i18n import t from backend.common.log import log T = TypeVar('T') @@ -35,4 +36,4 @@ def dynamic_import_data_model(module_path: str) -> Type[T]: return getattr(module, class_name) except (ImportError, AttributeError) as e: log.error(f'动态导入数据模型失败:{e}') - raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员') + raise errors.ServerError(msg=t('error.model_parse_failed')) diff --git a/backend/utils/snowflake.py b/backend/utils/snowflake.py index 1a2d16249..777304253 100644 --- a/backend/utils/snowflake.py +++ b/backend/utils/snowflake.py @@ -6,6 +6,7 @@ from backend.common.dataclasses import SnowflakeInfo from backend.common.exception import errors +from backend.common.i18n import t from backend.core.conf import settings @@ -54,9 +55,11 @@ def __init__( :param sequence: 起始序列号 """ if cluster_id < 0 or cluster_id > SnowflakeConfig.MAX_DATACENTER_ID: - raise errors.RequestError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间') + raise errors.RequestError( + msg=t('error.snowflake.cluster_id_invalid', max=SnowflakeConfig.MAX_DATACENTER_ID) + ) if node_id < 0 or node_id > SnowflakeConfig.MAX_WORKER_ID: - raise errors.RequestError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间') + raise errors.RequestError(msg=t('error.snowflake.node_id_invalid', max=SnowflakeConfig.MAX_WORKER_ID)) self.node_id = node_id self.cluster_id = cluster_id @@ -86,7 +89,7 @@ def generate(self) -> int: timestamp = self._current_millis() if timestamp < self.last_timestamp: - raise errors.ServerError(msg=f'系统时间倒退,拒绝生成 ID 直到 {self.last_timestamp}') + raise errors.ServerError(msg=t('error.snowflake.system_time_error', last_timestamp=self.last_timestamp)) if timestamp == self.last_timestamp: self.sequence = (self.sequence + 1) & SnowflakeConfig.SEQUENCE_MASK From 4c7340201f627dc7c32a9cedd7725bb8bf6df449 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 21:28:19 +0800 Subject: [PATCH 04/11] Update the zh-CN file --- backend/locale/zh-CN.yml | 96 ++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml index f59bf79a4..eefef4159 100644 --- a/backend/locale/zh-CN.yml +++ b/backend/locale/zh-CN.yml @@ -2,35 +2,26 @@ error: captcha: error: 验证码错误 expired: 验证码已过期,请重新获取 - demo_mode: 演示环境下禁止执行此操作 data_rule: - not_found: 数据规则不存在 + available_models: 数据规则可用模型不存在 exists: 数据规则已存在 - model_not_found: 数据规则模型不存在 model_column_not_found: 数据规则模型列不存在 - available_models: 数据规则可用模型不存在 + model_not_found: 数据规则模型不存在 + not_found: 数据规则不存在 data_scope: - not_found: 数据范围不存在 exists: 数据范围已存在 + not_found: 数据范围不存在 + demo_mode: 演示环境下禁止执行此操作 dept: - not_found: 部门不存在 - parent: - not_found: 父部门不存在 - related_self_not_allowed: 禁止关联自身为父级 exists: 部门已存在 exists_children: 部门存在子部门,无法删除 exists_users: 部门存在用户,无法删除 - menu: - not_found: 菜单不存在 - exists: 菜单已存在 + not_found: 部门不存在 parent: - not_found: 父菜单不存在 + not_found: 父部门不存在 related_self_not_allowed: 禁止关联自身为父级 - exists_children: 菜单存在子菜单,无法删除 - role: - not_found: 角色不存在 - exists: 角色已存在 email_config_missing: 缺少邮件动态配置,请检查系统参数配置-邮件配置 + expires_and_expire_seconds_conflict: expires 和 expire_seconds 只能设置一个 file_type_unknown: 未知的文件类型 image: format_not_supported: 此图片格式暂不支持 @@ -38,45 +29,55 @@ error: json_parse_failed: json 解析失败 language_not_found: 当前语言包未初始化或不存在 limit_reached: 请求过于频繁,请稍后重试 + menu: + exists: 菜单已存在 + exists_children: 菜单存在子菜单,无法删除 + not_found: 菜单不存在 + parent: + not_found: 父菜单不存在 + related_self_not_allowed: 禁止关联自身为父级 model_parse_failed: 数据模型列动态解析失败,请联系系统超级管理员 password: - required: 密码不允许为空 mismatch: 密码输入不一致 old_error: 原密码错误 + required: 密码不允许为空 permission_check_failed: 权限校验失败,请联系系统管理员 plugin: - not_found: 插件不存在 - zip_invalid: 插件压缩包格式非法 - git_url_invalid: Git 仓库地址格式非法 - install_failed: 插件安装失败,请稍后重试 - zip_content_invalid: 插件压缩包内容非法 - zip_missing_files: 插件压缩包内缺少必要文件 already_installed: 此插件已安装 - disabled: 插件 {plugin} 未启用,请联系系统管理员 code_generator: - business_not_found: 代码生成业务不存在 business_exists: 代码生成业务已存在 - table_not_found: 数据库表不存在 - table_business_exists: 已存在相同数据库表业务 - column_table_not_found: 代码生成模型列表不存在 - column_not_found: 代码生成模型列不存在 + business_not_found: 代码生成业务不存在 column_exists: 代码生成模型列已存在 column_name_exists: 代码生成模型列名称已存在 + column_not_found: 代码生成模型列不存在 + column_table_not_found: 代码生成模型列表不存在 + table_business_exists: 已存在相同数据库表业务 + table_not_found: 数据库表不存在 config: - not_found: 参数配置不存在 exists: 参数配置已存在 + not_found: 参数配置不存在 dict: data: - not_found: 字典数据不存在 exists: 字典数据已存在 + not_found: 字典数据不存在 type: - not_found: 字典类型不存在 exists: 字典类型已存在 + not_found: 字典类型不存在 + disabled: 插件 {plugin} 未启用,请联系系统管理员 + git_url_invalid: Git 仓库地址格式非法 + install_failed: 插件安装失败,请稍后重试 + not_found: 插件不存在 notice: not_found: 通知公告不存在 + zip_content_invalid: 插件压缩包内容非法 + zip_invalid: 插件压缩包格式非法 + zip_missing_files: 插件压缩包内缺少必要文件 rate_limit_exceeded: 请求过于频繁,请稍后重试 refresh_token_expired: Refresh Token 已过期,请重新登录 request_params_invalid: '请求参数非法: {message}' + role: + exists: 角色已存在 + not_found: 角色不存在 snowflake: cluster_id_invalid: '集群编号必须在 0-{max} 之间' node_id_invalid: '节点编号必须在 0-{max} 之间' @@ -87,35 +88,34 @@ error: task: celery_worker_unavailable: Celery Worker 暂不可用,请稍后重试 crontab_invalid: Crontab 表达式非法 + params_invalid: 执行失败,任务参数非法 result_not_found: 任务结果不存在 - scheduler_not_found: 任务调度不存在 + schedule_not_found: '{name} 计划为空!' scheduler_exists: 任务调度已存在 - params_invalid: 执行失败,任务参数非法 + scheduler_not_found: 任务调度不存在 scheduler_type_invalid: 暂不支持的计划类型:{type} - schedule_not_found: '{name} 计划为空!' token: expired: Token 已过期 invalid: Token 无效 upload_file_failed: 上传文件失败 user: - not_found: 用户不存在 + dept_deleted: 用户所属部门已被删除,请联系系统管理员 + dept_locked: 用户所属部门已被锁定,请联系系统管理员 locked: '用户已被锁定, 请联系统管理员' - not_staff: 用户已被禁止后台管理操作,请联系系统管理员 - no_role: 用户未分配角色,请联系系统管理员 - no_menu: 用户未分配菜单,请联系系统管理员 login_elsewhere: 此用户已在异地登录,请重新登录并及时修改密码 + no_menu: 用户未分配菜单,请联系系统管理员 + no_role: 用户未分配角色,请联系系统管理员 + not_found: 用户不存在 + not_staff: 用户已被禁止后台管理操作,请联系系统管理员 perm: - type_not_found: 权限类型不存在 edit_self_not_allowed: 禁止修改自身权限 - dept_locked: 用户所属部门已被锁定,请联系系统管理员 - dept_deleted: 用户所属部门已被删除,请联系系统管理员 + type_not_found: 权限类型不存在 role_locked: 用户所属角色已被锁定,请联系系统管理员 - username_or_password_error: 用户名或密码有误 username_exists: 用户名已存在 + username_or_password_error: 用户名或密码有误 video: format_not_supported: 此视频格式暂不支持 size_exceeded: 视频超出最大限制,请重新选择 - expires_and_expire_seconds_conflict: expires 和 expire_seconds 只能设置一个 pydantic: # 自定义验证错误信息,参考: # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 @@ -218,13 +218,13 @@ pydantic: uuid_version: '预期 UUID 版本为 {expected_version}' value_error: '值错误,{error}' response: - success: 请求成功 error: 请求错误 server_error: 服务器内部错误 + success: 请求成功 success: login: - success: 登录成功 oauth2_success: 登录成功(OAuth2) + success: 登录成功 plugin: install_success: '插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' - uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' + uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' \ No newline at end of file From 682776e4631ed780106097b1f44858ae6eab1cda Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 21:38:35 +0800 Subject: [PATCH 05/11] Update the en-US file --- backend/locale/en-US.json | 381 +++++++++++++++++++++++--------------- backend/locale/zh-CN.yml | 10 +- 2 files changed, 238 insertions(+), 153 deletions(-) diff --git a/backend/locale/en-US.json b/backend/locale/en-US.json index b57eafbe7..8c2e843f6 100644 --- a/backend/locale/en-US.json +++ b/backend/locale/en-US.json @@ -1,174 +1,259 @@ { - "response": { - "success": "Request successful", - "error": "Request error", - "server_error": "Internal server error" - }, "error": { - "captcha_error": "Captcha error", - "captcha_expired": "The captcha has expired, please get it again", + "captcha": { + "error": "Captcha error", + "expired": "Captcha has expired, please try again" + }, + "data_rule": { + "available_models": "Data rule available models do not exist", + "exists": "Data rule already exists", + "model_column_not_found": "Data rule model column does not exist", + "model_not_found": "Data rule model does not exist", + "not_found": "Data rule does not exist" + }, + "data_scope": { + "exists": "Data scope already exists", + "not_found": "Data scope does not exist" + }, + "demo_mode": "This operation is prohibited in demo mode", + "dept": { + "exists": "Department already exists", + "exists_children": "Department has sub-departments, cannot be deleted", + "exists_users": "Department has users, cannot be deleted", + "not_found": "Department does not exist", + "parent": { + "not_found": "Parent department does not exist", + "related_self_not_allowed": "Cannot associate itself as parent" + } + }, + "email_config_missing": "Missing email dynamic configuration, please check system parameter configuration - email configuration", + "expires_and_expire_seconds_conflict": "Only one of expires and expire_seconds can be set", + "file_type_unknown": "Unknown file type", + "image": { + "format_not_supported": "This image format is not supported", + "size_exceeded": "Image exceeds maximum limit, please reselect" + }, "json_parse_failed": "JSON parsing failed", - "invalid_request_params": "Invalid request parameters: {message}", - "user_not_found": "User not found", - "username_or_password_error": "Incorrect username or password", - "user_locked": "User has been locked, please contact system administrator", - "user_forbidden": "User has been prohibited from backend management operations, please contact system administrator", - "user_no_role": "User has not been assigned a role, please contact system administrator", - "user_no_menu": "User has not been assigned a menu, please contact system administrator", - "username_already_exists": "Username already registered", - "password_required": "Password cannot be empty", - "old_password_error": "Incorrect old password", - "password_mismatch": "Password inputs do not match", - "refresh_token_expired": "Refresh Token has expired, please log in again", - "user_login_elsewhere": "This user has logged in elsewhere, please log in again and change password promptly", - "dept_has_users": "Department has users, cannot delete", - "task_params_invalid": "Execution failed, task parameters are invalid", - "upload_file_failed": "File upload failed", - "plugin_install_failed": "Plugin installation failed, please try again later", - "model_parse_failed": "Data model column dynamic parsing failed, please contact system super administrator", - "permission_check_failed": "Permission check failed, please contact system administrator", "language_not_found": "Current language pack is not initialized or does not exist", - "email_config_missing": "Missing email dynamic configuration, please check system parameter configuration - email configuration", - "crontab_invalid": "Crontab expression is invalid", - "snowflake_cluster_id_invalid": "Cluster ID must be between 0-{SnowflakeConfig.MAX_DATACENTER_ID}", - "snowflake_node_id_invalid": "Node ID must be between 0-{SnowflakeConfig.MAX_WORKER_ID}", - "celery_worker_unavailable": "Celery Worker is temporarily unavailable, please try again later", - "token_invalid": "Token is invalid", - "token_expired": "Token has expired", + "limit_reached": "Request too frequent, please try again later", + "menu": { + "exists": "Menu already exists", + "exists_children": "Menu has sub-menus, cannot be deleted", + "not_found": "Menu does not exist", + "parent": { + "not_found": "Parent menu does not exist", + "related_self_not_allowed": "Cannot associate itself as parent" + } + }, + "model_parse_failed": "Data model column dynamic parsing failed, please contact system administrator", + "password": { + "mismatch": "Password mismatch", + "old_error": "Old password error", + "required": "Password cannot be empty" + }, + "permission_check_failed": "Permission verification failed, please contact system administrator", + "plugin": { + "already_installed": "This plugin is already installed", + "code_generator": { + "business_exists": "Code generation business already exists", + "business_not_found": "Code generation business does not exist", + "column_exists": "Code generation model column already exists", + "column_name_exists": "Code generation model column name already exists", + "column_not_found": "Code generation model column does not exist", + "column_table_not_found": "Code generation model list does not exist", + "table_business_exists": "Same database table business already exists", + "table_not_found": "Database table does not exist" + }, + "config": { + "exists": "Parameter configuration already exists", + "not_found": "Parameter configuration does not exist" + }, + "dict": { + "data": { + "exists": "Dictionary data already exists", + "not_found": "Dictionary data does not exist" + }, + "type": { + "exists": "Dictionary type already exists", + "not_found": "Dictionary type does not exist" + } + }, + "disabled": "Plugin {plugin} is not enabled, please contact system administrator", + "git_url_invalid": "Git repository URL format is invalid", + "install_failed": "Plugin installation failed, please try again later", + "not_found": "Plugin does not exist", + "notice": { + "not_found": "Notice announcement does not exist" + }, + "zip_content_invalid": "Plugin zip content is invalid", + "zip_invalid": "Plugin zip format is invalid", + "zip_missing_files": "Missing required files in plugin zip" + }, "rate_limit_exceeded": "Request too frequent, please try again later", - "file_type_unknown": "Unknown file type", - "image_format_not_supported": "This image format is not supported", - "image_size_exceeded": "Image exceeds maximum limit, please select again", - "video_format_not_supported": "This video format is not supported", - "video_size_exceeded": "Video exceeds maximum limit, please select again", - "plugin_zip_invalid": "Plugin zip package format is invalid", - "plugin_zip_content_invalid": "Plugin zip package content is invalid", - "plugin_zip_missing_files": "Required files are missing in the plugin zip package", - "plugin_already_installed": "This plugin is already installed", - "git_url_invalid": "Git repository URL format is invalid", - "sql_file_not_found": "SQL script file does not exist", - "sql_illegal_operation": "Illegal operations found in SQL script file, only SELECT and INSERT are allowed", - "demo_mode_forbidden": "This operation is forbidden in demo mode" - - }, - "success": { - "login_success": "Login successful", - "login_success_oauth2": "Login successful (OAuth2)", - "task_execute_success": "Task {task_id} executed successfully", - "plugin_install_success": "Plugin {plugin_name} installed successfully" + "refresh_token_expired": "Refresh Token has expired, please login again", + "request_params_invalid": "Request parameters invalid: {message}", + "role": { + "exists": "Role already exists", + "not_found": "Role does not exist" + }, + "snowflake": { + "cluster_id_invalid": "Cluster ID must be between 0-{max}", + "node_id_invalid": "Node ID must be between 0-{max}", + "system_time_error": "System time has regressed, refusing to generate ID until {last_timestamp}" + }, + "sql": { + "file_not_found": "SQL script file does not exist", + "syntax_not_allowed": "SQL script file contains illegal operations, only SELECT and INSERT are allowed" + }, + "task": { + "celery_worker_unavailable": "Celery Worker is temporarily unavailable, please try again later", + "crontab_invalid": "Crontab expression is invalid", + "params_invalid": "Execution failed, task parameters are invalid", + "result_not_found": "Task result does not exist", + "schedule_not_found": "{name} schedule is empty!", + "scheduler_exists": "Task scheduler already exists", + "scheduler_not_found": "Task scheduler does not exist", + "scheduler_type_invalid": "Unsupported schedule type: {type}" + }, + "token": { + "expired": "Token has expired", + "invalid": "Token is invalid" + }, + "upload_file_failed": "File upload failed", + "user": { + "dept_deleted": "User's department has been deleted, please contact system administrator", + "dept_locked": "User's department has been locked, please contact system administrator", + "locked": "User has been locked, please contact system administrator", + "login_elsewhere": "This user has logged in elsewhere, please login again and change password in time", + "no_menu": "User has not been assigned menu, please contact system administrator", + "no_role": "User has not been assigned role, please contact system administrator", + "not_found": "User does not exist", + "not_staff": "User has been prohibited from backend management operations, please contact system administrator", + "perm": { + "edit_self_not_allowed": "Not allowed to modify own permissions", + "type_not_found": "Permission type does not exist" + }, + "role_locked": "User's role has been locked, please contact system administrator" + }, + "username_exists": "Username already exists", + "username_or_password_error": "Username or password error", + "video": { + "format_not_supported": "This video format is not supported", + "size_exceeded": "Video exceeds maximum limit, please reselect" + } }, - "validation": { - "no_such_attribute": "Object does not have attribute '{attribute}'", - "json_invalid": "Invalid JSON: {error}", - "json_type": "JSON input should be string, bytes or bytearray", - "recursion_loop": "Recursion error - circular reference detected", - "model_type": "Input should be a valid dictionary or {class_name} instance", - "model_attributes_type": "Input should be a valid dictionary or object with extractable fields", - "dataclass_exact_type": "Input should be an instance of {class_name}", - "dataclass_type": "Input should be a dictionary or {class_name} instance", - "missing": "Field required", - "frozen_field": "Field is frozen", - "frozen_instance": "Instance is frozen", - "extra_forbidden": "Extra inputs are not permitted", - "invalid_key": "Keys should be strings", - "get_attribute_error": "Error extracting attribute: {error}", - "none_required": "Input should be None", - "enum": "Input should be {expected}", - "greater_than": "Input should be greater than {gt}", - "greater_than_equal": "Input should be greater than or equal to {ge}", - "less_than": "Input should be less than {lt}", - "less_than_equal": "Input should be less than or equal to {le}", - "finite_number": "Input should be a finite number", - "too_short": "{field_type} should have at least {min_length} items after validation, not {actual_length}", - "too_long": "{field_type} should have at most {max_length} items after validation, not {actual_length}", - "string_type": "Input should be a valid string", - "string_sub_type": "Input should be a string, not an instance of a str subclass", - "string_unicode": "Input should be a valid string, unable to parse raw data as a unicode string", - "string_pattern_mismatch": "String should match pattern '{pattern}'", - "string_too_short": "String should have at least {min_length} characters", - "string_too_long": "String should have at most {max_length} characters", - "dict_type": "Input should be a valid dictionary", - "mapping_type": "Input should be a valid mapping, error: {error}", - "iterable_type": "Input should be iterable", - "iteration_error": "Error iterating over object, error: {error}", - "list_type": "Input should be a valid list", - "tuple_type": "Input should be a valid tuple", - "set_type": "Input should be a valid set", - "bool_type": "Input should be a valid boolean", + "pydantic": { + "arguments_type": "Arguments must be a tuple, list or dict", + "assertion_error": "Assertion failed, {error}", "bool_parsing": "Input should be a valid boolean, unable to interpret input", - "int_type": "Input should be a valid integer", - "int_parsing": "Input should be a valid integer, unable to parse string as an integer", - "int_parsing_size": "Unable to parse input string as an integer, exceeds maximum size", - "int_from_float": "Input should be a valid integer, got a number with a fractional part", - "multiple_of": "Input should be a multiple of {multiple_of}", - "float_type": "Input should be a valid number", - "float_parsing": "Input should be a valid number, unable to parse string as a number", - "bytes_type": "Input should be valid bytes", - "bytes_too_short": "Data should have at least {min_length} bytes", + "bool_type": "Input should be a valid boolean", "bytes_too_long": "Data should have at most {max_length} bytes", - "value_error": "Value error, {error}", - "assertion_error": "Assertion failed, {error}", - "literal_error": "Input should be {expected}", - "date_type": "Input should be a valid date", - "date_parsing": "Input should be a valid date in YYYY-MM-DD format, {error}", - "date_from_datetime_parsing": "Input should be a valid date or datetime, {error}", + "bytes_too_short": "Data should have at least {min_length} bytes", + "bytes_type": "Input should be valid bytes", + "callable_type": "Input should be a callable object", + "dataclass_exact_type": "Input should be an instance of {class_name}", + "dataclass_type": "Input should be a dict or an instance of {class_name}", "date_from_datetime_inexact": "Datetime provided to date should have zero time - e.g. be an exact date", - "date_past": "Date should be in the past", + "date_from_datetime_parsing": "Input should be a valid date or datetime, {error}", "date_future": "Date should be in the future", - "time_type": "Input should be a valid time", - "time_parsing": "Input should be in a valid time format, {error}", - "datetime_type": "Input should be a valid datetime", - "datetime_parsing": "Input should be a valid datetime, {error}", + "date_past": "Date should be in the past", + "date_parsing": "Input should be a valid date in YYYY-MM-DD format, {error}", + "date_type": "Input should be a valid date", + "datetime_future": "Input should be in the future", "datetime_object_invalid": "Invalid datetime object, got {error}", "datetime_past": "Input should be in the past", - "datetime_future": "Input should be in the future", - "timezone_naive": "Input should not have timezone info", - "timezone_aware": "Input should have timezone info", - "timezone_offset": "Timezone offset of {tz_expected} required, got {tz_actual}", - "time_delta_type": "Input should be a valid timedelta", - "time_delta_parsing": "Input should be a valid timedelta, {error}", + "datetime_parsing": "Input should be a valid datetime, {error}", + "datetime_type": "Input should be a valid datetime", + "decimal_max_digits": "Decimal input should have no more than {max_digits} digits in total", + "decimal_max_places": "Decimal input should have no more than {decimal_places} decimal places", + "decimal_parsing": "Input should be a valid decimal number", + "decimal_type": "Decimal input should be an integer, float, string or Decimal object", + "decimal_whole_digits": "Decimal input should have no more than {whole_digits} digits before the decimal point", + "dict_type": "Input should be a valid dict", + "email_parsing": "Input should be a valid email address, {error}", + "email_type": "Input should be a valid email address", + "enum": "Input should be {expected}", + "extra_forbidden": "Extra inputs are not allowed", + "finite_number": "Input should be a finite number", + "float_parsing": "Input should be a valid number, unable to parse string as number", + "float_type": "Input should be a valid number", + "frozen_field": "Field is frozen", + "frozen_instance": "Instance is frozen", "frozen_set_type": "Input should be a valid frozenset", + "get_attribute_error": "Error getting attribute: {error}", + "greater_than": "Input should be greater than {gt}", + "greater_than_equal": "Input should be greater than or equal to {ge}", + "int_from_float": "Input should be a valid integer, got a number with a decimal part", + "int_parsing": "Input should be a valid integer, unable to parse string as integer", + "int_parsing_size": "Unable to parse input string as integer, exceeds maximum size", + "int_type": "Input should be a valid integer", + "invalid_key": "Key should be a string", "is_instance_of": "Input should be an instance of {class}", "is_subclass_of": "Input should be a subclass of {class}", - "callable_type": "Input should be callable", - "union_tag_invalid": "Input tag '{tag}' found using {discriminator} does not match any of the expected tags: {expected_tags}", - "union_tag_not_found": "Unable to extract tag using discriminator {discriminator}", - "arguments_type": "Arguments must be a tuple, list or dict", + "iteration_error": "Error iterating object, error: {error}", + "iterable_type": "Input should be iterable", + "json_invalid": "Invalid JSON: {error}", + "json_type": "JSON input should be a string, bytes or bytearray", + "less_than": "Input should be less than {lt}", + "less_than_equal": "Input should be less than or equal to {le}", + "list_type": "Input should be a valid list", + "literal_error": "Input should be {expected}", + "mapping_type": "Input should be a valid mapping, error: {error}", + "missing": "Field is required", "missing_argument": "Missing required argument", - "unexpected_keyword_argument": "Unexpected keyword argument", "missing_keyword_only_argument": "Missing required keyword-only argument", - "unexpected_positional_argument": "Unexpected positional argument", "missing_positional_only_argument": "Missing required positional-only argument", + "model_attributes_type": "Input should be a valid dict or object with extractable fields", + "model_type": "Input should be a valid dict or an instance of {class_name}", "multiple_argument_values": "Multiple values provided for argument", - "url_type": "URL input should be a string or URL", + "multiple_of": "Input should be a multiple of {multiple_of}", + "no_such_attribute": "Object has no attribute '{attribute}'", + "none_required": "Input should be None", + "recursion_loop": "Recursion error - circular reference detected", + "set_type": "Input should be a valid set", + "string_pattern_mismatch": "String should match pattern '{pattern}'", + "string_sub_type": "Input should be a string, not an instance of a str subclass", + "string_too_long": "String should have at most {max_length} characters", + "string_too_short": "String should have at least {min_length} characters", + "string_type": "Input should be a valid string", + "string_unicode": "Input should be a valid string, unable to parse raw data as Unicode string", + "time_delta_parsing": "Input should be a valid timedelta, {error}", + "time_delta_type": "Input should be a valid timedelta", + "time_parsing": "Input should be a valid time format, {error}", + "time_type": "Input should be a valid time", + "timezone_aware": "Input should contain timezone information", + "timezone_naive": "Input should not contain timezone information", + "timezone_offset": "Timezone offset should be {tz_expected}, got {tz_actual}", + "too_long": "{field_type} should have at most {max_length} items after validation, not {actual_length}", + "too_short": "{field_type} should have at least {min_length} items after validation, not {actual_length}", + "tuple_type": "Input should be a valid tuple", + "union_tag_invalid": "Input tag '{tag}' found using {discriminator} does not match any expected tags: {expected_tags}", + "union_tag_not_found": "Unable to extract tag using discriminator {discriminator}", + "unexpected_keyword_argument": "Unexpected keyword argument", + "unexpected_positional_argument": "Unexpected positional argument", "url_parsing": "Input should be a valid URL, {error}", + "url_scheme": "URL scheme should be {expected_schemes}", "url_syntax_violation": "Input violates strict URL syntax rules, {error}", "url_too_long": "URL should have at most {max_length} characters", - "url_scheme": "URL scheme should be {expected_schemes}", - "uuid_type": "UUID input should be a string, bytes or UUID object", + "url_type": "URL input should be a string or URL", "uuid_parsing": "Input should be a valid UUID, {error}", - "uuid_version": "Expected UUID version {expected_version}", - "decimal_type": "Decimal input should be an integer, float, string or Decimal object", - "decimal_parsing": "Input should be a valid decimal", - "decimal_max_digits": "Decimal input should have no more than {max_digits} digits in total", - "decimal_max_places": "Decimal input should have no more than {decimal_places} decimal places", - "decimal_whole_digits": "Decimal input should have no more than {whole_digits} digits before the decimal point", - "email_type": "Input should be a valid email address", - "email_parsing": "Input should be a valid email address, {error}" - }, - "task": { - "clean_login_log": "Clean login logs", - "execute_failed": "Task {task_id} execution failed", - "save_status_failed": "Failed to save latest status of task {name}: {error}", - "add_to_db_failed": "Failed to add task {name} to database" + "uuid_type": "UUID input should be a string, bytes or UUID object", + "uuid_version": "Expected UUID version to be {expected_version}", + "value_error": "Value error, {error}" }, - "websocket": { - "no_auth": "WebSocket connection failed: no authorization", - "auth_failed": "WebSocket connection failed: authorization failed, please check", - "connection_failed": "WebSocket connection failed: {error}" + "response": { + "error": "Request error", + "server_error": "Server internal error", + "success": "Request success" }, - "database": { - "redis_auth_failed": "❌ Database redis connection authentication failed", - "connection_failed": "❌ Database connection failed {error}" + "success": { + "login": { + "oauth2_success": "Login success (OAuth2)", + "success": "Login success" + }, + "plugin": { + "install_success": "Plugin {plugin_name} installed successfully, please refer to the plugin documentation (README.md) for related configuration and restart the service", + "uninstall_success": "Plugin {plugin_name} uninstalled successfully, please remove related configuration according to the plugin documentation (README.md) and restart the service" + } } -} +} \ No newline at end of file diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml index eefef4159..90d4fd344 100644 --- a/backend/locale/zh-CN.yml +++ b/backend/locale/zh-CN.yml @@ -74,14 +74,14 @@ error: zip_missing_files: 插件压缩包内缺少必要文件 rate_limit_exceeded: 请求过于频繁,请稍后重试 refresh_token_expired: Refresh Token 已过期,请重新登录 - request_params_invalid: '请求参数非法: {message}' + request_params_invalid: 请求参数非法:{message} role: exists: 角色已存在 not_found: 角色不存在 snowflake: - cluster_id_invalid: '集群编号必须在 0-{max} 之间' - node_id_invalid: '节点编号必须在 0-{max} 之间' - system_time_error: 系统时间倒退,拒绝生成 ID 直到 {last_timestamp}' + cluster_id_invalid: 集群编号必须在 0-{max} 之间 + node_id_invalid: 节点编号必须在 0-{max} 之间 + system_time_error: 系统时间倒退,拒绝生成 ID 直到 {last_timestamp} sql: file_not_found: SQL 脚本文件不存在 syntax_not_allowed: SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT @@ -227,4 +227,4 @@ success: success: 登录成功 plugin: install_success: '插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' - uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' \ No newline at end of file + uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' From f1b14bb70761dd13d1195b91c6b81f072a442ea8 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 21:43:52 +0800 Subject: [PATCH 06/11] Update the reload filter --- backend/cli.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/cli.py b/backend/cli.py index 0facfd733..397aca9d1 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,9 +52,7 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None: address=host, port=port, reload=not reload, - # https://github.com/emmett-framework/granian/issues/659 - # reload_filter=PythonFilter(extra_extensions=['.json', '.yaml', '.yml']), - reload_filter=PythonFilter, + reload_filter=CustomReloadFilter, workers=workers or 1, ).serve() From 93c2040e13c2d7c11e40bd3a6a628d11f7e8b293 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 21:46:22 +0800 Subject: [PATCH 07/11] Update locale success plugin value --- backend/app/admin/api/v1/sys/plugin.py | 4 ++-- backend/locale/en-US.json | 6 +++--- backend/locale/zh-CN.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index 80c60eb57..2da1a0ee5 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -46,7 +46,7 @@ async def install_plugin( ) -> ResponseModel: plugin = await plugin_service.install(type=type, file=file, repo_url=repo_url) return response_base.success( - res=CustomResponse(code=200, msg=t('success.plugin_install_success', plugin_name=plugin)) + res=CustomResponse(code=200, msg=t('success.plugin.install_success', plugin=plugin)) ) @@ -62,7 +62,7 @@ async def install_plugin( async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: await plugin_service.uninstall(plugin=plugin) return response_base.success( - res=CustomResponse(code=200, msg=t('success.plugin_uninstall_success', plugin_name=plugin)) + res=CustomResponse(code=200, msg=t('success.plugin.uninstall_success', plugin=plugin)) ) diff --git a/backend/locale/en-US.json b/backend/locale/en-US.json index 8c2e843f6..2a4df0bf9 100644 --- a/backend/locale/en-US.json +++ b/backend/locale/en-US.json @@ -252,8 +252,8 @@ "success": "Login success" }, "plugin": { - "install_success": "Plugin {plugin_name} installed successfully, please refer to the plugin documentation (README.md) for related configuration and restart the service", - "uninstall_success": "Plugin {plugin_name} uninstalled successfully, please remove related configuration according to the plugin documentation (README.md) and restart the service" + "install_success": "Plugin {plugin} installed successfully, please refer to the plugin documentation (README.md) for related configuration and restart the service", + "uninstall_success": "Plugin {plugin} uninstalled successfully, please remove related configuration according to the plugin documentation (README.md) and restart the service" } } -} \ No newline at end of file +} diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml index 90d4fd344..e1ee4dc67 100644 --- a/backend/locale/zh-CN.yml +++ b/backend/locale/zh-CN.yml @@ -226,5 +226,5 @@ success: oauth2_success: 登录成功(OAuth2) success: 登录成功 plugin: - install_success: '插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' - uninstall_success: '插件 {plugin_name} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' + install_success: '插件 {plugin} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' + uninstall_success: '插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' From d01e04dc83660cf62a2e922d9c1390531d56fa90 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 22:39:00 +0800 Subject: [PATCH 08/11] Fix lint --- backend/app/admin/api/v1/sys/plugin.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index 2da1a0ee5..1b07ca462 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -45,9 +45,7 @@ async def install_plugin( repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None, ) -> ResponseModel: plugin = await plugin_service.install(type=type, file=file, repo_url=repo_url) - return response_base.success( - res=CustomResponse(code=200, msg=t('success.plugin.install_success', plugin=plugin)) - ) + return response_base.success(res=CustomResponse(code=200, msg=t('success.plugin.install_success', plugin=plugin))) @router.delete( @@ -61,9 +59,7 @@ async def install_plugin( ) async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: await plugin_service.uninstall(plugin=plugin) - return response_base.success( - res=CustomResponse(code=200, msg=t('success.plugin.uninstall_success', plugin=plugin)) - ) + return response_base.success(res=CustomResponse(code=200, msg=t('success.plugin.uninstall_success', plugin=plugin))) @router.put( From d29568681c2b9dcf079da4b7b41eddd562e5e1c3 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 14 Aug 2025 23:12:31 +0800 Subject: [PATCH 09/11] Update pydantic error message translation --- backend/common/exception/exception_handler.py | 28 +++--- backend/locale/en-US.json | 99 ------------------- 2 files changed, 15 insertions(+), 112 deletions(-) diff --git a/backend/common/exception/exception_handler.py b/backend/common/exception/exception_handler.py index 5fa5e7165..7d70441c3 100644 --- a/backend/common/exception/exception_handler.py +++ b/backend/common/exception/exception_handler.py @@ -8,7 +8,7 @@ from uvicorn.protocols.http.h11_impl import STATUS_PHRASES from backend.common.exception.errors import BaseExceptionMixin -from backend.common.i18n import t +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.core.conf import settings @@ -44,18 +44,20 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation """ errors = [] for error in exc.errors(): - 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 - ) + # 非 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/locale/en-US.json b/backend/locale/en-US.json index 2a4df0bf9..5c33f7dd4 100644 --- a/backend/locale/en-US.json +++ b/backend/locale/en-US.json @@ -142,105 +142,6 @@ "size_exceeded": "Video exceeds maximum limit, please reselect" } }, - "pydantic": { - "arguments_type": "Arguments must be a tuple, list or dict", - "assertion_error": "Assertion failed, {error}", - "bool_parsing": "Input should be a valid boolean, unable to interpret input", - "bool_type": "Input should be a valid boolean", - "bytes_too_long": "Data should have at most {max_length} bytes", - "bytes_too_short": "Data should have at least {min_length} bytes", - "bytes_type": "Input should be valid bytes", - "callable_type": "Input should be a callable object", - "dataclass_exact_type": "Input should be an instance of {class_name}", - "dataclass_type": "Input should be a dict or an instance of {class_name}", - "date_from_datetime_inexact": "Datetime provided to date should have zero time - e.g. be an exact date", - "date_from_datetime_parsing": "Input should be a valid date or datetime, {error}", - "date_future": "Date should be in the future", - "date_past": "Date should be in the past", - "date_parsing": "Input should be a valid date in YYYY-MM-DD format, {error}", - "date_type": "Input should be a valid date", - "datetime_future": "Input should be in the future", - "datetime_object_invalid": "Invalid datetime object, got {error}", - "datetime_past": "Input should be in the past", - "datetime_parsing": "Input should be a valid datetime, {error}", - "datetime_type": "Input should be a valid datetime", - "decimal_max_digits": "Decimal input should have no more than {max_digits} digits in total", - "decimal_max_places": "Decimal input should have no more than {decimal_places} decimal places", - "decimal_parsing": "Input should be a valid decimal number", - "decimal_type": "Decimal input should be an integer, float, string or Decimal object", - "decimal_whole_digits": "Decimal input should have no more than {whole_digits} digits before the decimal point", - "dict_type": "Input should be a valid dict", - "email_parsing": "Input should be a valid email address, {error}", - "email_type": "Input should be a valid email address", - "enum": "Input should be {expected}", - "extra_forbidden": "Extra inputs are not allowed", - "finite_number": "Input should be a finite number", - "float_parsing": "Input should be a valid number, unable to parse string as number", - "float_type": "Input should be a valid number", - "frozen_field": "Field is frozen", - "frozen_instance": "Instance is frozen", - "frozen_set_type": "Input should be a valid frozenset", - "get_attribute_error": "Error getting attribute: {error}", - "greater_than": "Input should be greater than {gt}", - "greater_than_equal": "Input should be greater than or equal to {ge}", - "int_from_float": "Input should be a valid integer, got a number with a decimal part", - "int_parsing": "Input should be a valid integer, unable to parse string as integer", - "int_parsing_size": "Unable to parse input string as integer, exceeds maximum size", - "int_type": "Input should be a valid integer", - "invalid_key": "Key should be a string", - "is_instance_of": "Input should be an instance of {class}", - "is_subclass_of": "Input should be a subclass of {class}", - "iteration_error": "Error iterating object, error: {error}", - "iterable_type": "Input should be iterable", - "json_invalid": "Invalid JSON: {error}", - "json_type": "JSON input should be a string, bytes or bytearray", - "less_than": "Input should be less than {lt}", - "less_than_equal": "Input should be less than or equal to {le}", - "list_type": "Input should be a valid list", - "literal_error": "Input should be {expected}", - "mapping_type": "Input should be a valid mapping, error: {error}", - "missing": "Field is required", - "missing_argument": "Missing required argument", - "missing_keyword_only_argument": "Missing required keyword-only argument", - "missing_positional_only_argument": "Missing required positional-only argument", - "model_attributes_type": "Input should be a valid dict or object with extractable fields", - "model_type": "Input should be a valid dict or an instance of {class_name}", - "multiple_argument_values": "Multiple values provided for argument", - "multiple_of": "Input should be a multiple of {multiple_of}", - "no_such_attribute": "Object has no attribute '{attribute}'", - "none_required": "Input should be None", - "recursion_loop": "Recursion error - circular reference detected", - "set_type": "Input should be a valid set", - "string_pattern_mismatch": "String should match pattern '{pattern}'", - "string_sub_type": "Input should be a string, not an instance of a str subclass", - "string_too_long": "String should have at most {max_length} characters", - "string_too_short": "String should have at least {min_length} characters", - "string_type": "Input should be a valid string", - "string_unicode": "Input should be a valid string, unable to parse raw data as Unicode string", - "time_delta_parsing": "Input should be a valid timedelta, {error}", - "time_delta_type": "Input should be a valid timedelta", - "time_parsing": "Input should be a valid time format, {error}", - "time_type": "Input should be a valid time", - "timezone_aware": "Input should contain timezone information", - "timezone_naive": "Input should not contain timezone information", - "timezone_offset": "Timezone offset should be {tz_expected}, got {tz_actual}", - "too_long": "{field_type} should have at most {max_length} items after validation, not {actual_length}", - "too_short": "{field_type} should have at least {min_length} items after validation, not {actual_length}", - "tuple_type": "Input should be a valid tuple", - "union_tag_invalid": "Input tag '{tag}' found using {discriminator} does not match any expected tags: {expected_tags}", - "union_tag_not_found": "Unable to extract tag using discriminator {discriminator}", - "unexpected_keyword_argument": "Unexpected keyword argument", - "unexpected_positional_argument": "Unexpected positional argument", - "url_parsing": "Input should be a valid URL, {error}", - "url_scheme": "URL scheme should be {expected_schemes}", - "url_syntax_violation": "Input violates strict URL syntax rules, {error}", - "url_too_long": "URL should have at most {max_length} characters", - "url_type": "URL input should be a string or URL", - "uuid_parsing": "Input should be a valid UUID, {error}", - "uuid_type": "UUID input should be a string, bytes or UUID object", - "uuid_version": "Expected UUID version to be {expected_version}", - "value_error": "Value error, {error}" - }, "response": { "error": "Request error", "server_error": "Server internal error", From 246d162bf9e2035081925117d864ae26a9a33127 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Fri, 15 Aug 2025 19:49:49 +0800 Subject: [PATCH 10/11] Update to minimal implementation --- backend/app/admin/api/v1/sys/plugin.py | 13 +- backend/app/admin/service/auth_service.py | 16 +- .../app/admin/service/data_rule_service.py | 11 +- .../app/admin/service/data_scope_service.py | 11 +- backend/app/admin/service/dept_service.py | 19 ++- backend/app/admin/service/menu_service.py | 17 +-- backend/app/admin/service/plugin_service.py | 11 +- backend/app/admin/service/role_service.py | 21 ++- backend/app/admin/service/user_service.py | 53 ++++--- backend/app/task/api/v1/control.py | 5 +- backend/app/task/model/scheduler.py | 3 +- backend/app/task/service/result_service.py | 3 +- backend/app/task/service/scheduler_service.py | 19 ++- backend/app/task/utils/schedulers.py | 5 +- backend/app/task/utils/tzcrontab.py | 5 +- backend/common/exception/exception_handler.py | 6 +- backend/common/response/response_code.py | 2 +- backend/common/security/jwt.py | 25 ++- backend/common/security/permission.py | 5 +- backend/common/security/rbac.py | 9 +- backend/locale/en-US.json | 143 +----------------- backend/locale/zh-CN.yml | 117 -------------- .../service/business_service.py | 5 +- .../code_generator/service/code_service.py | 15 +- .../code_generator/service/column_service.py | 7 +- .../plugin/config/service/config_service.py | 9 +- .../plugin/dict/service/dict_data_service.py | 13 +- .../plugin/dict/service/dict_type_service.py | 9 +- backend/plugin/email/utils/send.py | 3 +- .../plugin/notice/service/notice_service.py | 5 +- backend/plugin/tools.py | 3 +- backend/utils/demo_site.py | 3 +- backend/utils/file_ops.py | 31 ++-- backend/utils/import_parse.py | 3 +- backend/utils/snowflake.py | 9 +- 35 files changed, 175 insertions(+), 459 deletions(-) diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index 1b07ca462..afa219097 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -8,7 +8,6 @@ from backend.app.admin.service.plugin_service import plugin_service from backend.common.enums import PluginType -from backend.common.i18n import t from backend.common.response.response_code import CustomResponse from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth @@ -44,8 +43,12 @@ async def install_plugin( file: Annotated[UploadFile | None, File()] = None, repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None, ) -> ResponseModel: - plugin = await plugin_service.install(type=type, file=file, repo_url=repo_url) - return response_base.success(res=CustomResponse(code=200, msg=t('success.plugin.install_success', plugin=plugin))) + plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url) + return response_base.success( + res=CustomResponse( + code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' + ) + ) @router.delete( @@ -59,7 +62,9 @@ async def install_plugin( ) async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: await plugin_service.uninstall(plugin=plugin) - return response_base.success(res=CustomResponse(code=200, msg=t('success.plugin.uninstall_success', plugin=plugin))) + return response_base.success( + res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') + ) @router.put( diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py index f0fde61a4..2266b4e77 100644 --- a/backend/app/admin/service/auth_service.py +++ b/backend/app/admin/service/auth_service.py @@ -45,16 +45,16 @@ async def user_verify(db: AsyncSession, username: str, password: str) -> User: """ user = await user_dao.get_by_username(db, username) if not user: - raise errors.NotFoundError(msg=t('error.username_or_password_error')) + raise errors.NotFoundError(msg='用户名或密码有误') if user.password is None: - raise errors.AuthorizationError(msg=t('error.username_or_password_error')) + raise errors.AuthorizationError(msg='用户名或密码有误') else: if not password_verify(password, user.password): - raise errors.AuthorizationError(msg=t('error.username_or_password_error')) + raise errors.AuthorizationError(msg='用户名或密码有误') if not user.status: - raise errors.AuthorizationError(msg=t('error.user.locked')) + raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') return user @@ -198,17 +198,17 @@ async def refresh_token(*, request: Request) -> GetNewToken: """ refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) if not refresh_token: - raise errors.RequestError(msg=t('error.refresh_token_expired')) + raise errors.RequestError(msg='Refresh Token 已过期,请重新登录') token_payload = jwt_decode(refresh_token) async with async_db_session() as db: user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') elif not user.status: - raise errors.AuthorizationError(msg=t('error.user.locked')) + raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') if not user.is_multi_login: if await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'): - raise errors.ForbiddenError(msg=t('error.user.login_elsewhere')) + raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码') new_token = await create_new_token( refresh_token, token_payload.session_uuid, diff --git a/backend/app/admin/service/data_rule_service.py b/backend/app/admin/service/data_rule_service.py index 7f87dbd9c..916d07d1a 100644 --- a/backend/app/admin/service/data_rule_service.py +++ b/backend/app/admin/service/data_rule_service.py @@ -13,7 +13,6 @@ UpdateDataRuleParam, ) from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.utils.import_parse import dynamic_import_data_model @@ -33,7 +32,7 @@ async def get(*, pk: int) -> DataRule: async with async_db_session() as db: data_rule = await data_rule_dao.get(db, pk) if not data_rule: - raise errors.NotFoundError(msg=t('error.data_rule.not_found')) + raise errors.NotFoundError(msg='数据规则不存在') return data_rule @staticmethod @@ -50,7 +49,7 @@ async def get_columns(model: str) -> list[GetDataRuleColumnDetail]: :return: """ if model not in settings.DATA_PERMISSION_MODELS: - raise errors.NotFoundError(msg=t('error.data_rule.available_models')) + raise errors.NotFoundError(msg='数据规则可用模型不存在') model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) model_columns = [ @@ -88,7 +87,7 @@ async def create(*, obj: CreateDataRuleParam) -> None: async with async_db_session.begin() as db: data_rule = await data_rule_dao.get_by_name(db, obj.name) if data_rule: - raise errors.ConflictError(msg=t('error.data_rule.exists')) + raise errors.ConflictError(msg='数据规则已存在') await data_rule_dao.create(db, obj) @staticmethod @@ -103,10 +102,10 @@ async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: async with async_db_session.begin() as db: data_rule = await data_rule_dao.get(db, pk) if not data_rule: - raise errors.NotFoundError(msg=t('error.data_rule.not_found')) + raise errors.NotFoundError(msg='数据规则不存在') if data_rule.name != obj.name: if await data_rule_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg=t('error.data_rule.exists')) + raise errors.ConflictError(msg='数据规则已存在') count = await data_rule_dao.update(db, pk, obj) return count diff --git a/backend/app/admin/service/data_scope_service.py b/backend/app/admin/service/data_scope_service.py index bf31e09f7..88099d2fe 100644 --- a/backend/app/admin/service/data_scope_service.py +++ b/backend/app/admin/service/data_scope_service.py @@ -13,7 +13,6 @@ UpdateDataScopeRuleParam, ) from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -33,7 +32,7 @@ async def get(*, pk: int) -> DataScope: async with async_db_session() as db: data_scope = await data_scope_dao.get(db, pk) if not data_scope: - raise errors.NotFoundError(msg=t('error.data_scope.not_found')) + raise errors.NotFoundError(msg='数据范围不存在') return data_scope @staticmethod @@ -54,7 +53,7 @@ async def get_rules(*, pk: int) -> DataScope: async with async_db_session() as db: data_scope = await data_scope_dao.get_with_relation(db, pk) if not data_scope: - raise errors.NotFoundError(msg=t('error.data_scope.not_found')) + raise errors.NotFoundError(msg='数据范围不存在') return data_scope @staticmethod @@ -79,7 +78,7 @@ async def create(*, obj: CreateDataScopeParam) -> None: async with async_db_session.begin() as db: data_scope = await data_scope_dao.get_by_name(db, obj.name) if data_scope: - raise errors.ConflictError(msg=t('error.data_scope.exists')) + raise errors.ConflictError(msg='数据范围已存在') await data_scope_dao.create(db, obj) @staticmethod @@ -94,10 +93,10 @@ async def update(*, pk: int, obj: UpdateDataScopeParam) -> int: async with async_db_session.begin() as db: data_scope = await data_scope_dao.get(db, pk) if not data_scope: - raise errors.NotFoundError(msg=t('error.data_scope.not_found')) + raise errors.NotFoundError(msg='数据范围不存在') if data_scope.name != obj.name: if await data_scope_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg=t('error.data_scope.exists')) + raise errors.ConflictError(msg='数据范围已存在') count = await data_scope_dao.update(db, pk, obj) for role in await data_scope.awaitable_attrs.roles: for user in await role.awaitable_attrs.users: diff --git a/backend/app/admin/service/dept_service.py b/backend/app/admin/service/dept_service.py index 755afbf7f..049e229b4 100644 --- a/backend/app/admin/service/dept_service.py +++ b/backend/app/admin/service/dept_service.py @@ -8,7 +8,6 @@ from backend.app.admin.model import Dept from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -29,7 +28,7 @@ async def get(*, pk: int) -> Dept: async with async_db_session() as db: dept = await dept_dao.get(db, pk) if not dept: - raise errors.NotFoundError(msg=t('error.dept.not_found')) + raise errors.NotFoundError(msg='部门不存在') return dept @staticmethod @@ -62,11 +61,11 @@ async def create(*, obj: CreateDeptParam) -> None: async with async_db_session.begin() as db: dept = await dept_dao.get_by_name(db, obj.name) if dept: - raise errors.ConflictError(msg=t('error.dept.exists')) + raise errors.ConflictError(msg='部门名称已存在') if obj.parent_id: parent_dept = await dept_dao.get(db, obj.parent_id) if not parent_dept: - raise errors.NotFoundError(msg=t('error.dept.not_found')) + raise errors.NotFoundError(msg='父级部门不存在') await dept_dao.create(db, obj) @staticmethod @@ -81,16 +80,16 @@ async def update(*, pk: int, obj: UpdateDeptParam) -> int: async with async_db_session.begin() as db: dept = await dept_dao.get(db, pk) if not dept: - raise errors.NotFoundError(msg=t('error.dept.not_found')) + raise errors.NotFoundError(msg='部门不存在') if dept.name != obj.name: if await dept_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg=t('error.dept.exists')) + raise errors.ConflictError(msg='部门名称已存在') if obj.parent_id: parent_dept = await dept_dao.get(db, obj.parent_id) if not parent_dept: - raise errors.NotFoundError(msg=t('error.dept.parent.not_found')) + raise errors.NotFoundError(msg='父级部门不存在') if obj.parent_id == dept.id: - raise errors.ForbiddenError(msg=t('error.dept.parent.related_self_not_allowed')) + raise errors.ForbiddenError(msg='禁止关联自身为父级') count = await dept_dao.update(db, pk, obj) return count @@ -105,10 +104,10 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: dept = await dept_dao.get_with_relation(db, pk) if dept.users: - raise errors.ConflictError(msg=t('error.dept.exists_users')) + raise errors.ConflictError(msg='部门下存在用户,无法删除') children = await dept_dao.get_children(db, pk) if children: - raise errors.ConflictError(msg=t('error.dept.exists_children')) + raise errors.ConflictError(msg='部门下存在子部门,无法删除') count = await dept_dao.delete(db, pk) for user in dept.users: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') diff --git a/backend/app/admin/service/menu_service.py b/backend/app/admin/service/menu_service.py index afd81b90e..28a0235b1 100644 --- a/backend/app/admin/service/menu_service.py +++ b/backend/app/admin/service/menu_service.py @@ -8,7 +8,6 @@ from backend.app.admin.model import Menu from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -29,7 +28,7 @@ async def get(*, pk: int) -> Menu: async with async_db_session() as db: menu = await menu_dao.get(db, menu_id=pk) if not menu: - raise errors.NotFoundError(msg=t('error.menu.not_found')) + raise errors.NotFoundError(msg='菜单不存在') return menu @staticmethod @@ -79,11 +78,11 @@ async def create(*, obj: CreateMenuParam) -> None: async with async_db_session.begin() as db: title = await menu_dao.get_by_title(db, obj.title) if title: - raise errors.ConflictError(msg=t('error.menu.exists')) + raise errors.ConflictError(msg='菜单标题已存在') if obj.parent_id: parent_menu = await menu_dao.get(db, obj.parent_id) if not parent_menu: - raise errors.NotFoundError(msg=t('error.menu.not_found')) + raise errors.NotFoundError(msg='父级菜单不存在') await menu_dao.create(db, obj) @staticmethod @@ -98,16 +97,16 @@ async def update(*, pk: int, obj: UpdateMenuParam) -> int: async with async_db_session.begin() as db: menu = await menu_dao.get(db, pk) if not menu: - raise errors.NotFoundError(msg=t('error.menu.not_found')) + raise errors.NotFoundError(msg='菜单不存在') if menu.title != obj.title: if await menu_dao.get_by_title(db, obj.title): - raise errors.ConflictError(msg=t('error.menu.exists')) + raise errors.ConflictError(msg='菜单标题已存在') if obj.parent_id: parent_menu = await menu_dao.get(db, obj.parent_id) if not parent_menu: - raise errors.NotFoundError(msg=t('error.menu.parent.not_found')) + raise errors.NotFoundError(msg='父级菜单不存在') if obj.parent_id == menu.id: - raise errors.ForbiddenError(msg=t('error.menu.parent.related_self_not_allowed')) + raise errors.ForbiddenError(msg='禁止关联自身为父级') count = await menu_dao.update(db, pk, obj) for role in await menu.awaitable_attrs.roles: for user in await role.awaitable_attrs.users: @@ -125,7 +124,7 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: children = await menu_dao.get_children(db, pk) if children: - raise errors.ConflictError(msg=t('error.menu.exists_children')) + raise errors.ConflictError(msg='菜单下存在子菜单,无法删除') menu = await menu_dao.get(db, pk) count = await menu_dao.delete(db, pk) if menu: diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py index 78008f8ae..57157dc5c 100644 --- a/backend/app/admin/service/plugin_service.py +++ b/backend/app/admin/service/plugin_service.py @@ -12,7 +12,6 @@ from backend.common.enums import PluginType, StatusType from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client @@ -55,10 +54,10 @@ async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: """ if type == PluginType.zip: if not file: - raise errors.RequestError(msg=t('error.plugin.zip_invalid')) + raise errors.RequestError(msg='ZIP 压缩包不能为空') return await install_zip_plugin(file) if not repo_url: - raise errors.RequestError(msg=t('error.plugin.git_url_invalid')) + raise errors.RequestError(msg='Git 仓库地址不能为空') return await install_git_plugin(repo_url) @staticmethod @@ -71,7 +70,7 @@ async def uninstall(*, plugin: str): """ plugin_dir = os.path.join(PLUGIN_DIR, plugin) if not os.path.exists(plugin_dir): - raise errors.NotFoundError(msg=t('error.plugin.not_found')) + raise errors.NotFoundError(msg='插件不存在') await uninstall_requirements_async(plugin) bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') shutil.move(plugin_dir, bacup_dir) @@ -88,7 +87,7 @@ async def update_status(*, plugin: str): """ plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}') if not plugin_info: - raise errors.NotFoundError(msg=t('error.plugin.not_found')) + raise errors.NotFoundError(msg='插件不存在') plugin_info = json.loads(plugin_info) # 更新持久缓存状态 @@ -110,7 +109,7 @@ async def build(*, plugin: str) -> io.BytesIO: """ plugin_dir = os.path.join(PLUGIN_DIR, plugin) if not os.path.exists(plugin_dir): - raise errors.NotFoundError(msg=t('error.plugin.not_found')) + raise errors.NotFoundError(msg='插件不存在') bio = io.BytesIO() with zipfile.ZipFile(bio, 'w') as zf: diff --git a/backend/app/admin/service/role_service.py b/backend/app/admin/service/role_service.py index ba1b7b0b7..760b666a4 100644 --- a/backend/app/admin/service/role_service.py +++ b/backend/app/admin/service/role_service.py @@ -16,7 +16,6 @@ UpdateRoleScopeParam, ) from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -37,7 +36,7 @@ async def get(*, pk: int) -> Role: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') return role @staticmethod @@ -69,7 +68,7 @@ async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') menu_tree = get_tree_data(role.menus) if role.menus else [] return menu_tree @@ -84,7 +83,7 @@ async def get_scopes(*, pk: int) -> list[int]: async with async_db_session() as db: role = await role_dao.get_with_relation(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') scope_ids = [scope.id for scope in role.scopes] return scope_ids @@ -99,7 +98,7 @@ async def create(*, obj: CreateRoleParam) -> None: async with async_db_session.begin() as db: role = await role_dao.get_by_name(db, obj.name) if role: - raise errors.ConflictError(msg=t('error.role.exists')) + raise errors.ConflictError(msg='角色已存在') await role_dao.create(db, obj) @staticmethod @@ -114,10 +113,10 @@ async def update(*, pk: int, obj: UpdateRoleParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') if role.name != obj.name: if await role_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg=t('error.role.exists')) + raise errors.ConflictError(msg='角色已存在') count = await role_dao.update(db, pk, obj) for user in await role.awaitable_attrs.users: await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') @@ -135,11 +134,11 @@ async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') for menu_id in menu_ids.menus: menu = await menu_dao.get(db, menu_id) if not menu: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='菜单不存在') count = await role_dao.update_menus(db, pk, menu_ids) for user in await role.awaitable_attrs.users: await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') @@ -157,11 +156,11 @@ async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int: async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') for scope_id in scope_ids.scopes: scope = await data_scope_dao.get(db, scope_id) if not scope: - raise errors.NotFoundError(msg=t('error.data_scope.not_found')) + raise errors.NotFoundError(msg='数据范围不存在') count = await role_dao.update_scopes(db, pk, scope_ids) for user in await role.awaitable_attrs.users: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') diff --git a/backend/app/admin/service/user_service.py b/backend/app/admin/service/user_service.py index 044a73d11..666cee27b 100644 --- a/backend/app/admin/service/user_service.py +++ b/backend/app/admin/service/user_service.py @@ -18,7 +18,6 @@ ) from backend.common.enums import UserPermissionType from backend.common.exception import errors -from backend.common.i18n import t from backend.common.response.response_code import CustomErrorCode from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify from backend.core.conf import settings @@ -41,7 +40,7 @@ async def get_userinfo(*, pk: int | None = None, username: str | None = None) -> async with async_db_session() as db: user = await user_dao.get_with_relation(db, user_id=pk, username=username) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') return user @staticmethod @@ -55,7 +54,7 @@ async def get_roles(*, pk: int) -> Sequence[Role]: async with async_db_session() as db: user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') return user.roles @staticmethod @@ -83,15 +82,15 @@ async def create(*, request: Request, obj: AddUserParam) -> None: async with async_db_session.begin() as db: superuser_verify(request) if await user_dao.get_by_username(db, obj.username): - raise errors.ConflictError(msg=t('error.user.username_exists')) + raise errors.ConflictError(msg='用户名已注册') obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}' if not obj.password: - raise errors.RequestError(msg=t('error.password.required')) + raise errors.RequestError(msg='密码不允许为空') if not await dept_dao.get(db, obj.dept_id): - raise errors.NotFoundError(msg=t('error.dept.not_found')) + raise errors.NotFoundError(msg='部门不存在') for role_id in obj.roles: if not await role_dao.get(db, role_id): - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') await user_dao.add(db, obj) @staticmethod @@ -108,13 +107,13 @@ async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int: superuser_verify(request) user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') if obj.username != user.username: if await user_dao.get_by_username(db, obj.username): - raise errors.ConflictError(msg=t('error.user.username_exists')) + raise errors.ConflictError(msg='用户名已注册') for role_id in obj.roles: if not await role_dao.get(db, role_id): - raise errors.NotFoundError(msg=t('error.role.not_found')) + raise errors.NotFoundError(msg='角色不存在') count = await user_dao.update(db, user, obj) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -135,28 +134,28 @@ async def update_permission(*, request: Request, pk: int, type: UserPermissionTy case UserPermissionType.superuser: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') if pk == request.user.id: - raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) + raise errors.ForbiddenError(msg='禁止修改自身权限') count = await user_dao.set_super(db, pk, not user.status) case UserPermissionType.staff: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') if pk == request.user.id: - raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) + raise errors.ForbiddenError(msg='禁止修改自身权限') count = await user_dao.set_staff(db, pk, not user.is_staff) case UserPermissionType.status: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') if pk == request.user.id: - raise errors.ForbiddenError(msg=t('error.user.perm.edit_self_not_allowed')) + raise errors.ForbiddenError(msg='禁止修改自身权限') count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1) case UserPermissionType.multi_login: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login new_multi_login = not multi_login count = await user_dao.set_multi_login(db, pk, new_multi_login) @@ -175,7 +174,7 @@ async def update_permission(*, request: Request, pk: int, type: UserPermissionTy key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}' await redis_client.delete_prefix(key_prefix) case _: - raise errors.RequestError(msg=t('error.perm.type_not_found')) + raise errors.RequestError(msg='权限类型不存在') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -194,7 +193,7 @@ async def reset_password(*, request: Request, pk: int, password: str) -> int: superuser_verify(request) user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') count = await user_dao.reset_password(db, user.id, password) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', @@ -219,7 +218,7 @@ async def update_nickname(*, request: Request, nickname: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') count = await user_dao.update_nickname(db, token_payload.id, nickname) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -238,7 +237,7 @@ async def update_avatar(*, request: Request, avatar: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') count = await user_dao.update_avatar(db, token_payload.id, avatar) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @@ -258,10 +257,10 @@ async def update_email(*, request: Request, captcha: str, email: str) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') if not captcha_code: - raise errors.RequestError(msg=t('error.captcha.expired')) + raise errors.RequestError(msg='验证码已失效,请重新获取') if captcha != captcha_code: raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') @@ -283,11 +282,11 @@ async def update_password(*, request: Request, obj: ResetPasswordParam) -> int: token_payload = jwt_decode(token) user = await user_dao.get(db, token_payload.id) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') if not password_verify(obj.old_password, user.password): - raise errors.RequestError(msg=t('error.password.old_error')) + raise errors.RequestError(msg='原密码错误') if obj.new_password != obj.confirm_password: - raise errors.RequestError(msg=t('error.password.mismatch')) + raise errors.RequestError(msg='密码输入不一致') count = await user_dao.reset_password(db, user.id, obj.new_password) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', @@ -309,7 +308,7 @@ async def delete(*, pk: int) -> int: async with async_db_session.begin() as db: user = await user_dao.get(db, pk) if not user: - raise errors.NotFoundError(msg=t('error.user.not_found')) + raise errors.NotFoundError(msg='用户不存在') count = await user_dao.delete(db, user.id) key_prefix = [ f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', diff --git a/backend/app/task/api/v1/control.py b/backend/app/task/api/v1/control.py index 572fe40a6..7bcb30c07 100644 --- a/backend/app/task/api/v1/control.py +++ b/backend/app/task/api/v1/control.py @@ -8,7 +8,6 @@ from backend.app.task import celery_app from backend.app.task.schema.control import TaskRegisteredDetail from backend.common.exception import errors -from backend.common.i18n import t from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission @@ -22,7 +21,7 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail inspector = celery_app.control.inspect(timeout=0.5) registered = await run_in_threadpool(inspector.registered) if not registered: - raise errors.ServerError(msg=t('error.celery_worker_unavailable')) + raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') task_registered = [] celery_app_tasks = celery_app.tasks for _, tasks in registered.items(): @@ -47,6 +46,6 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail async def revoke_task(task_id: Annotated[str, Path(description='任务 UUID')]) -> ResponseModel: workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5) if not workers: - raise errors.ServerError(msg=t('error.celery_worker_unavailable')) + raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') celery_app.control.revoke(task_id) return response_base.success() diff --git a/backend/app/task/model/scheduler.py b/backend/app/task/model/scheduler.py index 428001545..45488638a 100644 --- a/backend/app/task/model/scheduler.py +++ b/backend/app/task/model/scheduler.py @@ -16,7 +16,6 @@ from sqlalchemy.orm import Mapped, mapped_column from backend.common.exception import errors -from backend.common.i18n import t from backend.common.model import Base, id_key from backend.core.conf import settings from backend.database.redis import redis_client @@ -62,7 +61,7 @@ class TaskScheduler(Base): @staticmethod def before_insert_or_update(mapper, connection, target): if target.expire_seconds is not None and target.expire_time: - raise errors.ConflictError(msg=t('error.expires_and_expire_seconds_conflict')) + raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个') @classmethod def changed(cls, mapper, connection, target): diff --git a/backend/app/task/service/result_service.py b/backend/app/task/service/result_service.py index e09dd07e5..bb7391370 100644 --- a/backend/app/task/service/result_service.py +++ b/backend/app/task/service/result_service.py @@ -6,7 +6,6 @@ from backend.app.task.model.result import TaskResult from backend.app.task.schema.result import DeleteTaskResultParam from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session @@ -22,7 +21,7 @@ async def get(*, pk: int) -> TaskResult: async with async_db_session() as db: result = await task_result_dao.get(db, pk) if not result: - raise errors.NotFoundError(msg=t('error.task.result_not_found')) + raise errors.NotFoundError(msg='任务结果不存在') return result @staticmethod diff --git a/backend/app/task/service/scheduler_service.py b/backend/app/task/service/scheduler_service.py index ebe73dc71..ea3357ee7 100644 --- a/backend/app/task/service/scheduler_service.py +++ b/backend/app/task/service/scheduler_service.py @@ -14,7 +14,6 @@ from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam from backend.app.task.utils.tzcrontab import crontab_verify from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session @@ -32,7 +31,7 @@ async def get(*, pk) -> TaskScheduler | None: async with async_db_session() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) + raise errors.NotFoundError(msg='任务调度不存在') return task_scheduler @staticmethod @@ -64,7 +63,7 @@ async def create(*, obj: CreateTaskSchedulerParam) -> None: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get_by_name(db, obj.name) if task_scheduler: - raise errors.ConflictError(msg=t('error.task.scheduler_exists')) + raise errors.ConflictError(msg='任务调度已存在') if obj.type == TaskSchedulerType.CRONTAB: crontab_verify(obj.crontab) await task_scheduler_dao.create(db, obj) @@ -81,10 +80,10 @@ async def update(*, pk: int, obj: UpdateTaskSchedulerParam) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) + raise errors.NotFoundError(msg='任务调度不存在') if task_scheduler.name != obj.name: if await task_scheduler_dao.get_by_name(db, obj.name): - raise errors.ConflictError(msg=t('error.task.scheduler_exists')) + raise errors.ConflictError(msg='任务调度已存在') if task_scheduler.type == TaskSchedulerType.CRONTAB: crontab_verify(obj.crontab) count = await task_scheduler_dao.update(db, pk, obj) @@ -101,7 +100,7 @@ async def update_status(*, pk: int) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) + raise errors.NotFoundError(msg='任务调度不存在') count = await task_scheduler_dao.set_status(db, pk, not task_scheduler.enabled) return count @@ -116,7 +115,7 @@ async def delete(*, pk) -> int: async with async_db_session.begin() as db: task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) + raise errors.NotFoundError(msg='任务调度不存在') count = await task_scheduler_dao.delete(db, pk) return count @@ -131,15 +130,15 @@ async def execute(*, pk: int) -> None: async with async_db_session() as db: workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5) if not workers: - raise errors.ServerError(msg=t('error.celery_worker_unavailable')) + raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') task_scheduler = await task_scheduler_dao.get(db, pk) if not task_scheduler: - raise errors.NotFoundError(msg=t('error.task.scheduler_not_found')) + raise errors.NotFoundError(msg='任务调度不存在') try: args = json.loads(task_scheduler.args) if task_scheduler.args else None kwargs = json.loads(task_scheduler.kwargs) if task_scheduler.kwargs else None except (TypeError, json.JSONDecodeError): - raise errors.RequestError(msg=t('error.task.params_invalid')) + raise errors.RequestError(msg='执行失败,任务参数非法') else: celery_app.send_task(name=task_scheduler.task, args=args, kwargs=kwargs) diff --git a/backend/app/task/utils/schedulers.py b/backend/app/task/utils/schedulers.py index 84e613feb..2bb410df9 100644 --- a/backend/app/task/utils/schedulers.py +++ b/backend/app/task/utils/schedulers.py @@ -20,7 +20,6 @@ from backend.app.task.schema.scheduler import CreateTaskSchedulerParam from backend.app.task.utils.tzcrontab import TzAwareCrontab, crontab_verify from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -92,7 +91,7 @@ def __init__(self, model: TaskScheduler, app=None): month_of_year=crontab_split[4], ) else: - raise errors.NotFoundError(msg=t('error.task.schedule_not_found', name=self.name)) + raise errors.NotFoundError(msg=f'{self.name} 计划为空!') # logger.debug('Schedule: {}'.format(self.schedule)) except Exception as e: logger.error(f'禁用计划为空的任务 {self.name},详情:{e}') @@ -236,7 +235,7 @@ async def to_model_schedule(name: str, task: str, schedule: schedules.schedule | if not obj: obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump()) else: - raise errors.NotFoundError(msg=t('error.task.scheduler_type_invalid', type=schedule)) + raise errors.NotFoundError(msg=f'暂不支持的计划类型:{schedule}') return obj diff --git a/backend/app/task/utils/tzcrontab.py b/backend/app/task/utils/tzcrontab.py index 2297f9439..2260efc17 100644 --- a/backend/app/task/utils/tzcrontab.py +++ b/backend/app/task/utils/tzcrontab.py @@ -6,7 +6,6 @@ from celery.schedules import ParseException, crontab_parser from backend.common.exception import errors -from backend.common.i18n import t from backend.utils.timezone import timezone @@ -62,7 +61,7 @@ def crontab_verify(crontab: str) -> None: """ crontab_split = crontab.split(' ') if len(crontab_split) != 5: - raise errors.RequestError(msg=t('error.crontab_invalid')) + raise errors.RequestError(msg='Crontab 表达式非法') try: crontab_parser(60, 0).parse(crontab_split[0]) # minute @@ -71,4 +70,4 @@ def crontab_verify(crontab: str) -> None: crontab_parser(31, 1).parse(crontab_split[3]) # day_of_month crontab_parser(12, 1).parse(crontab_split[4]) # month_of_year except ParseException: - raise errors.RequestError(msg=t('error.crontab_invalid')) + raise errors.RequestError(msg='Crontab 表达式非法') diff --git a/backend/common/exception/exception_handler.py b/backend/common/exception/exception_handler.py index 7d70441c3..502c0fab6 100644 --- a/backend/common/exception/exception_handler.py +++ b/backend/common/exception/exception_handler.py @@ -61,13 +61,13 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation errors.append(error) error = errors[0] if error.get('type') == 'json_invalid': - message = t('error.json_parse_failed') + message = 'json解析失败' else: error_input = error.get('input') field = str(error.get('loc')[-1]) error_msg = error.get('msg') - message = f'{field} {error_msg}:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg - msg = f'{t("request_params_invalid", message=message)}' + message = f'{field} {error_msg},输入:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg + msg = f'请求参数非法: {message}' data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None content = { 'code': StandardResponseCode.HTTP_422, diff --git a/backend/common/response/response_code.py b/backend/common/response/response_code.py index f4e84d36b..ca2fca9ac 100644 --- a/backend/common/response/response_code.py +++ b/backend/common/response/response_code.py @@ -27,7 +27,7 @@ class CustomResponseCode(CustomCodeBase): HTTP_200 = (200, 'response.success') HTTP_400 = (400, 'response.error') - HTTP_500 = (500, 'response.server_error') + HTTP_500 = (500, '服务器内部错误') class CustomErrorCode(CustomCodeBase): diff --git a/backend/common/security/jwt.py b/backend/common/security/jwt.py index eec3e8fd5..296f09100 100644 --- a/backend/common/security/jwt.py +++ b/backend/common/security/jwt.py @@ -21,7 +21,6 @@ from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload from backend.common.exception import errors from backend.common.exception.errors import TokenError -from backend.common.i18n import t from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client @@ -101,11 +100,11 @@ def jwt_decode(token: str) -> TokenPayload: user_id = payload.get('sub') expire = payload.get('exp') if not session_uuid or not user_id or not expire: - raise errors.TokenError(msg=t('error.token.invalid')) + raise errors.TokenError(msg='Token 无效') except ExpiredSignatureError: - raise errors.TokenError(msg=t('error.token.expired')) + raise errors.TokenError(msg='Token 已过期') except (JWTError, Exception): - raise errors.TokenError(msg=t('error.token.invalid')) + raise errors.TokenError(msg='Token 无效') return TokenPayload( id=int(user_id), session_uuid=session_uuid, expire_time=timezone.from_datetime(timezone.to_utc(expire)) ) @@ -190,7 +189,7 @@ async def create_new_token( """ redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') if not redis_refresh_token or redis_refresh_token != refresh_token: - raise errors.TokenError(msg=t('error.refresh_token_expired')) + raise errors.TokenError(msg='Refresh Token 已过期,请重新登录') await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}') @@ -228,7 +227,7 @@ def get_token(request: Request) -> str: authorization = request.headers.get('Authorization') scheme, token = get_authorization_scheme_param(authorization) if not authorization or scheme.lower() != 'bearer': - raise errors.TokenError(msg=t('error.token.invalid')) + raise errors.TokenError(msg='Token 无效') return token @@ -244,18 +243,18 @@ async def get_current_user(db: AsyncSession, pk: int) -> User: user = await user_dao.get_with_relation(db, user_id=pk) if not user: - raise errors.TokenError(msg=t('error.token.invalid')) + raise errors.TokenError(msg='Token 无效') if not user.status: - raise errors.AuthorizationError(msg=t('error.user.locked')) + raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员') if user.dept_id: if not user.dept.status: - raise errors.AuthorizationError(msg=t('error.user.dept_locked')) + raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员') if user.dept.del_flag: - raise errors.AuthorizationError(msg=t('error.user.dept_deleted')) + raise errors.AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员') if user.roles: role_status = [role.status for role in user.roles] if all(status == 0 for status in role_status): - raise errors.AuthorizationError(msg=t('error.user.role_locked')) + raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员') return user @@ -283,10 +282,10 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail: user_id = token_payload.id redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') if not redis_token: - raise errors.TokenError(msg=t('error.token.expired')) + raise errors.TokenError(msg='Token 已过期') if token != redis_token: - raise errors.TokenError(msg=t('error.token.invalid')) + raise errors.TokenError(msg='Token 已失效') cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') if not cache_user: diff --git a/backend/common/security/permission.py b/backend/common/security/permission.py index aaab324c7..afa182f83 100644 --- a/backend/common/security/permission.py +++ b/backend/common/security/permission.py @@ -8,7 +8,6 @@ from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings from backend.utils.import_parse import dynamic_import_data_model @@ -92,7 +91,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl # 验证规则模型 rule_model = data_rule.model if rule_model not in settings.DATA_PERMISSION_MODELS: - raise errors.NotFoundError(msg=t('error.data_rule.model_not_found')) + raise errors.NotFoundError(msg='数据规则模型不存在') model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) # 验证规则列 @@ -101,7 +100,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl ] column = data_rule.column if column not in model_columns: - raise errors.NotFoundError(msg=t('error.data_rule.model_column_not_found')) + raise errors.NotFoundError(msg='数据规则模型列不存在') # 构建过滤条件 column_obj = getattr(model_ins, column) diff --git a/backend/common/security/rbac.py b/backend/common/security/rbac.py index 4a080d42f..e40f4217e 100644 --- a/backend/common/security/rbac.py +++ b/backend/common/security/rbac.py @@ -4,7 +4,6 @@ from backend.common.enums import MethodType, StatusType from backend.common.exception import errors -from backend.common.i18n import t from backend.common.log import log from backend.common.security.jwt import DependsJwtAuth from backend.core.conf import settings @@ -39,17 +38,17 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # 检测用户角色 user_roles = request.user.roles if not user_roles or all(status == 0 for status in user_roles): - raise errors.AuthorizationError(msg=t('error.user.no_role')) + raise errors.AuthorizationError(msg='用户未分配角色,请联系系统管理员') # 检测用户所属角色菜单 if not any(len(role.menus) > 0 for role in user_roles): - raise errors.AuthorizationError(msg=t('error.user.no_menu')) + raise errors.AuthorizationError(msg='用户未分配菜单,请联系系统管理员') # 检测后台管理操作权限 method = request.method if method != MethodType.GET or method != MethodType.OPTIONS: if not request.user.is_staff: - raise errors.AuthorizationError(msg=t('error.user.not_staff')) + raise errors.AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员') # RBAC 鉴权 if settings.RBAC_ROLE_MENU_MODE: @@ -82,7 +81,7 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: casbin_verify = getattr(casbin_rbac, 'casbin_verify') except (ImportError, AttributeError) as e: log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}') - raise errors.ServerError(msg=t('error.permission_check_failed')) + raise errors.ServerError(msg='权限校验失败,请联系系统管理员') await casbin_verify(request) diff --git a/backend/locale/en-US.json b/backend/locale/en-US.json index 5c33f7dd4..723d7535a 100644 --- a/backend/locale/en-US.json +++ b/backend/locale/en-US.json @@ -4,157 +4,16 @@ "error": "Captcha error", "expired": "Captcha has expired, please try again" }, - "data_rule": { - "available_models": "Data rule available models do not exist", - "exists": "Data rule already exists", - "model_column_not_found": "Data rule model column does not exist", - "model_not_found": "Data rule model does not exist", - "not_found": "Data rule does not exist" - }, - "data_scope": { - "exists": "Data scope already exists", - "not_found": "Data scope does not exist" - }, - "demo_mode": "This operation is prohibited in demo mode", - "dept": { - "exists": "Department already exists", - "exists_children": "Department has sub-departments, cannot be deleted", - "exists_users": "Department has users, cannot be deleted", - "not_found": "Department does not exist", - "parent": { - "not_found": "Parent department does not exist", - "related_self_not_allowed": "Cannot associate itself as parent" - } - }, - "email_config_missing": "Missing email dynamic configuration, please check system parameter configuration - email configuration", - "expires_and_expire_seconds_conflict": "Only one of expires and expire_seconds can be set", - "file_type_unknown": "Unknown file type", - "image": { - "format_not_supported": "This image format is not supported", - "size_exceeded": "Image exceeds maximum limit, please reselect" - }, - "json_parse_failed": "JSON parsing failed", - "language_not_found": "Current language pack is not initialized or does not exist", - "limit_reached": "Request too frequent, please try again later", - "menu": { - "exists": "Menu already exists", - "exists_children": "Menu has sub-menus, cannot be deleted", - "not_found": "Menu does not exist", - "parent": { - "not_found": "Parent menu does not exist", - "related_self_not_allowed": "Cannot associate itself as parent" - } - }, - "model_parse_failed": "Data model column dynamic parsing failed, please contact system administrator", - "password": { - "mismatch": "Password mismatch", - "old_error": "Old password error", - "required": "Password cannot be empty" - }, - "permission_check_failed": "Permission verification failed, please contact system administrator", - "plugin": { - "already_installed": "This plugin is already installed", - "code_generator": { - "business_exists": "Code generation business already exists", - "business_not_found": "Code generation business does not exist", - "column_exists": "Code generation model column already exists", - "column_name_exists": "Code generation model column name already exists", - "column_not_found": "Code generation model column does not exist", - "column_table_not_found": "Code generation model list does not exist", - "table_business_exists": "Same database table business already exists", - "table_not_found": "Database table does not exist" - }, - "config": { - "exists": "Parameter configuration already exists", - "not_found": "Parameter configuration does not exist" - }, - "dict": { - "data": { - "exists": "Dictionary data already exists", - "not_found": "Dictionary data does not exist" - }, - "type": { - "exists": "Dictionary type already exists", - "not_found": "Dictionary type does not exist" - } - }, - "disabled": "Plugin {plugin} is not enabled, please contact system administrator", - "git_url_invalid": "Git repository URL format is invalid", - "install_failed": "Plugin installation failed, please try again later", - "not_found": "Plugin does not exist", - "notice": { - "not_found": "Notice announcement does not exist" - }, - "zip_content_invalid": "Plugin zip content is invalid", - "zip_invalid": "Plugin zip format is invalid", - "zip_missing_files": "Missing required files in plugin zip" - }, - "rate_limit_exceeded": "Request too frequent, please try again later", - "refresh_token_expired": "Refresh Token has expired, please login again", - "request_params_invalid": "Request parameters invalid: {message}", - "role": { - "exists": "Role already exists", - "not_found": "Role does not exist" - }, - "snowflake": { - "cluster_id_invalid": "Cluster ID must be between 0-{max}", - "node_id_invalid": "Node ID must be between 0-{max}", - "system_time_error": "System time has regressed, refusing to generate ID until {last_timestamp}" - }, - "sql": { - "file_not_found": "SQL script file does not exist", - "syntax_not_allowed": "SQL script file contains illegal operations, only SELECT and INSERT are allowed" - }, - "task": { - "celery_worker_unavailable": "Celery Worker is temporarily unavailable, please try again later", - "crontab_invalid": "Crontab expression is invalid", - "params_invalid": "Execution failed, task parameters are invalid", - "result_not_found": "Task result does not exist", - "schedule_not_found": "{name} schedule is empty!", - "scheduler_exists": "Task scheduler already exists", - "scheduler_not_found": "Task scheduler does not exist", - "scheduler_type_invalid": "Unsupported schedule type: {type}" - }, - "token": { - "expired": "Token has expired", - "invalid": "Token is invalid" - }, - "upload_file_failed": "File upload failed", - "user": { - "dept_deleted": "User's department has been deleted, please contact system administrator", - "dept_locked": "User's department has been locked, please contact system administrator", - "locked": "User has been locked, please contact system administrator", - "login_elsewhere": "This user has logged in elsewhere, please login again and change password in time", - "no_menu": "User has not been assigned menu, please contact system administrator", - "no_role": "User has not been assigned role, please contact system administrator", - "not_found": "User does not exist", - "not_staff": "User has been prohibited from backend management operations, please contact system administrator", - "perm": { - "edit_self_not_allowed": "Not allowed to modify own permissions", - "type_not_found": "Permission type does not exist" - }, - "role_locked": "User's role has been locked, please contact system administrator" - }, - "username_exists": "Username already exists", - "username_or_password_error": "Username or password error", - "video": { - "format_not_supported": "This video format is not supported", - "size_exceeded": "Video exceeds maximum limit, please reselect" - } + "language_not_found": "Current language pack is not initialized or does not exist" }, "response": { "error": "Request error", - "server_error": "Server internal error", "success": "Request success" }, "success": { "login": { "oauth2_success": "Login success (OAuth2)", "success": "Login success" - }, - "plugin": { - "install_success": "Plugin {plugin} installed successfully, please refer to the plugin documentation (README.md) for related configuration and restart the service", - "uninstall_success": "Plugin {plugin} uninstalled successfully, please remove related configuration according to the plugin documentation (README.md) and restart the service" } } } diff --git a/backend/locale/zh-CN.yml b/backend/locale/zh-CN.yml index e1ee4dc67..82ea7c10f 100644 --- a/backend/locale/zh-CN.yml +++ b/backend/locale/zh-CN.yml @@ -2,120 +2,7 @@ error: captcha: error: 验证码错误 expired: 验证码已过期,请重新获取 - data_rule: - available_models: 数据规则可用模型不存在 - exists: 数据规则已存在 - model_column_not_found: 数据规则模型列不存在 - model_not_found: 数据规则模型不存在 - not_found: 数据规则不存在 - data_scope: - exists: 数据范围已存在 - not_found: 数据范围不存在 - demo_mode: 演示环境下禁止执行此操作 - dept: - exists: 部门已存在 - exists_children: 部门存在子部门,无法删除 - exists_users: 部门存在用户,无法删除 - not_found: 部门不存在 - parent: - not_found: 父部门不存在 - related_self_not_allowed: 禁止关联自身为父级 - email_config_missing: 缺少邮件动态配置,请检查系统参数配置-邮件配置 - expires_and_expire_seconds_conflict: expires 和 expire_seconds 只能设置一个 - file_type_unknown: 未知的文件类型 - image: - format_not_supported: 此图片格式暂不支持 - size_exceeded: 图片超出最大限制,请重新选择 - json_parse_failed: json 解析失败 language_not_found: 当前语言包未初始化或不存在 - limit_reached: 请求过于频繁,请稍后重试 - menu: - exists: 菜单已存在 - exists_children: 菜单存在子菜单,无法删除 - not_found: 菜单不存在 - parent: - not_found: 父菜单不存在 - related_self_not_allowed: 禁止关联自身为父级 - model_parse_failed: 数据模型列动态解析失败,请联系系统超级管理员 - password: - mismatch: 密码输入不一致 - old_error: 原密码错误 - required: 密码不允许为空 - permission_check_failed: 权限校验失败,请联系系统管理员 - plugin: - already_installed: 此插件已安装 - code_generator: - business_exists: 代码生成业务已存在 - business_not_found: 代码生成业务不存在 - column_exists: 代码生成模型列已存在 - column_name_exists: 代码生成模型列名称已存在 - column_not_found: 代码生成模型列不存在 - column_table_not_found: 代码生成模型列表不存在 - table_business_exists: 已存在相同数据库表业务 - table_not_found: 数据库表不存在 - config: - exists: 参数配置已存在 - not_found: 参数配置不存在 - dict: - data: - exists: 字典数据已存在 - not_found: 字典数据不存在 - type: - exists: 字典类型已存在 - not_found: 字典类型不存在 - disabled: 插件 {plugin} 未启用,请联系系统管理员 - git_url_invalid: Git 仓库地址格式非法 - install_failed: 插件安装失败,请稍后重试 - not_found: 插件不存在 - notice: - not_found: 通知公告不存在 - zip_content_invalid: 插件压缩包内容非法 - zip_invalid: 插件压缩包格式非法 - zip_missing_files: 插件压缩包内缺少必要文件 - rate_limit_exceeded: 请求过于频繁,请稍后重试 - refresh_token_expired: Refresh Token 已过期,请重新登录 - request_params_invalid: 请求参数非法:{message} - role: - exists: 角色已存在 - not_found: 角色不存在 - snowflake: - cluster_id_invalid: 集群编号必须在 0-{max} 之间 - node_id_invalid: 节点编号必须在 0-{max} 之间 - system_time_error: 系统时间倒退,拒绝生成 ID 直到 {last_timestamp} - sql: - file_not_found: SQL 脚本文件不存在 - syntax_not_allowed: SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT - task: - celery_worker_unavailable: Celery Worker 暂不可用,请稍后重试 - crontab_invalid: Crontab 表达式非法 - params_invalid: 执行失败,任务参数非法 - result_not_found: 任务结果不存在 - schedule_not_found: '{name} 计划为空!' - scheduler_exists: 任务调度已存在 - scheduler_not_found: 任务调度不存在 - scheduler_type_invalid: 暂不支持的计划类型:{type} - token: - expired: Token 已过期 - invalid: Token 无效 - upload_file_failed: 上传文件失败 - user: - dept_deleted: 用户所属部门已被删除,请联系系统管理员 - dept_locked: 用户所属部门已被锁定,请联系系统管理员 - locked: '用户已被锁定, 请联系统管理员' - login_elsewhere: 此用户已在异地登录,请重新登录并及时修改密码 - no_menu: 用户未分配菜单,请联系系统管理员 - no_role: 用户未分配角色,请联系系统管理员 - not_found: 用户不存在 - not_staff: 用户已被禁止后台管理操作,请联系系统管理员 - perm: - edit_self_not_allowed: 禁止修改自身权限 - type_not_found: 权限类型不存在 - role_locked: 用户所属角色已被锁定,请联系系统管理员 - username_exists: 用户名已存在 - username_or_password_error: 用户名或密码有误 - video: - format_not_supported: 此视频格式暂不支持 - size_exceeded: 视频超出最大限制,请重新选择 pydantic: # 自定义验证错误信息,参考: # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 @@ -219,12 +106,8 @@ pydantic: value_error: '值错误,{error}' response: error: 请求错误 - server_error: 服务器内部错误 success: 请求成功 success: login: oauth2_success: 登录成功(OAuth2) success: 登录成功 - plugin: - install_success: '插件 {plugin} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' - uninstall_success: '插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务' diff --git a/backend/plugin/code_generator/service/business_service.py b/backend/plugin/code_generator/service/business_service.py index 39e489e70..7c829615e 100644 --- a/backend/plugin/code_generator/service/business_service.py +++ b/backend/plugin/code_generator/service/business_service.py @@ -5,7 +5,6 @@ from sqlalchemy import Select from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_business import gen_business_dao from backend.plugin.code_generator.model import GenBusiness @@ -26,7 +25,7 @@ async def get(*, pk: int) -> GenBusiness: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) + raise errors.NotFoundError(msg='代码生成业务不存在') return business @staticmethod @@ -56,7 +55,7 @@ async def create(*, obj: CreateGenBusinessParam) -> None: async with async_db_session.begin() as db: business = await gen_business_dao.get_by_name(db, obj.table_name) if business: - raise errors.ConflictError(msg=t('error.plugin.code_generator.business_exists')) + raise errors.ConflictError(msg='代码生成业务已存在') await gen_business_dao.create(db, obj) @staticmethod diff --git a/backend/plugin/code_generator/service/code_service.py b/backend/plugin/code_generator/service/code_service.py index a8ef95c21..f93fe0535 100644 --- a/backend/plugin/code_generator/service/code_service.py +++ b/backend/plugin/code_generator/service/code_service.py @@ -13,7 +13,6 @@ from sqlalchemy import RowMapping from backend.common.exception import errors -from backend.common.i18n import t from backend.core.path_conf import BASE_PATH from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_business import gen_business_dao @@ -53,11 +52,11 @@ async def import_business_and_model(*, obj: ImportParam) -> None: async with async_db_session.begin() as db: table_info = await gen_dao.get_table(db, obj.table_name) if not table_info: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.table_not_found')) + raise errors.NotFoundError(msg='数据库表不存在') business_info = await gen_business_dao.get_by_name(db, obj.table_name) if business_info: - raise errors.ConflictError(msg=t('error.plugin.code_generator.table_business_exists')) + raise errors.ConflictError(msg='已存在相同数据库表业务') table_name = table_info[0] new_business = GenBusiness( @@ -103,7 +102,7 @@ async def render_tpl_code(*, business: GenBusiness) -> dict[str, str]: """ gen_models = await gen_column_service.get_columns(business_id=business.id) if not gen_models: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.column_table_not_found')) + raise errors.NotFoundError(msg='代码生成模型表为空') gen_vars = gen_template.get_vars(business, gen_models) return { @@ -121,7 +120,7 @@ async def preview(self, *, pk: int) -> dict[str, bytes]: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) + raise errors.NotFoundError(msg='业务不存在') tpl_code_map = await self.render_tpl_code(business=business) @@ -156,7 +155,7 @@ async def get_generate_path(*, pk: int) -> list[str]: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) + raise errors.NotFoundError(msg='业务不存在') gen_path = business.gen_path or 'fba-backend-app-dir' target_files = gen_template.get_code_gen_paths(business) @@ -173,7 +172,7 @@ async def generate(self, *, pk: int) -> None: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) + raise errors.NotFoundError(msg='业务不存在') tpl_code_map = await self.render_tpl_code(business=business) gen_path = business.gen_path or os.path.join(BASE_PATH, 'app') @@ -228,7 +227,7 @@ async def download(self, *, pk: int) -> io.BytesIO: async with async_db_session() as db: business = await gen_business_dao.get(db, pk) if not business: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.business_not_found')) + raise errors.NotFoundError(msg='业务不存在') bio = io.BytesIO() with zipfile.ZipFile(bio, 'w') as zf: diff --git a/backend/plugin/code_generator/service/column_service.py b/backend/plugin/code_generator/service/column_service.py index f674610d2..ef501c55e 100644 --- a/backend/plugin/code_generator/service/column_service.py +++ b/backend/plugin/code_generator/service/column_service.py @@ -3,7 +3,6 @@ from typing import Sequence from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.code_generator.crud.crud_column import gen_column_dao from backend.plugin.code_generator.enums import GenMySQLColumnType @@ -26,7 +25,7 @@ async def get(*, pk: int) -> GenColumn: async with async_db_session() as db: column = await gen_column_dao.get(db, pk) if not column: - raise errors.NotFoundError(msg=t('error.plugin.code_generator.column_not_found')) + raise errors.NotFoundError(msg='代码生成模型列不存在') return column @staticmethod @@ -58,7 +57,7 @@ async def create(*, obj: CreateGenColumnParam) -> None: async with async_db_session.begin() as db: gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) if obj.name in [gen_column.name for gen_column in gen_columns]: - raise errors.ForbiddenError(msg=t('error.plugin.code_generator.column_exists')) + raise errors.ForbiddenError(msg='模型列已存在') pd_type = sql_type_to_pydantic(obj.type) await gen_column_dao.create(db, obj, pd_type=pd_type) @@ -77,7 +76,7 @@ async def update(*, pk: int, obj: UpdateGenColumnParam) -> int: if obj.name != column.name: gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) if obj.name in [gen_column.name for gen_column in gen_columns]: - raise errors.ConflictError(msg=t('error.plugin.code_generator.column_name_exists')) + raise errors.ConflictError(msg='模型列名已存在') pd_type = sql_type_to_pydantic(obj.type) return await gen_column_dao.update(db, pk, obj, pd_type=pd_type) diff --git a/backend/plugin/config/service/config_service.py b/backend/plugin/config/service/config_service.py index 016732d17..d94228f0e 100644 --- a/backend/plugin/config/service/config_service.py +++ b/backend/plugin/config/service/config_service.py @@ -4,7 +4,6 @@ from sqlalchemy import Select from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.config.crud.crud_config import config_dao from backend.plugin.config.model import Config @@ -28,7 +27,7 @@ async def get(*, pk: int) -> Config: async with async_db_session() as db: config = await config_dao.get(db, pk) if not config: - raise errors.NotFoundError(msg=t('error.plugin.config.not_found')) + raise errors.NotFoundError(msg='参数配置不存在') return config @staticmethod @@ -53,7 +52,7 @@ async def create(*, obj: CreateConfigParam) -> None: async with async_db_session.begin() as db: config = await config_dao.get_by_key(db, obj.key) if config: - raise errors.ConflictError(msg=t('error.plugin.config.exists')) + raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在') await config_dao.create(db, obj) @staticmethod @@ -68,11 +67,11 @@ async def update(*, pk: int, obj: UpdateConfigParam) -> int: async with async_db_session.begin() as db: config = await config_dao.get(db, pk) if not config: - raise errors.NotFoundError(msg=t('error.plugin.config.not_found')) + raise errors.NotFoundError(msg='参数配置不存在') if config.key != obj.key: config = await config_dao.get_by_key(db, obj.key) if config: - raise errors.ConflictError(msg=t('error.plugin.config.exists')) + raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在') count = await config_dao.update(db, pk, obj) return count diff --git a/backend/plugin/dict/service/dict_data_service.py b/backend/plugin/dict/service/dict_data_service.py index 31ff6062a..0fba2a523 100644 --- a/backend/plugin/dict/service/dict_data_service.py +++ b/backend/plugin/dict/service/dict_data_service.py @@ -5,7 +5,6 @@ from sqlalchemy import Select from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.dict.crud.crud_dict_data import dict_data_dao from backend.plugin.dict.crud.crud_dict_type import dict_type_dao @@ -27,7 +26,7 @@ async def get(*, pk: int) -> DictData: async with async_db_session() as db: dict_data = await dict_data_dao.get(db, pk) if not dict_data: - raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) + raise errors.NotFoundError(msg='字典数据不存在') return dict_data @staticmethod @@ -65,10 +64,10 @@ async def create(*, obj: CreateDictDataParam) -> None: async with async_db_session.begin() as db: dict_data = await dict_data_dao.get_by_label(db, obj.label) if dict_data: - raise errors.ConflictError(msg=t('error.plugin.dict.data.exists')) + raise errors.ConflictError(msg='字典数据已存在') dict_type = await dict_type_dao.get(db, obj.type_id) if not dict_type: - raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) + raise errors.NotFoundError(msg='字典类型不存在') await dict_data_dao.create(db, obj, dict_type.code) @staticmethod @@ -83,13 +82,13 @@ async def update(*, pk: int, obj: UpdateDictDataParam) -> int: async with async_db_session.begin() as db: dict_data = await dict_data_dao.get(db, pk) if not dict_data: - raise errors.NotFoundError(msg=t('error.plugin.dict.data.not_found')) + raise errors.NotFoundError(msg='字典数据不存在') if dict_data.label != obj.label: if await dict_data_dao.get_by_label(db, obj.label): - raise errors.ConflictError(msg=t('error.plugin.dict.data.exists')) + raise errors.ConflictError(msg='字典数据已存在') dict_type = await dict_type_dao.get(db, obj.type_id) if not dict_type: - raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) + raise errors.NotFoundError(msg='字典类型不存在') count = await dict_data_dao.update(db, pk, obj, dict_type.code) return count diff --git a/backend/plugin/dict/service/dict_type_service.py b/backend/plugin/dict/service/dict_type_service.py index 0c2e0228d..827276ee1 100644 --- a/backend/plugin/dict/service/dict_type_service.py +++ b/backend/plugin/dict/service/dict_type_service.py @@ -3,7 +3,6 @@ from sqlalchemy import Select from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.dict.crud.crud_dict_type import dict_type_dao from backend.plugin.dict.model import DictType @@ -24,7 +23,7 @@ async def get(*, pk) -> DictType: async with async_db_session() as db: dict_type = await dict_type_dao.get(db, pk) if not dict_type: - raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) + raise errors.NotFoundError(msg='字典类型不存在') return dict_type @staticmethod @@ -50,7 +49,7 @@ async def create(*, obj: CreateDictTypeParam) -> None: async with async_db_session.begin() as db: dict_type = await dict_type_dao.get_by_code(db, obj.code) if dict_type: - raise errors.ConflictError(msg=t('error.plugin.dict.type.exists')) + raise errors.ConflictError(msg='字典类型已存在') await dict_type_dao.create(db, obj) @staticmethod @@ -65,10 +64,10 @@ async def update(*, pk: int, obj: UpdateDictTypeParam) -> int: async with async_db_session.begin() as db: dict_type = await dict_type_dao.get(db, pk) if not dict_type: - raise errors.NotFoundError(msg=t('error.plugin.dict.type.not_found')) + raise errors.NotFoundError(msg='字典类型不存在') if dict_type.code != obj.code: if await dict_type_dao.get_by_code(db, obj.code): - raise errors.ConflictError(msg=t('error.plugin.dict.type.exists')) + raise errors.ConflictError(msg='字典类型已存在') count = await dict_type_dao.update(db, pk, obj) return count diff --git a/backend/plugin/email/utils/send.py b/backend/plugin/email/utils/send.py index 24ff26209..03353071f 100644 --- a/backend/plugin/email/utils/send.py +++ b/backend/plugin/email/utils/send.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.common.exception import errors -from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR @@ -87,7 +86,7 @@ def get_config_table(conn): configs = {d['key']: d for d in select_list_serialize(dynamic_email_config)} if configs.get('EMAIL_STATUS'): if len(dynamic_email_config) < 6: - raise errors.NotFoundError(msg=t('error.email_config_missing')) + raise errors.NotFoundError(msg='缺少邮件动态配置,请检查系统参数配置-邮件配置') smtp_client = SMTP( hostname=configs.get('EMAIL_HOST'), port=configs.get('EMAIL_PORT'), diff --git a/backend/plugin/notice/service/notice_service.py b/backend/plugin/notice/service/notice_service.py index e0cccc88e..fa6c88263 100644 --- a/backend/plugin/notice/service/notice_service.py +++ b/backend/plugin/notice/service/notice_service.py @@ -5,7 +5,6 @@ from sqlalchemy import Select from backend.common.exception import errors -from backend.common.i18n import t from backend.database.db import async_db_session from backend.plugin.notice.crud.crud_notice import notice_dao from backend.plugin.notice.model import Notice @@ -26,7 +25,7 @@ async def get(*, pk: int) -> Notice: async with async_db_session() as db: notice = await notice_dao.get(db, pk) if not notice: - raise errors.NotFoundError(msg=t('error.plugin.notice.not_found')) + raise errors.NotFoundError(msg='通知公告不存在') return notice @staticmethod @@ -64,7 +63,7 @@ async def update(*, pk: int, obj: UpdateNoticeParam) -> int: async with async_db_session.begin() as db: notice = await notice_dao.get(db, pk) if not notice: - raise errors.NotFoundError(msg=t('error.plugin.notice.not_found')) + raise errors.NotFoundError(msg='通知公告不存在') count = await notice_dao.update(db, pk, obj) return count diff --git a/backend/plugin/tools.py b/backend/plugin/tools.py index 89c9de9d1..11f8bb3be 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -19,7 +19,6 @@ from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType from backend.common.exception import errors -from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR @@ -389,4 +388,4 @@ async def __call__(self, request: Request) -> None: raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员') if not int(json.loads(plugin_info)['plugin']['enable']): - raise errors.ServerError(msg=t('error.plugin.disabled', plugin=self.plugin)) + raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员') diff --git a/backend/utils/demo_site.py b/backend/utils/demo_site.py index 1c2e51fb0..039577c03 100644 --- a/backend/utils/demo_site.py +++ b/backend/utils/demo_site.py @@ -3,7 +3,6 @@ from fastapi import Request from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings @@ -22,4 +21,4 @@ async def demo_site(request: Request) -> None: and method != 'OPTIONS' and (method, path) not in settings.DEMO_MODE_EXCLUDE ): - raise errors.ForbiddenError(msg=t('error.demo_mode')) + raise errors.ForbiddenError(msg='演示环境下禁止执行此操作') diff --git a/backend/utils/file_ops.py b/backend/utils/file_ops.py index 642f54ac6..3d074b9d5 100644 --- a/backend/utils/file_ops.py +++ b/backend/utils/file_ops.py @@ -13,7 +13,6 @@ from backend.common.enums import FileType from backend.common.exception import errors -from backend.common.i18n import t from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR @@ -47,18 +46,18 @@ def upload_file_verify(file: UploadFile) -> None: filename = file.filename file_ext = filename.split('.')[-1].lower() if not file_ext: - raise errors.RequestError(msg=t('error.file_type_unknown')) + raise errors.RequestError(msg='未知的文件类型') if file_ext == FileType.image: if file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE: - raise errors.RequestError(msg=t('error.image.format_not_supported')) + raise errors.RequestError(msg='此图片格式暂不支持') if file.size > settings.UPLOAD_IMAGE_SIZE_MAX: - raise errors.RequestError(msg=t('error.image.size_exceeded')) + raise errors.RequestError(msg='图片超出最大限制,请重新选择') elif file_ext == FileType.video: if file_ext not in settings.UPLOAD_VIDEO_EXT_INCLUDE: - raise errors.RequestError(msg=t('error.video.format_not_supported')) + raise errors.RequestError(msg='此视频格式暂不支持') if file.size > settings.UPLOAD_VIDEO_SIZE_MAX: - raise errors.RequestError(msg=t('error.video.size_exceeded')) + raise errors.RequestError(msg='视频超出最大限制,请重新选择') async def upload_file(file: UploadFile) -> str: @@ -78,7 +77,7 @@ async def upload_file(file: UploadFile) -> str: await fb.write(content) except Exception as e: log.error(f'上传文件 {filename} 失败:{str(e)}') - raise errors.RequestError(msg=t('error.upload_file_failed')) + raise errors.RequestError(msg='上传文件失败') await file.close() return filename @@ -97,19 +96,19 @@ async def install_zip_plugin(file: UploadFile | str) -> str: contents = await file.read() file_bytes = io.BytesIO(contents) if not zipfile.is_zipfile(file_bytes): - raise errors.RequestError(msg=t('error.plugin.zip_invalid')) + raise errors.RequestError(msg='插件压缩包格式非法') with zipfile.ZipFile(file_bytes) as zf: # 校验压缩包 plugin_namelist = zf.namelist() plugin_dir_name = plugin_namelist[0].split('/')[0] if not plugin_namelist: - raise errors.RequestError(msg=t('error.plugin.zip_content_invalid')) + raise errors.RequestError(msg='插件压缩包内容非法') if ( len(plugin_namelist) <= 3 or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist or f'{plugin_dir_name}/README.md' not in plugin_namelist ): - raise errors.RequestError(msg=t('error.plugin.zip_missing_files')) + raise errors.RequestError(msg='插件压缩包内缺少必要文件') # 插件是否可安装 plugin_name = re.match( @@ -120,7 +119,7 @@ async def install_zip_plugin(file: UploadFile | str) -> str: ).group() full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) if os.path.exists(full_plugin_path): - raise errors.ConflictError(msg=t('error.plugin.already_installed')) + raise errors.ConflictError(msg='此插件已安装') else: os.makedirs(full_plugin_path, exist_ok=True) @@ -149,15 +148,15 @@ async def install_git_plugin(repo_url: str) -> str: """ match = is_git_url(repo_url) if not match: - raise errors.RequestError(msg=t('error.plugin.git_url_invalid')) + raise errors.RequestError(msg='Git 仓库地址格式非法') repo_name = match.group('repo') if os.path.exists(os.path.join(PLUGIN_DIR, repo_name)): - raise errors.ConflictError(msg=t('error.plugin.already_installed')) + raise errors.ConflictError(msg=f'{repo_name} 插件已安装') try: porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) except Exception as e: log.error(f'插件安装失败: {e}') - raise errors.ServerError(msg=t('error.plugin.install_failed')) from e + raise errors.ServerError(msg='插件安装失败,请稍后重试') from e await install_requirements_async(repo_name) await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') @@ -173,7 +172,7 @@ async def parse_sql_script(filepath: str) -> list[str]: :return: """ if not os.path.exists(filepath): - raise errors.NotFoundError(msg=t('error.sql.file_not_found')) + raise errors.NotFoundError(msg='SQL 脚本文件不存在') async with aiofiles.open(filepath, mode='r', encoding='utf-8') as f: contents = await f.read(1024) @@ -183,6 +182,6 @@ async def parse_sql_script(filepath: str) -> list[str]: statements = split(contents) for statement in statements: if not any(statement.lower().startswith(_) for _ in ['select', 'insert']): - raise errors.RequestError(msg=t('error.sql.syntax_not_allowed')) + raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT') return statements diff --git a/backend/utils/import_parse.py b/backend/utils/import_parse.py index d1e535324..55a3e26ef 100644 --- a/backend/utils/import_parse.py +++ b/backend/utils/import_parse.py @@ -6,7 +6,6 @@ from typing import Any, Type, TypeVar from backend.common.exception import errors -from backend.common.i18n import t from backend.common.log import log T = TypeVar('T') @@ -36,4 +35,4 @@ def dynamic_import_data_model(module_path: str) -> Type[T]: return getattr(module, class_name) except (ImportError, AttributeError) as e: log.error(f'动态导入数据模型失败:{e}') - raise errors.ServerError(msg=t('error.model_parse_failed')) + raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员') diff --git a/backend/utils/snowflake.py b/backend/utils/snowflake.py index 777304253..1a2d16249 100644 --- a/backend/utils/snowflake.py +++ b/backend/utils/snowflake.py @@ -6,7 +6,6 @@ from backend.common.dataclasses import SnowflakeInfo from backend.common.exception import errors -from backend.common.i18n import t from backend.core.conf import settings @@ -55,11 +54,9 @@ def __init__( :param sequence: 起始序列号 """ if cluster_id < 0 or cluster_id > SnowflakeConfig.MAX_DATACENTER_ID: - raise errors.RequestError( - msg=t('error.snowflake.cluster_id_invalid', max=SnowflakeConfig.MAX_DATACENTER_ID) - ) + raise errors.RequestError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间') if node_id < 0 or node_id > SnowflakeConfig.MAX_WORKER_ID: - raise errors.RequestError(msg=t('error.snowflake.node_id_invalid', max=SnowflakeConfig.MAX_WORKER_ID)) + raise errors.RequestError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间') self.node_id = node_id self.cluster_id = cluster_id @@ -89,7 +86,7 @@ def generate(self) -> int: timestamp = self._current_millis() if timestamp < self.last_timestamp: - raise errors.ServerError(msg=t('error.snowflake.system_time_error', last_timestamp=self.last_timestamp)) + raise errors.ServerError(msg=f'系统时间倒退,拒绝生成 ID 直到 {self.last_timestamp}') if timestamp == self.last_timestamp: self.sequence = (self.sequence + 1) & SnowflakeConfig.SEQUENCE_MASK From 21e7685502b6416e745e157e95673139d1365a78 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Fri, 15 Aug 2025 19:54:54 +0800 Subject: [PATCH 11/11] Fix minimal missing code --- backend/utils/health_check.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/utils/health_check.py b/backend/utils/health_check.py index 473206e8c..8187eebe0 100644 --- a/backend/utils/health_check.py +++ b/backend/utils/health_check.py @@ -11,7 +11,6 @@ from fastapi.routing import APIRoute 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 StandardResponseCode @@ -42,7 +41,7 @@ async def http_limit_callback(request: Request, response: Response, expire: int) """ expires = ceil(expire / 1000) raise errors.HTTPError( - code=StandardResponseCode.HTTP_429, msg=t('error.limit_reached'), headers={'Retry-After': str(expires)} + code=StandardResponseCode.HTTP_429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)} )