[add] i18n support zh,en
This commit is contained in:
@@ -16,6 +16,7 @@ from . import (
|
||||
file_controller,
|
||||
file_storage_controller,
|
||||
home_page_controller,
|
||||
i18n_controller,
|
||||
implicit_memory_controller,
|
||||
knowledge_controller,
|
||||
knowledgeshare_controller,
|
||||
@@ -94,5 +95,6 @@ manager_router.include_router(memory_working_controller.router)
|
||||
manager_router.include_router(file_storage_controller.router)
|
||||
manager_router.include_router(ontology_controller.router)
|
||||
manager_router.include_router(skill_controller.router)
|
||||
manager_router.include_router(i18n_controller.router)
|
||||
|
||||
__all__ = ["manager_router"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -16,6 +17,7 @@ from app.core.exceptions import BusinessException
|
||||
from app.core.error_codes import BizCode
|
||||
from app.dependencies import get_current_user, oauth2_scheme
|
||||
from app.models.user_model import User
|
||||
from app.i18n.dependencies import get_translator
|
||||
|
||||
# 获取专用日志器
|
||||
auth_logger = get_auth_logger()
|
||||
@@ -26,7 +28,8 @@ router = APIRouter(tags=["Authentication"])
|
||||
@router.post("/token", response_model=ApiResponse)
|
||||
async def login_for_access_token(
|
||||
form_data: TokenRequest,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""用户登录获取token"""
|
||||
auth_logger.info(f"用户登录请求: {form_data.email}")
|
||||
@@ -40,10 +43,10 @@ async def login_for_access_token(
|
||||
invite_info = workspace_service.validate_invite_token(db, form_data.invite)
|
||||
|
||||
if not invite_info.is_valid:
|
||||
raise BusinessException("邀请码无效或已过期", code=BizCode.BAD_REQUEST)
|
||||
raise BusinessException(t("auth.invite.invalid"), code=BizCode.BAD_REQUEST)
|
||||
|
||||
if invite_info.email != form_data.email:
|
||||
raise BusinessException("邀请邮箱与登录邮箱不匹配", code=BizCode.BAD_REQUEST)
|
||||
raise BusinessException(t("auth.invite.email_mismatch"), code=BizCode.BAD_REQUEST)
|
||||
auth_logger.info(f"邀请码验证成功: workspace={invite_info.workspace_name}")
|
||||
try:
|
||||
# 尝试认证用户
|
||||
@@ -69,7 +72,7 @@ async def login_for_access_token(
|
||||
elif e.code == BizCode.PASSWORD_ERROR:
|
||||
# 用户存在但密码错误
|
||||
auth_logger.warning(f"接受邀请失败,密码验证错误: {form_data.email}")
|
||||
raise BusinessException("接受邀请失败,密码验证错误", BizCode.LOGIN_FAILED)
|
||||
raise BusinessException(t("auth.invite.password_verification_failed"), BizCode.LOGIN_FAILED)
|
||||
else:
|
||||
# 其他认证失败情况,直接抛出
|
||||
raise
|
||||
@@ -82,7 +85,7 @@ async def login_for_access_token(
|
||||
except BusinessException as e:
|
||||
|
||||
# 其他认证失败情况,直接抛出
|
||||
raise BusinessException(e.message,BizCode.LOGIN_FAILED)
|
||||
raise BusinessException(e.message, BizCode.LOGIN_FAILED)
|
||||
|
||||
# 创建 tokens
|
||||
access_token, access_token_id = security.create_access_token(subject=user.id)
|
||||
@@ -110,14 +113,15 @@ async def login_for_access_token(
|
||||
expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at
|
||||
),
|
||||
msg="登录成功"
|
||||
msg=t("auth.login.success")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=ApiResponse)
|
||||
async def refresh_token(
|
||||
refresh_request: RefreshTokenRequest,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""刷新token"""
|
||||
auth_logger.info("收到token刷新请求")
|
||||
@@ -125,18 +129,18 @@ async def refresh_token(
|
||||
# 验证 refresh token
|
||||
userId = security.verify_token(refresh_request.refresh_token, "refresh")
|
||||
if not userId:
|
||||
raise BusinessException("无效的refresh token", code=BizCode.TOKEN_INVALID)
|
||||
raise BusinessException(t("auth.token.invalid_refresh_token"), code=BizCode.TOKEN_INVALID)
|
||||
|
||||
# 检查用户是否存在
|
||||
user = auth_service.get_user_by_id(db, userId)
|
||||
if not user:
|
||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
raise BusinessException(t("auth.user.not_found"), code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 检查 refresh token 黑名单
|
||||
if settings.ENABLE_SINGLE_SESSION:
|
||||
refresh_token_id = security.get_token_id(refresh_request.refresh_token)
|
||||
if refresh_token_id and await SessionService.is_token_blacklisted(refresh_token_id):
|
||||
raise BusinessException("Refresh token已失效", code=BizCode.TOKEN_BLACKLISTED)
|
||||
raise BusinessException(t("auth.token.refresh_token_blacklisted"), code=BizCode.TOKEN_BLACKLISTED)
|
||||
|
||||
# 生成新 tokens
|
||||
new_access_token, new_access_token_id = security.create_access_token(subject=user.id)
|
||||
@@ -167,7 +171,7 @@ async def refresh_token(
|
||||
expires_at=access_expires_at,
|
||||
refresh_expires_at=refresh_expires_at
|
||||
),
|
||||
msg="token刷新成功"
|
||||
msg=t("auth.token.refresh_success")
|
||||
)
|
||||
|
||||
|
||||
@@ -175,14 +179,15 @@ async def refresh_token(
|
||||
async def logout(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""登出当前用户:加入token黑名单并清理会话"""
|
||||
auth_logger.info(f"用户 {current_user.username} 请求登出")
|
||||
|
||||
token_id = security.get_token_id(token)
|
||||
if not token_id:
|
||||
raise BusinessException("无效的access token", code=BizCode.TOKEN_INVALID)
|
||||
raise BusinessException(t("auth.token.invalid"), code=BizCode.TOKEN_INVALID)
|
||||
|
||||
# 加入黑名单
|
||||
await SessionService.blacklist_token(token_id)
|
||||
@@ -192,5 +197,5 @@ async def logout(
|
||||
await SessionService.clear_user_session(current_user.username)
|
||||
|
||||
auth_logger.info(f"用户 {current_user.username} 登出成功")
|
||||
return success(msg="登出成功")
|
||||
return success(msg=t("auth.logout.success"))
|
||||
|
||||
|
||||
833
api/app/controllers/i18n_controller.py
Normal file
833
api/app/controllers/i18n_controller.py
Normal file
@@ -0,0 +1,833 @@
|
||||
"""
|
||||
I18n Management API Controller
|
||||
|
||||
This module provides management APIs for:
|
||||
- Language management (list, get, add, update languages)
|
||||
- Translation management (get, update, reload translations)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Callable, Optional
|
||||
|
||||
from app.core.logging_config import get_api_logger
|
||||
from app.core.response_utils import success
|
||||
from app.db import get_db
|
||||
from app.dependencies import get_current_user, get_current_superuser
|
||||
from app.i18n.dependencies import get_translator
|
||||
from app.i18n.service import get_translation_service
|
||||
from app.models.user_model import User
|
||||
from app.schemas.i18n_schema import (
|
||||
LanguageInfo,
|
||||
LanguageListResponse,
|
||||
LanguageCreateRequest,
|
||||
LanguageUpdateRequest,
|
||||
TranslationResponse,
|
||||
TranslationUpdateRequest,
|
||||
MissingTranslationsResponse,
|
||||
ReloadResponse
|
||||
)
|
||||
from app.schemas.response_schema import ApiResponse
|
||||
|
||||
api_logger = get_api_logger()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/i18n",
|
||||
tags=["I18n Management"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Language Management APIs
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/languages", response_model=ApiResponse)
|
||||
def get_languages(
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of all supported languages.
|
||||
|
||||
Returns:
|
||||
List of language information including code, name, and status
|
||||
"""
|
||||
api_logger.info(f"Get languages request from user: {current_user.username}")
|
||||
|
||||
from app.core.config import settings
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Get available locales from translation service
|
||||
available_locales = translation_service.get_available_locales()
|
||||
|
||||
# Build language info list
|
||||
languages = []
|
||||
for locale in available_locales:
|
||||
is_default = locale == settings.I18N_DEFAULT_LANGUAGE
|
||||
is_enabled = locale in settings.I18N_SUPPORTED_LANGUAGES
|
||||
|
||||
# Get native names
|
||||
native_names = {
|
||||
"zh": "中文(简体)",
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"ko": "한국어",
|
||||
"fr": "Français",
|
||||
"de": "Deutsch",
|
||||
"es": "Español"
|
||||
}
|
||||
|
||||
language_info = LanguageInfo(
|
||||
code=locale,
|
||||
name=f"{locale.upper()}",
|
||||
native_name=native_names.get(locale, locale),
|
||||
is_enabled=is_enabled,
|
||||
is_default=is_default
|
||||
)
|
||||
languages.append(language_info)
|
||||
|
||||
response = LanguageListResponse(languages=languages)
|
||||
|
||||
api_logger.info(f"Returning {len(languages)} languages")
|
||||
return success(data=response.dict(), msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/languages/{locale}", response_model=ApiResponse)
|
||||
def get_language(
|
||||
locale: str,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get information about a specific language.
|
||||
|
||||
Args:
|
||||
locale: Language code (e.g., 'zh', 'en')
|
||||
|
||||
Returns:
|
||||
Language information
|
||||
"""
|
||||
api_logger.info(f"Get language info request: locale={locale}, user={current_user.username}")
|
||||
|
||||
from app.core.config import settings
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if locale exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
api_logger.warning(f"Language not found: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
# Build language info
|
||||
is_default = locale == settings.I18N_DEFAULT_LANGUAGE
|
||||
is_enabled = locale in settings.I18N_SUPPORTED_LANGUAGES
|
||||
|
||||
native_names = {
|
||||
"zh": "中文(简体)",
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"ko": "한국어",
|
||||
"fr": "Français",
|
||||
"de": "Deutsch",
|
||||
"es": "Español"
|
||||
}
|
||||
|
||||
language_info = LanguageInfo(
|
||||
code=locale,
|
||||
name=f"{locale.upper()}",
|
||||
native_name=native_names.get(locale, locale),
|
||||
is_enabled=is_enabled,
|
||||
is_default=is_default
|
||||
)
|
||||
|
||||
api_logger.info(f"Returning language info for: {locale}")
|
||||
return success(data=language_info.dict(), msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.post("/languages", response_model=ApiResponse)
|
||||
def add_language(
|
||||
request: LanguageCreateRequest,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Add a new language (admin only).
|
||||
|
||||
Note: This endpoint validates the request but actual language addition
|
||||
requires creating translation files in the locales directory.
|
||||
|
||||
Args:
|
||||
request: Language creation request
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Add language request: code={request.code}, admin={current_user.username}"
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if language already exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if request.code in available_locales:
|
||||
api_logger.warning(f"Language already exists: {request.code}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=t("i18n.language.already_exists", locale=request.code)
|
||||
)
|
||||
|
||||
# Note: Actual language addition requires creating translation files
|
||||
# This endpoint serves as a validation and documentation point
|
||||
|
||||
api_logger.info(
|
||||
f"Language addition validated: {request.code}. "
|
||||
"Translation files need to be created manually."
|
||||
)
|
||||
|
||||
return success(
|
||||
msg=t(
|
||||
"i18n.language.add_instructions",
|
||||
locale=request.code,
|
||||
dir=settings.I18N_CORE_LOCALES_DIR
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.put("/languages/{locale}", response_model=ApiResponse)
|
||||
def update_language(
|
||||
locale: str,
|
||||
request: LanguageUpdateRequest,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Update language configuration (admin only).
|
||||
|
||||
Note: This endpoint validates the request but actual configuration
|
||||
changes require updating environment variables or config files.
|
||||
|
||||
Args:
|
||||
locale: Language code
|
||||
request: Language update request
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Update language request: locale={locale}, admin={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if language exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
api_logger.warning(f"Language not found: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
# Note: Actual configuration changes require updating settings
|
||||
# This endpoint serves as a validation and documentation point
|
||||
|
||||
api_logger.info(
|
||||
f"Language update validated: {locale}. "
|
||||
"Configuration changes require environment variable updates."
|
||||
)
|
||||
|
||||
return success(msg=t("i18n.language.update_instructions", locale=locale))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Translation Management APIs
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/translations", response_model=ApiResponse)
|
||||
def get_all_translations(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all translations for all or specific locale.
|
||||
|
||||
Args:
|
||||
locale: Optional locale filter
|
||||
|
||||
Returns:
|
||||
All translations organized by locale and namespace
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Get all translations request: locale={locale}, user={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
if locale:
|
||||
# Get translations for specific locale
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
translations = {
|
||||
locale: translation_service._cache.get(locale, {})
|
||||
}
|
||||
else:
|
||||
# Get all translations
|
||||
translations = translation_service._cache
|
||||
|
||||
response = TranslationResponse(translations=translations)
|
||||
|
||||
api_logger.info(f"Returning translations for: {locale or 'all locales'}")
|
||||
return success(data=response.dict(), msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/translations/{locale}", response_model=ApiResponse)
|
||||
def get_locale_translations(
|
||||
locale: str,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all translations for a specific locale.
|
||||
|
||||
Args:
|
||||
locale: Language code
|
||||
|
||||
Returns:
|
||||
All translations for the locale organized by namespace
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Get locale translations request: locale={locale}, user={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if locale exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
api_logger.warning(f"Language not found: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
translations = translation_service._cache.get(locale, {})
|
||||
|
||||
api_logger.info(f"Returning {len(translations)} namespaces for locale: {locale}")
|
||||
return success(data={"locale": locale, "translations": translations}, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/translations/{locale}/{namespace}", response_model=ApiResponse)
|
||||
def get_namespace_translations(
|
||||
locale: str,
|
||||
namespace: str,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get translations for a specific namespace in a locale.
|
||||
|
||||
Args:
|
||||
locale: Language code
|
||||
namespace: Translation namespace (e.g., 'common', 'auth')
|
||||
|
||||
Returns:
|
||||
Translations for the specified namespace
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Get namespace translations request: locale={locale}, "
|
||||
f"namespace={namespace}, user={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if locale exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
api_logger.warning(f"Language not found: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
# Get namespace translations
|
||||
locale_translations = translation_service._cache.get(locale, {})
|
||||
namespace_translations = locale_translations.get(namespace, {})
|
||||
|
||||
if not namespace_translations:
|
||||
api_logger.warning(f"Namespace not found: {namespace} in locale: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.namespace.not_found", namespace=namespace, locale=locale)
|
||||
)
|
||||
|
||||
api_logger.info(
|
||||
f"Returning translations for namespace: {namespace} in locale: {locale}"
|
||||
)
|
||||
return success(
|
||||
data={
|
||||
"locale": locale,
|
||||
"namespace": namespace,
|
||||
"translations": namespace_translations
|
||||
},
|
||||
msg=t("common.success.retrieved")
|
||||
)
|
||||
|
||||
|
||||
@router.put("/translations/{locale}/{key:path}", response_model=ApiResponse)
|
||||
def update_translation(
|
||||
locale: str,
|
||||
key: str,
|
||||
request: TranslationUpdateRequest,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Update a single translation (admin only).
|
||||
|
||||
Note: This endpoint validates the request but actual translation updates
|
||||
require modifying translation files in the locales directory.
|
||||
|
||||
Args:
|
||||
locale: Language code
|
||||
key: Translation key (format: "namespace.key.subkey")
|
||||
request: Translation update request
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Update translation request: locale={locale}, key={key}, "
|
||||
f"admin={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# Check if locale exists
|
||||
available_locales = translation_service.get_available_locales()
|
||||
if locale not in available_locales:
|
||||
api_logger.warning(f"Language not found: {locale}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=t("i18n.language.not_found", locale=locale)
|
||||
)
|
||||
|
||||
# Validate key format
|
||||
if "." not in key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=t("i18n.translation.invalid_key_format", key=key)
|
||||
)
|
||||
|
||||
# Note: Actual translation updates require modifying JSON files
|
||||
# This endpoint serves as a validation and documentation point
|
||||
|
||||
api_logger.info(
|
||||
f"Translation update validated: {locale}/{key}. "
|
||||
"Translation files need to be updated manually."
|
||||
)
|
||||
|
||||
return success(
|
||||
msg=t("i18n.translation.update_instructions", locale=locale, key=key)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/translations/missing", response_model=ApiResponse)
|
||||
def get_missing_translations(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of missing translations.
|
||||
|
||||
Compares translations across locales to find missing keys.
|
||||
|
||||
Args:
|
||||
locale: Optional locale to check (defaults to checking all non-default locales)
|
||||
|
||||
Returns:
|
||||
List of missing translation keys
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Get missing translations request: locale={locale}, user={current_user.username}"
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
translation_service = get_translation_service()
|
||||
|
||||
default_locale = settings.I18N_DEFAULT_LANGUAGE
|
||||
available_locales = translation_service.get_available_locales()
|
||||
|
||||
# Get default locale translations as reference
|
||||
default_translations = translation_service._cache.get(default_locale, {})
|
||||
|
||||
# Collect all keys from default locale
|
||||
def collect_keys(data, prefix=""):
|
||||
keys = []
|
||||
for key, value in data.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
keys.extend(collect_keys(value, full_key))
|
||||
else:
|
||||
keys.append(full_key)
|
||||
return keys
|
||||
|
||||
default_keys = set()
|
||||
for namespace, translations in default_translations.items():
|
||||
namespace_keys = collect_keys(translations, namespace)
|
||||
default_keys.update(namespace_keys)
|
||||
|
||||
# Find missing keys in target locale(s)
|
||||
missing_by_locale = {}
|
||||
|
||||
target_locales = [locale] if locale else [
|
||||
loc for loc in available_locales if loc != default_locale
|
||||
]
|
||||
|
||||
for target_locale in target_locales:
|
||||
if target_locale not in available_locales:
|
||||
continue
|
||||
|
||||
target_translations = translation_service._cache.get(target_locale, {})
|
||||
target_keys = set()
|
||||
|
||||
for namespace, translations in target_translations.items():
|
||||
namespace_keys = collect_keys(translations, namespace)
|
||||
target_keys.update(namespace_keys)
|
||||
|
||||
missing_keys = default_keys - target_keys
|
||||
if missing_keys:
|
||||
missing_by_locale[target_locale] = sorted(list(missing_keys))
|
||||
|
||||
response = MissingTranslationsResponse(missing_translations=missing_by_locale)
|
||||
|
||||
total_missing = sum(len(keys) for keys in missing_by_locale.values())
|
||||
api_logger.info(f"Found {total_missing} missing translations across {len(missing_by_locale)} locales")
|
||||
|
||||
return success(data=response.dict(), msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.post("/reload", response_model=ApiResponse)
|
||||
def reload_translations(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Trigger hot reload of translation files (admin only).
|
||||
|
||||
Args:
|
||||
locale: Optional locale to reload (defaults to reloading all locales)
|
||||
|
||||
Returns:
|
||||
Reload status and statistics
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Reload translations request: locale={locale or 'all'}, "
|
||||
f"admin={current_user.username}"
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
if not settings.I18N_ENABLE_HOT_RELOAD:
|
||||
api_logger.warning("Hot reload is disabled in configuration")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=t("i18n.reload.disabled")
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
|
||||
try:
|
||||
# Reload translations
|
||||
translation_service.reload(locale)
|
||||
|
||||
# Get statistics
|
||||
available_locales = translation_service.get_available_locales()
|
||||
reloaded_locales = [locale] if locale else available_locales
|
||||
|
||||
response = ReloadResponse(
|
||||
success=True,
|
||||
reloaded_locales=reloaded_locales,
|
||||
total_locales=len(available_locales)
|
||||
)
|
||||
|
||||
api_logger.info(
|
||||
f"Successfully reloaded translations for: {', '.join(reloaded_locales)}"
|
||||
)
|
||||
|
||||
return success(data=response.dict(), msg=t("i18n.reload.success"))
|
||||
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to reload translations: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=t("i18n.reload.failed", error=str(e))
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Performance Monitoring APIs
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/metrics", response_model=ApiResponse)
|
||||
def get_metrics(
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Get i18n performance metrics (admin only).
|
||||
|
||||
Returns:
|
||||
Performance metrics including:
|
||||
- Request counts
|
||||
- Missing translations
|
||||
- Timing statistics
|
||||
- Locale usage
|
||||
- Error counts
|
||||
"""
|
||||
api_logger.info(f"Get metrics request: admin={current_user.username}")
|
||||
|
||||
translation_service = get_translation_service()
|
||||
metrics = translation_service.get_metrics_summary()
|
||||
|
||||
api_logger.info("Returning i18n metrics")
|
||||
return success(data=metrics, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/metrics/cache", response_model=ApiResponse)
|
||||
def get_cache_stats(
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Get cache statistics (admin only).
|
||||
|
||||
Returns:
|
||||
Cache statistics including:
|
||||
- Hit/miss rates
|
||||
- LRU cache performance
|
||||
- Loaded locales
|
||||
- Memory usage
|
||||
"""
|
||||
api_logger.info(f"Get cache stats request: admin={current_user.username}")
|
||||
|
||||
translation_service = get_translation_service()
|
||||
cache_stats = translation_service.get_cache_stats()
|
||||
memory_usage = translation_service.get_memory_usage()
|
||||
|
||||
data = {
|
||||
"cache": cache_stats,
|
||||
"memory": memory_usage
|
||||
}
|
||||
|
||||
api_logger.info("Returning cache statistics")
|
||||
return success(data=data, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/metrics/prometheus")
|
||||
def get_prometheus_metrics(
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Get metrics in Prometheus format (admin only).
|
||||
|
||||
Returns:
|
||||
Prometheus-formatted metrics as plain text
|
||||
"""
|
||||
api_logger.info(f"Get Prometheus metrics request: admin={current_user.username}")
|
||||
|
||||
from app.i18n.metrics import get_metrics
|
||||
metrics = get_metrics()
|
||||
prometheus_output = metrics.export_prometheus()
|
||||
|
||||
from fastapi.responses import PlainTextResponse
|
||||
return PlainTextResponse(content=prometheus_output)
|
||||
|
||||
|
||||
@router.post("/metrics/reset", response_model=ApiResponse)
|
||||
def reset_metrics(
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Reset all metrics (admin only).
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
api_logger.info(f"Reset metrics request: admin={current_user.username}")
|
||||
|
||||
from app.i18n.metrics import get_metrics
|
||||
metrics = get_metrics()
|
||||
metrics.reset()
|
||||
|
||||
translation_service = get_translation_service()
|
||||
translation_service.cache.reset_stats()
|
||||
|
||||
api_logger.info("Metrics reset completed")
|
||||
return success(msg=t("i18n.metrics.reset_success"))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Missing Translation Logging and Reporting APIs
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/logs/missing", response_model=ApiResponse)
|
||||
def get_missing_translation_logs(
|
||||
locale: Optional[str] = None,
|
||||
limit: Optional[int] = 100,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Get missing translation logs (admin only).
|
||||
|
||||
Returns logged missing translations with context information.
|
||||
|
||||
Args:
|
||||
locale: Optional locale filter
|
||||
limit: Maximum number of entries to return (default: 100)
|
||||
|
||||
Returns:
|
||||
Missing translation logs with context
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Get missing translation logs request: locale={locale}, "
|
||||
f"limit={limit}, admin={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
translation_logger = translation_service.translation_logger
|
||||
|
||||
# Get missing translations
|
||||
missing_translations = translation_logger.get_missing_translations(locale)
|
||||
|
||||
# Get missing with context
|
||||
missing_with_context = translation_logger.get_missing_with_context(locale, limit)
|
||||
|
||||
# Get statistics
|
||||
statistics = translation_logger.get_statistics()
|
||||
|
||||
data = {
|
||||
"missing_translations": missing_translations,
|
||||
"recent_context": missing_with_context,
|
||||
"statistics": statistics
|
||||
}
|
||||
|
||||
api_logger.info(
|
||||
f"Returning {statistics['total_missing']} missing translations"
|
||||
)
|
||||
return success(data=data, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.get("/logs/missing/report", response_model=ApiResponse)
|
||||
def generate_missing_translation_report(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Generate a comprehensive missing translation report (admin only).
|
||||
|
||||
Args:
|
||||
locale: Optional locale filter
|
||||
|
||||
Returns:
|
||||
Comprehensive report with missing translations and statistics
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Generate missing translation report request: locale={locale}, "
|
||||
f"admin={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
translation_logger = translation_service.translation_logger
|
||||
|
||||
# Generate report
|
||||
report = translation_logger.generate_report(locale)
|
||||
|
||||
api_logger.info(
|
||||
f"Generated report with {report['total_missing']} missing translations"
|
||||
)
|
||||
return success(data=report, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.post("/logs/missing/export", response_model=ApiResponse)
|
||||
def export_missing_translations(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Export missing translations to JSON file (admin only).
|
||||
|
||||
Args:
|
||||
locale: Optional locale filter
|
||||
|
||||
Returns:
|
||||
Export status and file path
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Export missing translations request: locale={locale}, "
|
||||
f"admin={current_user.username}"
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
translation_service = get_translation_service()
|
||||
translation_logger = translation_service.translation_logger
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
locale_suffix = f"_{locale}" if locale else "_all"
|
||||
output_file = f"logs/i18n/missing_translations{locale_suffix}_{timestamp}.json"
|
||||
|
||||
# Export to file
|
||||
translation_logger.export_to_json(output_file)
|
||||
|
||||
api_logger.info(f"Missing translations exported to: {output_file}")
|
||||
return success(
|
||||
data={"file_path": output_file},
|
||||
msg=t("i18n.logs.export_success", file=output_file)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/logs/missing", response_model=ApiResponse)
|
||||
def clear_missing_translation_logs(
|
||||
locale: Optional[str] = None,
|
||||
t: Callable = Depends(get_translator),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Clear missing translation logs (admin only).
|
||||
|
||||
Args:
|
||||
locale: Optional locale to clear (clears all if not specified)
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
api_logger.info(
|
||||
f"Clear missing translation logs request: locale={locale or 'all'}, "
|
||||
f"admin={current_user.username}"
|
||||
)
|
||||
|
||||
translation_service = get_translation_service()
|
||||
translation_logger = translation_service.translation_logger
|
||||
|
||||
# Clear logs
|
||||
translation_logger.clear(locale)
|
||||
|
||||
api_logger.info(f"Cleared missing translation logs for: {locale or 'all locales'}")
|
||||
return success(msg=t("i18n.logs.clear_success"))
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException
|
||||
@@ -19,6 +20,7 @@ from app.services import user_service
|
||||
from app.core.logging_config import get_api_logger
|
||||
from app.core.response_utils import success
|
||||
from app.core.security import verify_password
|
||||
from app.i18n.dependencies import get_translator
|
||||
|
||||
# 获取API专用日志器
|
||||
api_logger = get_api_logger()
|
||||
@@ -33,7 +35,8 @@ router = APIRouter(
|
||||
def create_superuser(
|
||||
user: user_schema.UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_superuser: User = Depends(get_current_superuser)
|
||||
current_superuser: User = Depends(get_current_superuser),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""创建超级管理员(仅超级管理员可访问)"""
|
||||
api_logger.info(f"超级管理员创建请求: {user.username}, email: {user.email}")
|
||||
@@ -42,7 +45,7 @@ def create_superuser(
|
||||
api_logger.info(f"超级管理员创建成功: {result.username} (ID: {result.id})")
|
||||
|
||||
result_schema = user_schema.User.model_validate(result)
|
||||
return success(data=result_schema, msg="超级管理员创建成功")
|
||||
return success(data=result_schema, msg=t("users.create.superuser_success"))
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=ApiResponse)
|
||||
@@ -50,6 +53,7 @@ def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""停用用户(软删除)"""
|
||||
api_logger.info(f"用户停用请求: user_id={user_id}, 操作者: {current_user.username}")
|
||||
@@ -57,13 +61,14 @@ def delete_user(
|
||||
db=db, user_id_to_deactivate=user_id, current_user=current_user
|
||||
)
|
||||
api_logger.info(f"用户停用成功: {result.username} (ID: {result.id})")
|
||||
return success(msg="用户停用成功")
|
||||
return success(msg=t("users.delete.deactivate_success"))
|
||||
|
||||
@router.post("/{user_id}/activate", response_model=ApiResponse)
|
||||
def activate_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""激活用户"""
|
||||
api_logger.info(f"用户激活请求: user_id={user_id}, 操作者: {current_user.username}")
|
||||
@@ -74,13 +79,14 @@ def activate_user(
|
||||
api_logger.info(f"用户激活成功: {result.username} (ID: {result.id})")
|
||||
|
||||
result_schema = user_schema.User.model_validate(result)
|
||||
return success(data=result_schema, msg="用户激活成功")
|
||||
return success(data=result_schema, msg=t("users.activate.success"))
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse)
|
||||
def get_current_user_info(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
api_logger.info(f"当前用户信息请求: {current_user.username}")
|
||||
@@ -105,7 +111,7 @@ def get_current_user_info(
|
||||
break
|
||||
|
||||
api_logger.info(f"当前用户信息获取成功: {result.username}, 角色: {result_schema.role}, 工作空间: {result_schema.current_workspace_name}")
|
||||
return success(data=result_schema, msg="用户信息获取成功")
|
||||
return success(data=result_schema, msg=t("users.info.get_success"))
|
||||
|
||||
|
||||
@router.get("/superusers", response_model=ApiResponse)
|
||||
@@ -113,6 +119,7 @@ def get_tenant_superusers(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前租户下的超管账号列表(仅超级管理员可访问)"""
|
||||
api_logger.info(f"获取租户超管列表请求: {current_user.username}")
|
||||
@@ -125,7 +132,7 @@ def get_tenant_superusers(
|
||||
api_logger.info(f"租户超管列表获取成功: count={len(superusers)}")
|
||||
|
||||
superusers_schema = [user_schema.User.model_validate(u) for u in superusers]
|
||||
return success(data=superusers_schema, msg="租户超管列表获取成功")
|
||||
return success(data=superusers_schema, msg=t("users.list.superusers_success"))
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +141,7 @@ def get_user_info_by_id(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""根据用户ID获取用户信息"""
|
||||
api_logger.info(f"获取用户信息请求: user_id={user_id}, 操作者: {current_user.username}")
|
||||
@@ -144,7 +152,7 @@ def get_user_info_by_id(
|
||||
api_logger.info(f"用户信息获取成功: {result.username}")
|
||||
|
||||
result_schema = user_schema.User.model_validate(result)
|
||||
return success(data=result_schema, msg="用户信息获取成功")
|
||||
return success(data=result_schema, msg=t("users.info.get_success"))
|
||||
|
||||
|
||||
@router.put("/change-password", response_model=ApiResponse)
|
||||
@@ -152,6 +160,7 @@ async def change_password(
|
||||
request: ChangePasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""修改当前用户密码"""
|
||||
api_logger.info(f"用户密码修改请求: {current_user.username}")
|
||||
@@ -164,7 +173,7 @@ async def change_password(
|
||||
current_user=current_user
|
||||
)
|
||||
api_logger.info(f"用户密码修改成功: {current_user.username}")
|
||||
return success(msg="密码修改成功")
|
||||
return success(msg=t("auth.password.change_success"))
|
||||
|
||||
|
||||
@router.put("/admin/change-password", response_model=ApiResponse)
|
||||
@@ -172,6 +181,7 @@ async def admin_change_password(
|
||||
request: AdminChangePasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""超级管理员修改指定用户的密码"""
|
||||
api_logger.info(f"管理员密码修改请求: 管理员 {current_user.username} 修改用户 {request.user_id}")
|
||||
@@ -186,16 +196,17 @@ async def admin_change_password(
|
||||
# 根据是否生成了随机密码来构造响应
|
||||
if request.new_password:
|
||||
api_logger.info(f"管理员密码修改成功: 用户 {request.user_id}")
|
||||
return success(msg="密码修改成功")
|
||||
return success(msg=t("auth.password.change_success"))
|
||||
else:
|
||||
api_logger.info(f"管理员密码重置成功: 用户 {request.user_id}, 随机密码已生成")
|
||||
return success(data=generated_password, msg="密码重置成功")
|
||||
return success(data=generated_password, msg=t("auth.password.reset_success"))
|
||||
|
||||
|
||||
@router.post("/verify_pwd", response_model=ApiResponse)
|
||||
def verify_pwd(
|
||||
request: VerifyPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""验证当前用户密码"""
|
||||
api_logger.info(f"用户验证密码请求: {current_user.username}")
|
||||
@@ -203,8 +214,8 @@ def verify_pwd(
|
||||
is_valid = verify_password(request.password, current_user.hashed_password)
|
||||
api_logger.info(f"用户密码验证结果: {current_user.username}, valid={is_valid}")
|
||||
if not is_valid:
|
||||
raise BusinessException("密码验证失败", code=BizCode.VALIDATION_FAILED)
|
||||
return success(data={"valid": is_valid}, msg="验证完成")
|
||||
raise BusinessException(t("users.errors.password_verification_failed"), code=BizCode.VALIDATION_FAILED)
|
||||
return success(data={"valid": is_valid}, msg=t("common.success.retrieved"))
|
||||
|
||||
|
||||
@router.post("/send-email-code", response_model=ApiResponse)
|
||||
@@ -212,6 +223,7 @@ async def send_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""发送邮箱验证码"""
|
||||
api_logger.info(f"用户请求发送邮箱验证码: {current_user.username}, email={request.email}")
|
||||
@@ -219,7 +231,7 @@ async def send_email_code(
|
||||
await user_service.send_email_code_method(db=db, email=request.email, user_id=current_user.id)
|
||||
|
||||
api_logger.info(f"邮箱验证码已发送: {current_user.username}")
|
||||
return success(msg="验证码已发送到您的邮箱,请查收")
|
||||
return success(msg=t("users.email.code_sent"))
|
||||
|
||||
|
||||
@router.put("/change-email", response_model=ApiResponse)
|
||||
@@ -227,6 +239,7 @@ async def change_email(
|
||||
request: VerifyEmailCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""验证验证码并修改邮箱"""
|
||||
api_logger.info(f"用户修改邮箱: {current_user.username}, new_email={request.new_email}")
|
||||
@@ -239,4 +252,51 @@ async def change_email(
|
||||
)
|
||||
|
||||
api_logger.info(f"用户邮箱修改成功: {current_user.username}")
|
||||
return success(msg="邮箱修改成功")
|
||||
return success(msg=t("users.email.change_success"))
|
||||
|
||||
|
||||
|
||||
@router.get("/me/language", response_model=ApiResponse)
|
||||
def get_current_user_language(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前用户的语言偏好"""
|
||||
api_logger.info(f"获取用户语言偏好: {current_user.username}")
|
||||
|
||||
language = user_service.get_user_language_preference(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
current_user=current_user
|
||||
)
|
||||
|
||||
api_logger.info(f"用户语言偏好获取成功: {current_user.username}, language={language}")
|
||||
return success(
|
||||
data=user_schema.LanguagePreferenceResponse(language=language),
|
||||
msg=t("users.language.get_success")
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/language", response_model=ApiResponse)
|
||||
def update_current_user_language(
|
||||
request: user_schema.LanguagePreferenceRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
"""设置当前用户的语言偏好"""
|
||||
api_logger.info(f"更新用户语言偏好: {current_user.username}, language={request.language}")
|
||||
|
||||
updated_user = user_service.update_user_language_preference(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
language=request.language,
|
||||
current_user=current_user
|
||||
)
|
||||
|
||||
api_logger.info(f"用户语言偏好更新成功: {current_user.username}, language={request.language}")
|
||||
return success(
|
||||
data=user_schema.LanguagePreferenceResponse(language=updated_user.preferred_language),
|
||||
msg=t("users.language.update_success")
|
||||
)
|
||||
|
||||
@@ -14,6 +14,12 @@ from app.dependencies import (
|
||||
get_current_user,
|
||||
workspace_access_guard,
|
||||
)
|
||||
from app.i18n.dependencies import get_current_language, get_translator
|
||||
from app.i18n.serializers import (
|
||||
WorkspaceSerializer,
|
||||
WorkspaceMemberSerializer,
|
||||
WorkspaceInviteSerializer
|
||||
)
|
||||
from app.models.tenant_model import Tenants
|
||||
from app.models.user_model import User
|
||||
from app.models.workspace_model import InviteStatus
|
||||
@@ -65,7 +71,9 @@ def get_workspaces(
|
||||
include_current: bool = Query(True, description="是否包含当前工作空间"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenants = Depends(get_current_tenant)
|
||||
current_tenant: Tenants = Depends(get_current_tenant),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前租户下用户参与的所有工作空间
|
||||
|
||||
@@ -88,8 +96,13 @@ def get_workspaces(
|
||||
)
|
||||
|
||||
api_logger.info(f"成功获取 {len(workspaces)} 个工作空间")
|
||||
workspaces_schema = [WorkspaceResponse.model_validate(w) for w in workspaces]
|
||||
return success(data=workspaces_schema, msg="工作空间列表获取成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceSerializer()
|
||||
workspaces_data = [WorkspaceResponse.model_validate(w).model_dump() for w in workspaces]
|
||||
workspaces_i18n = serializer.serialize_list(workspaces_data, language)
|
||||
|
||||
return success(data=workspaces_i18n, msg=t("workspace.list_retrieved"))
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse)
|
||||
@@ -98,6 +111,8 @@ def create_workspace(
|
||||
language_type: str = Header(default="zh", alias="X-Language-Type"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""创建新的工作空间"""
|
||||
from app.core.language_utils import get_language_from_header
|
||||
@@ -118,8 +133,13 @@ def create_workspace(
|
||||
f"工作空间创建成功 - 名称: {workspace.name}, ID: {result.id}, "
|
||||
f"创建者: {current_user.username}, language={language}"
|
||||
)
|
||||
result_schema = WorkspaceResponse.model_validate(result)
|
||||
return success(data=result_schema, msg="工作空间创建成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceSerializer()
|
||||
result_data = WorkspaceResponse.model_validate(result).model_dump()
|
||||
result_i18n = serializer.serialize(result_data, language)
|
||||
|
||||
return success(data=result_i18n, msg=t("workspace.created"))
|
||||
|
||||
@router.put("", response_model=ApiResponse)
|
||||
@cur_workspace_access_guard()
|
||||
@@ -127,6 +147,8 @@ def update_workspace(
|
||||
workspace: WorkspaceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""更新工作空间"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -139,14 +161,21 @@ def update_workspace(
|
||||
user=current_user,
|
||||
)
|
||||
api_logger.info(f"工作空间更新成功 - ID: {workspace_id}, 用户: {current_user.username}")
|
||||
result_schema = WorkspaceResponse.model_validate(result)
|
||||
return success(data=result_schema, msg="工作空间更新成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceSerializer()
|
||||
result_data = WorkspaceResponse.model_validate(result).model_dump()
|
||||
result_i18n = serializer.serialize(result_data, language)
|
||||
|
||||
return success(data=result_i18n, msg=t("workspace.updated"))
|
||||
|
||||
@router.get("/members", response_model=ApiResponse)
|
||||
@cur_workspace_access_guard()
|
||||
def get_cur_workspace_members(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取工作空间成员列表(关系序列化)"""
|
||||
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {current_user.current_workspace_id} 的成员列表")
|
||||
@@ -157,8 +186,14 @@ def get_cur_workspace_members(
|
||||
user=current_user,
|
||||
)
|
||||
api_logger.info(f"工作空间成员列表获取成功 - ID: {current_user.current_workspace_id}, 数量: {len(members)}")
|
||||
|
||||
# 转换为表格项并使用序列化器添加国际化字段
|
||||
table_items = _convert_members_to_table_items(members)
|
||||
return success(data=table_items, msg="工作空间成员列表获取成功")
|
||||
serializer = WorkspaceMemberSerializer()
|
||||
members_data = [item.model_dump() for item in table_items]
|
||||
members_i18n = serializer.serialize_list(members_data, language)
|
||||
|
||||
return success(data=members_i18n, msg=t("workspace.members.list_retrieved"))
|
||||
|
||||
|
||||
@router.put("/members", response_model=ApiResponse)
|
||||
@@ -168,6 +203,7 @@ def update_workspace_members(
|
||||
updates: List[WorkspaceMemberUpdate],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
workspace_id = current_user.current_workspace_id
|
||||
api_logger.info(f"用户 {current_user.username} 请求更新工作空间 {workspace_id} 的成员角色")
|
||||
@@ -178,7 +214,7 @@ def update_workspace_members(
|
||||
user=current_user,
|
||||
)
|
||||
api_logger.info(f"工作空间成员角色更新成功 - ID: {workspace_id}, 数量: {len(members)}")
|
||||
return success(msg="成员角色更新成功")
|
||||
return success(msg=t("workspace.members.role_updated"))
|
||||
|
||||
|
||||
@router.delete("/members/{member_id}", response_model=ApiResponse)
|
||||
@@ -187,6 +223,7 @@ def delete_workspace_member(
|
||||
member_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
workspace_id = current_user.current_workspace_id
|
||||
api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}")
|
||||
@@ -198,7 +235,7 @@ def delete_workspace_member(
|
||||
user=current_user,
|
||||
)
|
||||
api_logger.info(f"工作空间成员删除成功 - ID: {workspace_id}, 成员: {member_id}")
|
||||
return success(msg="成员删除成功")
|
||||
return success(msg=t("workspace.members.deleted"))
|
||||
|
||||
|
||||
# 创建空间协作邀请
|
||||
@@ -208,6 +245,8 @@ def create_workspace_invite(
|
||||
invite_data: WorkspaceInviteCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""创建工作空间邀请"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -220,7 +259,12 @@ def create_workspace_invite(
|
||||
user=current_user
|
||||
)
|
||||
api_logger.info(f"工作空间邀请创建成功 - 工作空间: {workspace_id}, 邮箱: {invite_data.email}")
|
||||
return success(data=result, msg="邀请创建成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceInviteSerializer()
|
||||
result_i18n = serializer.serialize(result, language)
|
||||
|
||||
return success(data=result_i18n, msg=t("workspace.invites.created"))
|
||||
|
||||
|
||||
@router.get("/invites", response_model=ApiResponse)
|
||||
@@ -232,6 +276,8 @@ def get_workspace_invites(
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取工作空间邀请列表"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -246,18 +292,30 @@ def get_workspace_invites(
|
||||
offset=offset
|
||||
)
|
||||
api_logger.info(f"成功获取 {len(invites)} 个邀请记录")
|
||||
return success(data=invites, msg="邀请列表获取成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceInviteSerializer()
|
||||
invites_i18n = serializer.serialize_list(invites, language)
|
||||
|
||||
return success(data=invites_i18n, msg=t("workspace.invites.list_retrieved"))
|
||||
|
||||
|
||||
@public_router.get("/invites/validate/{token}", response_model=ApiResponse)
|
||||
def get_workspace_invite_info(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取工作空间邀请用户信息(无需认证)"""
|
||||
result = workspace_service.validate_invite_token(db=db, token=token)
|
||||
api_logger.info(f"工作空间邀请验证成功 - 邀请: {token}")
|
||||
return success(data=result, msg="邀请验证成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceInviteSerializer()
|
||||
result_i18n = serializer.serialize(result, language)
|
||||
|
||||
return success(data=result_i18n, msg=t("workspace.invites.validated"))
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}", response_model=ApiResponse)
|
||||
@@ -267,6 +325,8 @@ def revoke_workspace_invite(
|
||||
invite_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""撤销工作空间邀请"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -279,7 +339,12 @@ def revoke_workspace_invite(
|
||||
user=current_user
|
||||
)
|
||||
api_logger.info(f"工作空间邀请撤销成功 - 邀请: {invite_id}")
|
||||
return success(data=result, msg="邀请撤销成功")
|
||||
|
||||
# 使用序列化器添加国际化字段
|
||||
serializer = WorkspaceInviteSerializer()
|
||||
result_i18n = serializer.serialize(result, language)
|
||||
|
||||
return success(data=result_i18n, msg=t("workspace.invites.revoked"))
|
||||
|
||||
# ==================== 公开邀请接口(无需认证) ====================
|
||||
|
||||
@@ -302,6 +367,7 @@ def switch_workspace(
|
||||
workspace_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""切换工作空间"""
|
||||
api_logger.info(f"用户 {current_user.username} 请求切换工作空间为 {workspace_id}")
|
||||
@@ -312,7 +378,7 @@ def switch_workspace(
|
||||
user=current_user,
|
||||
)
|
||||
api_logger.info(f"成功切换工作空间为 {workspace_id}")
|
||||
return success(msg="工作空间切换成功")
|
||||
return success(msg=t("workspace.switched"))
|
||||
|
||||
|
||||
@router.get("/storage", response_model=ApiResponse)
|
||||
@@ -320,6 +386,7 @@ def switch_workspace(
|
||||
def get_workspace_storage_type(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前工作空间的存储类型"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -331,7 +398,7 @@ def get_workspace_storage_type(
|
||||
user=current_user
|
||||
)
|
||||
api_logger.info(f"成功获取工作空间 {workspace_id} 的存储类型: {storage_type}")
|
||||
return success(data={"storage_type": storage_type}, msg="存储类型获取成功")
|
||||
return success(data={"storage_type": storage_type}, msg=t("workspace.storage.type_retrieved"))
|
||||
|
||||
|
||||
@router.get("/workspace_models", response_model=ApiResponse)
|
||||
@@ -339,6 +406,8 @@ def get_workspace_storage_type(
|
||||
def workspace_models_configs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
language: str = Depends(get_current_language),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""获取当前工作空间的模型配置(llm, embedding, rerank)"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -354,14 +423,14 @@ def workspace_models_configs(
|
||||
api_logger.warning(f"工作空间 {workspace_id} 不存在或无权访问")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="工作空间不存在或无权访问"
|
||||
detail=t("workspace.not_found")
|
||||
)
|
||||
|
||||
api_logger.info(
|
||||
f"成功获取工作空间 {workspace_id} 的模型配置: "
|
||||
f"llm={configs.get('llm')}, embedding={configs.get('embedding')}, rerank={configs.get('rerank')}"
|
||||
)
|
||||
return success(data=WorkspaceModelsConfig.model_validate(configs), msg="模型配置获取成功")
|
||||
return success(data=WorkspaceModelsConfig.model_validate(configs), msg=t("workspace.models.config_retrieved"))
|
||||
|
||||
|
||||
@router.put("/workspace_models", response_model=ApiResponse)
|
||||
@@ -370,6 +439,7 @@ def update_workspace_models_configs(
|
||||
models_update: WorkspaceModelsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
t: callable = Depends(get_translator)
|
||||
):
|
||||
"""更新当前工作空间的模型配置(llm, embedding, rerank)"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
@@ -386,5 +456,5 @@ def update_workspace_models_configs(
|
||||
f"成功更新工作空间 {workspace_id} 的模型配置: "
|
||||
f"llm={updated_workspace.llm}, embedding={updated_workspace.embedding}, rerank={updated_workspace.rerank}"
|
||||
)
|
||||
return success(data=WorkspaceModelsConfig.model_validate(updated_workspace), msg="模型配置更新成功")
|
||||
return success(data=WorkspaceModelsConfig.model_validate(updated_workspace), msg=t("workspace.models.config_updated"))
|
||||
|
||||
|
||||
@@ -162,6 +162,44 @@ class Settings:
|
||||
# This controls the language used for memory summary titles and other generated content
|
||||
DEFAULT_LANGUAGE: str = os.getenv("DEFAULT_LANGUAGE", "zh")
|
||||
|
||||
# ========================================================================
|
||||
# Internationalization (i18n) Configuration
|
||||
# ========================================================================
|
||||
# Default language for API responses
|
||||
I18N_DEFAULT_LANGUAGE: str = os.getenv("I18N_DEFAULT_LANGUAGE", "zh")
|
||||
|
||||
# Supported languages (comma-separated)
|
||||
I18N_SUPPORTED_LANGUAGES: list[str] = [
|
||||
lang.strip()
|
||||
for lang in os.getenv("I18N_SUPPORTED_LANGUAGES", "zh,en").split(",")
|
||||
if lang.strip()
|
||||
]
|
||||
|
||||
# Core locales directory (community edition)
|
||||
# Use absolute path to work from any working directory
|
||||
I18N_CORE_LOCALES_DIR: str = os.getenv(
|
||||
"I18N_CORE_LOCALES_DIR",
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "locales")
|
||||
)
|
||||
|
||||
# Premium locales directory (enterprise edition, optional)
|
||||
I18N_PREMIUM_LOCALES_DIR: Optional[str] = os.getenv("I18N_PREMIUM_LOCALES_DIR", None)
|
||||
|
||||
# Enable translation cache
|
||||
I18N_ENABLE_TRANSLATION_CACHE: bool = os.getenv("I18N_ENABLE_TRANSLATION_CACHE", "true").lower() == "true"
|
||||
|
||||
# LRU cache size for hot translations
|
||||
I18N_LRU_CACHE_SIZE: int = int(os.getenv("I18N_LRU_CACHE_SIZE", "1000"))
|
||||
|
||||
# Enable hot reload of translation files
|
||||
I18N_ENABLE_HOT_RELOAD: bool = os.getenv("I18N_ENABLE_HOT_RELOAD", "false").lower() == "true"
|
||||
|
||||
# Fallback language when translation is missing
|
||||
I18N_FALLBACK_LANGUAGE: str = os.getenv("I18N_FALLBACK_LANGUAGE", "zh")
|
||||
|
||||
# Log missing translations
|
||||
I18N_LOG_MISSING_TRANSLATIONS: bool = os.getenv("I18N_LOG_MISSING_TRANSLATIONS", "true").lower() == "true"
|
||||
|
||||
# Logging settings
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FORMAT: str = os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
61
api/app/i18n/README.md
Normal file
61
api/app/i18n/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Internationalization (i18n) Module
|
||||
|
||||
This module provides internationalization support for the MemoryBear API.
|
||||
|
||||
## Components
|
||||
|
||||
- `service.py` - Translation service and core translation logic
|
||||
- `middleware.py` - Language detection middleware
|
||||
- `dependencies.py` - FastAPI dependency injection functions
|
||||
- `exceptions.py` - Internationalized exception classes
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Translation
|
||||
|
||||
```python
|
||||
from app.i18n import t
|
||||
|
||||
# Simple translation
|
||||
message = t("common.success.created")
|
||||
|
||||
# Parameterized translation
|
||||
message = t("common.validation.required", field="Name")
|
||||
```
|
||||
|
||||
### Enum Translation
|
||||
|
||||
```python
|
||||
from app.i18n import t_enum
|
||||
|
||||
# Translate enum value
|
||||
role_display = t_enum("workspace_role", "manager")
|
||||
```
|
||||
|
||||
### In FastAPI Endpoints
|
||||
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from app.i18n.dependencies import get_translator
|
||||
|
||||
@router.post("/workspaces")
|
||||
async def create_workspace(
|
||||
data: WorkspaceCreate,
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
workspace = await workspace_service.create(data)
|
||||
return {
|
||||
"success": True,
|
||||
"message": t("workspace.created_successfully"),
|
||||
"data": workspace
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See `app/core/config.py` for i18n configuration options:
|
||||
|
||||
- `I18N_DEFAULT_LANGUAGE` - Default language (default: "zh")
|
||||
- `I18N_SUPPORTED_LANGUAGES` - Supported languages (default: "zh,en")
|
||||
- `I18N_ENABLE_TRANSLATION_CACHE` - Enable caching (default: true)
|
||||
- `I18N_LOG_MISSING_TRANSLATIONS` - Log missing translations (default: true)
|
||||
113
api/app/i18n/__init__.py
Normal file
113
api/app/i18n/__init__.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Internationalization (i18n) module for MemoryBear Enterprise.
|
||||
|
||||
This module provides complete i18n support for the backend API including:
|
||||
- Translation loading from multiple directories (community + enterprise)
|
||||
- Translation service with caching and fallback
|
||||
- Language detection middleware
|
||||
- Dependency injection for FastAPI
|
||||
- Convenience functions for easy usage
|
||||
|
||||
Usage:
|
||||
from app.i18n import t, t_enum
|
||||
|
||||
# Simple translation
|
||||
message = t("common.success.created")
|
||||
|
||||
# Parameterized translation
|
||||
error = t("common.validation.required", field="名称")
|
||||
|
||||
# Enum translation
|
||||
role_display = t_enum("workspace_role", "manager")
|
||||
"""
|
||||
|
||||
from app.i18n.dependencies import (
|
||||
get_current_language,
|
||||
get_enum_translator,
|
||||
get_translator,
|
||||
)
|
||||
from app.i18n.exceptions import (
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
FileNotFoundError,
|
||||
FileTooLargeError,
|
||||
ForbiddenError,
|
||||
I18nException,
|
||||
InternalServerError,
|
||||
InvalidCredentialsError,
|
||||
InvalidFileTypeError,
|
||||
NotFoundError,
|
||||
QuotaExceededError,
|
||||
RateLimitExceededError,
|
||||
ServiceUnavailableError,
|
||||
TenantNotFoundError,
|
||||
TenantSuspendedError,
|
||||
TokenExpiredError,
|
||||
TokenInvalidError,
|
||||
UnauthorizedError,
|
||||
UserAlreadyExistsError,
|
||||
UserNotFoundError,
|
||||
ValidationError,
|
||||
WorkspaceNotFoundError,
|
||||
WorkspacePermissionDeniedError,
|
||||
get_current_locale,
|
||||
set_current_locale,
|
||||
)
|
||||
from app.i18n.loader import TranslationLoader
|
||||
from app.i18n.logger import (
|
||||
TranslationLogger,
|
||||
get_translation_logger,
|
||||
log_missing_translation,
|
||||
log_translation_error,
|
||||
)
|
||||
from app.i18n.middleware import LanguageMiddleware
|
||||
from app.i18n.service import (
|
||||
TranslationService,
|
||||
get_translation_service,
|
||||
t,
|
||||
t_enum,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TranslationLoader",
|
||||
"LanguageMiddleware",
|
||||
"TranslationService",
|
||||
"get_translation_service",
|
||||
"t",
|
||||
"t_enum",
|
||||
"get_current_language",
|
||||
"get_translator",
|
||||
"get_enum_translator",
|
||||
# Context management
|
||||
"get_current_locale",
|
||||
"set_current_locale",
|
||||
# Logging
|
||||
"TranslationLogger",
|
||||
"get_translation_logger",
|
||||
"log_missing_translation",
|
||||
"log_translation_error",
|
||||
# Exception classes
|
||||
"I18nException",
|
||||
"BadRequestError",
|
||||
"UnauthorizedError",
|
||||
"ForbiddenError",
|
||||
"NotFoundError",
|
||||
"ConflictError",
|
||||
"ValidationError",
|
||||
"InternalServerError",
|
||||
"ServiceUnavailableError",
|
||||
"WorkspaceNotFoundError",
|
||||
"WorkspacePermissionDeniedError",
|
||||
"UserNotFoundError",
|
||||
"UserAlreadyExistsError",
|
||||
"TenantNotFoundError",
|
||||
"TenantSuspendedError",
|
||||
"InvalidCredentialsError",
|
||||
"TokenExpiredError",
|
||||
"TokenInvalidError",
|
||||
"FileNotFoundError",
|
||||
"FileTooLargeError",
|
||||
"InvalidFileTypeError",
|
||||
"RateLimitExceededError",
|
||||
"QuotaExceededError",
|
||||
]
|
||||
291
api/app/i18n/cache.py
Normal file
291
api/app/i18n/cache.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Advanced caching system for i18n translations.
|
||||
|
||||
This module provides:
|
||||
- LRU cache for hot translations
|
||||
- Lazy loading mechanism
|
||||
- Memory optimization
|
||||
- Cache statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional
|
||||
from collections import OrderedDict
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranslationCache:
|
||||
"""
|
||||
Advanced translation cache with LRU eviction and lazy loading.
|
||||
|
||||
Features:
|
||||
- LRU cache for frequently accessed translations
|
||||
- Lazy loading to reduce startup time
|
||||
- Memory-efficient storage
|
||||
- Cache hit/miss statistics
|
||||
"""
|
||||
|
||||
def __init__(self, max_lru_size: int = 1000, enable_lazy_load: bool = True):
|
||||
"""
|
||||
Initialize the translation cache.
|
||||
|
||||
Args:
|
||||
max_lru_size: Maximum size of LRU cache for hot translations
|
||||
enable_lazy_load: Enable lazy loading of locales
|
||||
"""
|
||||
self.max_lru_size = max_lru_size
|
||||
self.enable_lazy_load = enable_lazy_load
|
||||
|
||||
# Main cache: {locale: {namespace: {key: value}}}
|
||||
self._main_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# LRU cache for hot translations
|
||||
self._lru_cache: OrderedDict = OrderedDict()
|
||||
|
||||
# Loaded locales tracker
|
||||
self._loaded_locales: set = set()
|
||||
|
||||
# Statistics
|
||||
self._stats = {
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"lru_hits": 0,
|
||||
"lru_misses": 0,
|
||||
"lazy_loads": 0
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"TranslationCache initialized with LRU size: {max_lru_size}, "
|
||||
f"lazy loading: {enable_lazy_load}"
|
||||
)
|
||||
|
||||
def set_locale_data(self, locale: str, data: Dict[str, Any]):
|
||||
"""
|
||||
Set translation data for a locale.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
data: Translation data dictionary
|
||||
"""
|
||||
self._main_cache[locale] = data
|
||||
self._loaded_locales.add(locale)
|
||||
logger.debug(f"Loaded locale '{locale}' into cache")
|
||||
|
||||
def get_translation(
|
||||
self,
|
||||
locale: str,
|
||||
namespace: str,
|
||||
key_path: list
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get translation from cache with LRU optimization.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
namespace: Translation namespace
|
||||
key_path: List of nested keys
|
||||
|
||||
Returns:
|
||||
Translation string or None if not found
|
||||
"""
|
||||
# Build cache key for LRU
|
||||
cache_key = f"{locale}:{namespace}:{'.'.join(key_path)}"
|
||||
|
||||
# Check LRU cache first (hot translations)
|
||||
if cache_key in self._lru_cache:
|
||||
self._stats["lru_hits"] += 1
|
||||
self._stats["hits"] += 1
|
||||
# Move to end (most recently used)
|
||||
self._lru_cache.move_to_end(cache_key)
|
||||
return self._lru_cache[cache_key]
|
||||
|
||||
self._stats["lru_misses"] += 1
|
||||
|
||||
# Check main cache
|
||||
if locale not in self._main_cache:
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
if namespace not in self._main_cache[locale]:
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
# Navigate through nested keys
|
||||
current = self._main_cache[locale][namespace]
|
||||
for key in key_path:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
# Return only if it's a string value
|
||||
if not isinstance(current, str):
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
self._stats["hits"] += 1
|
||||
|
||||
# Add to LRU cache
|
||||
self._add_to_lru(cache_key, current)
|
||||
|
||||
return current
|
||||
|
||||
def _add_to_lru(self, key: str, value: str):
|
||||
"""
|
||||
Add translation to LRU cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Translation value
|
||||
"""
|
||||
# Remove oldest if cache is full
|
||||
if len(self._lru_cache) >= self.max_lru_size:
|
||||
self._lru_cache.popitem(last=False)
|
||||
|
||||
self._lru_cache[key] = value
|
||||
|
||||
def is_locale_loaded(self, locale: str) -> bool:
|
||||
"""
|
||||
Check if a locale is loaded.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
|
||||
Returns:
|
||||
True if locale is loaded
|
||||
"""
|
||||
return locale in self._loaded_locales
|
||||
|
||||
def get_loaded_locales(self) -> list:
|
||||
"""
|
||||
Get list of loaded locales.
|
||||
|
||||
Returns:
|
||||
List of locale codes
|
||||
"""
|
||||
return list(self._loaded_locales)
|
||||
|
||||
def clear_lru(self):
|
||||
"""Clear the LRU cache."""
|
||||
self._lru_cache.clear()
|
||||
logger.info("LRU cache cleared")
|
||||
|
||||
def clear_locale(self, locale: str):
|
||||
"""
|
||||
Clear cache for a specific locale.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
"""
|
||||
if locale in self._main_cache:
|
||||
del self._main_cache[locale]
|
||||
self._loaded_locales.discard(locale)
|
||||
|
||||
# Clear related LRU entries
|
||||
keys_to_remove = [k for k in self._lru_cache if k.startswith(f"{locale}:")]
|
||||
for key in keys_to_remove:
|
||||
del self._lru_cache[key]
|
||||
|
||||
logger.info(f"Cleared cache for locale '{locale}'")
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all caches."""
|
||||
self._main_cache.clear()
|
||||
self._lru_cache.clear()
|
||||
self._loaded_locales.clear()
|
||||
logger.info("All caches cleared")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
total_requests = self._stats["hits"] + self._stats["misses"]
|
||||
hit_rate = (
|
||||
self._stats["hits"] / total_requests * 100
|
||||
if total_requests > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
lru_total = self._stats["lru_hits"] + self._stats["lru_misses"]
|
||||
lru_hit_rate = (
|
||||
self._stats["lru_hits"] / lru_total * 100
|
||||
if lru_total > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_requests": total_requests,
|
||||
"hits": self._stats["hits"],
|
||||
"misses": self._stats["misses"],
|
||||
"hit_rate": round(hit_rate, 2),
|
||||
"lru_hits": self._stats["lru_hits"],
|
||||
"lru_misses": self._stats["lru_misses"],
|
||||
"lru_hit_rate": round(lru_hit_rate, 2),
|
||||
"lru_size": len(self._lru_cache),
|
||||
"lru_max_size": self.max_lru_size,
|
||||
"loaded_locales": len(self._loaded_locales),
|
||||
"lazy_loads": self._stats["lazy_loads"]
|
||||
}
|
||||
|
||||
def reset_stats(self):
|
||||
"""Reset cache statistics."""
|
||||
self._stats = {
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"lru_hits": 0,
|
||||
"lru_misses": 0,
|
||||
"lazy_loads": 0
|
||||
}
|
||||
logger.info("Cache statistics reset")
|
||||
|
||||
def get_memory_usage(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate memory usage of the cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with memory usage information
|
||||
"""
|
||||
import sys
|
||||
|
||||
main_cache_size = sys.getsizeof(self._main_cache)
|
||||
lru_cache_size = sys.getsizeof(self._lru_cache)
|
||||
|
||||
# Rough estimate of nested data
|
||||
for locale_data in self._main_cache.values():
|
||||
main_cache_size += sys.getsizeof(locale_data)
|
||||
for namespace_data in locale_data.values():
|
||||
main_cache_size += sys.getsizeof(namespace_data)
|
||||
|
||||
return {
|
||||
"main_cache_bytes": main_cache_size,
|
||||
"lru_cache_bytes": lru_cache_size,
|
||||
"total_bytes": main_cache_size + lru_cache_size,
|
||||
"main_cache_mb": round(main_cache_size / 1024 / 1024, 2),
|
||||
"lru_cache_mb": round(lru_cache_size / 1024 / 1024, 2),
|
||||
"total_mb": round((main_cache_size + lru_cache_size) / 1024 / 1024, 2)
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_cached_translation_key(locale: str, namespace: str, key: str) -> str:
|
||||
"""
|
||||
LRU cached function for building translation cache keys.
|
||||
|
||||
This reduces string concatenation overhead for frequently accessed keys.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
namespace: Translation namespace
|
||||
key: Translation key
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
return f"{locale}:{namespace}:{key}"
|
||||
158
api/app/i18n/dependencies.py
Normal file
158
api/app/i18n/dependencies.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
FastAPI dependency injection functions for i18n.
|
||||
|
||||
This module provides dependency injection functions that can be used
|
||||
in FastAPI route handlers to access the current language and translator.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.i18n.service import get_translation_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_current_language(request: Request) -> str:
|
||||
"""
|
||||
Get the current language from the request context.
|
||||
|
||||
This dependency extracts the language that was determined by the
|
||||
LanguageMiddleware and stored in request.state.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
Language code (e.g., "zh", "en")
|
||||
|
||||
Usage:
|
||||
@router.get("/example")
|
||||
async def example(language: str = Depends(get_current_language)):
|
||||
return {"language": language}
|
||||
"""
|
||||
# Get language from request state (set by LanguageMiddleware)
|
||||
language = getattr(request.state, "language", None)
|
||||
|
||||
if language is None:
|
||||
# Fallback to default language if not set
|
||||
from app.core.config import settings
|
||||
language = settings.I18N_DEFAULT_LANGUAGE
|
||||
logger.warning(
|
||||
"Language not found in request.state, using default: "
|
||||
f"{language}"
|
||||
)
|
||||
|
||||
return language
|
||||
|
||||
|
||||
async def get_translator(request: Request) -> Callable:
|
||||
"""
|
||||
Get a translator function bound to the current request's language.
|
||||
|
||||
This dependency returns a translation function that automatically
|
||||
uses the current request's language, making it easy to translate
|
||||
strings in route handlers.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
Translation function with signature: t(key: str, **params) -> str
|
||||
|
||||
Usage:
|
||||
@router.post("/workspaces")
|
||||
async def create_workspace(
|
||||
data: WorkspaceCreate,
|
||||
t: Callable = Depends(get_translator)
|
||||
):
|
||||
workspace = await workspace_service.create(data)
|
||||
return {
|
||||
"success": True,
|
||||
"message": t("workspace.created_successfully"),
|
||||
"data": workspace
|
||||
}
|
||||
|
||||
# With parameters
|
||||
@router.get("/items")
|
||||
async def get_items(t: Callable = Depends(get_translator)):
|
||||
count = 5
|
||||
return {
|
||||
"message": t("items.found", count=count)
|
||||
}
|
||||
"""
|
||||
# Get current language
|
||||
language = await get_current_language(request)
|
||||
|
||||
# Get translation service
|
||||
service = get_translation_service()
|
||||
|
||||
# Return a bound translation function
|
||||
def translate(key: str, **params) -> str:
|
||||
"""
|
||||
Translate a key using the current request's language.
|
||||
|
||||
Args:
|
||||
key: Translation key (e.g., "common.success.created")
|
||||
**params: Parameters for parameterized messages
|
||||
|
||||
Returns:
|
||||
Translated string
|
||||
"""
|
||||
return service.translate(key, language, **params)
|
||||
|
||||
return translate
|
||||
|
||||
|
||||
async def get_enum_translator(request: Request) -> Callable:
|
||||
"""
|
||||
Get an enum translator function bound to the current request's language.
|
||||
|
||||
This dependency returns a function for translating enum values
|
||||
that automatically uses the current request's language.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
Enum translation function with signature:
|
||||
t_enum(enum_type: str, value: str) -> str
|
||||
|
||||
Usage:
|
||||
@router.get("/workspace/{id}")
|
||||
async def get_workspace(
|
||||
id: str,
|
||||
t_enum: Callable = Depends(get_enum_translator)
|
||||
):
|
||||
workspace = await workspace_service.get(id)
|
||||
return {
|
||||
"id": workspace.id,
|
||||
"role": workspace.role,
|
||||
"role_display": t_enum("workspace_role", workspace.role),
|
||||
"status": workspace.status,
|
||||
"status_display": t_enum("workspace_status", workspace.status)
|
||||
}
|
||||
"""
|
||||
# Get current language
|
||||
language = await get_current_language(request)
|
||||
|
||||
# Get translation service
|
||||
service = get_translation_service()
|
||||
|
||||
# Return a bound enum translation function
|
||||
def translate_enum(enum_type: str, value: str) -> str:
|
||||
"""
|
||||
Translate an enum value using the current request's language.
|
||||
|
||||
Args:
|
||||
enum_type: Enum type name (e.g., "workspace_role")
|
||||
value: Enum value (e.g., "manager")
|
||||
|
||||
Returns:
|
||||
Translated enum display name
|
||||
"""
|
||||
return service.translate_enum(enum_type, value, language)
|
||||
|
||||
return translate_enum
|
||||
495
api/app/i18n/exceptions.py
Normal file
495
api/app/i18n/exceptions.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
Internationalized exception classes for i18n system.
|
||||
|
||||
This module provides exception classes that automatically translate
|
||||
error messages based on the current request's language.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.i18n.service import get_translation_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Context variable to store current locale
|
||||
_current_locale: ContextVar[Optional[str]] = ContextVar("current_locale", default=None)
|
||||
|
||||
|
||||
def set_current_locale(locale: str) -> None:
|
||||
"""
|
||||
Set the current locale in the context variable.
|
||||
|
||||
This should be called by the LanguageMiddleware.
|
||||
|
||||
Args:
|
||||
locale: Locale code (e.g., "zh", "en")
|
||||
"""
|
||||
_current_locale.set(locale)
|
||||
|
||||
|
||||
def get_current_locale() -> Optional[str]:
|
||||
"""
|
||||
Get the current locale from the context variable.
|
||||
|
||||
Returns:
|
||||
Locale code or None if not set
|
||||
"""
|
||||
return _current_locale.get()
|
||||
|
||||
|
||||
class I18nException(HTTPException):
|
||||
"""
|
||||
Base exception class with automatic i18n support.
|
||||
|
||||
This exception automatically translates error messages based on:
|
||||
1. The current request's language (from request.state.language)
|
||||
2. The fallback language if request language is not available
|
||||
3. The error key itself if no translation is found
|
||||
|
||||
Features:
|
||||
- Automatic error message translation
|
||||
- Parameterized error messages support
|
||||
- Consistent error response format
|
||||
- Language-aware error handling
|
||||
|
||||
Usage:
|
||||
# Simple error
|
||||
raise I18nException(
|
||||
error_key="errors.workspace.not_found",
|
||||
status_code=404
|
||||
)
|
||||
|
||||
# Error with parameters
|
||||
raise I18nException(
|
||||
error_key="errors.validation.missing_field",
|
||||
status_code=400,
|
||||
field="name"
|
||||
)
|
||||
|
||||
# Custom error code
|
||||
raise I18nException(
|
||||
error_key="errors.workspace.not_found",
|
||||
error_code="WORKSPACE_NOT_FOUND",
|
||||
status_code=404,
|
||||
workspace_id="123"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str,
|
||||
status_code: int = 400,
|
||||
error_code: Optional[str] = None,
|
||||
locale: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
**params
|
||||
):
|
||||
"""
|
||||
Initialize the i18n exception.
|
||||
|
||||
Args:
|
||||
error_key: Translation key for the error message
|
||||
(e.g., "errors.workspace.not_found")
|
||||
status_code: HTTP status code (default: 400)
|
||||
error_code: Custom error code for API clients
|
||||
(default: derived from error_key)
|
||||
locale: Target locale for translation (optional)
|
||||
If not provided, uses current request's language
|
||||
headers: Additional HTTP headers
|
||||
**params: Parameters for parameterized error messages
|
||||
"""
|
||||
self.error_key = error_key
|
||||
self.error_code = error_code or self._generate_error_code(error_key)
|
||||
self.params = params
|
||||
|
||||
# Get locale from request context if not provided
|
||||
if locale is None:
|
||||
locale = self._get_current_locale()
|
||||
|
||||
# Translate error message
|
||||
translation_service = get_translation_service()
|
||||
message = translation_service.translate(
|
||||
error_key,
|
||||
locale,
|
||||
**params
|
||||
)
|
||||
|
||||
# Build error detail
|
||||
detail = {
|
||||
"error_code": self.error_code,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
# Add parameters to detail if provided
|
||||
if params:
|
||||
detail["params"] = params
|
||||
|
||||
# Initialize HTTPException
|
||||
super().__init__(
|
||||
status_code=status_code,
|
||||
detail=detail,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"I18nException raised: {self.error_code} "
|
||||
f"(key: {error_key}, locale: {locale})"
|
||||
)
|
||||
|
||||
def _get_current_locale(self) -> str:
|
||||
"""
|
||||
Get the current locale from request context.
|
||||
|
||||
Returns:
|
||||
Locale code (e.g., "zh", "en")
|
||||
"""
|
||||
try:
|
||||
# Try to get locale from context variable
|
||||
locale = _current_locale.get()
|
||||
if locale:
|
||||
return locale
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get locale from context: {e}")
|
||||
|
||||
# Fallback to default locale
|
||||
from app.core.config import settings
|
||||
return settings.I18N_DEFAULT_LANGUAGE
|
||||
|
||||
def _generate_error_code(self, error_key: str) -> str:
|
||||
"""
|
||||
Generate error code from error key.
|
||||
|
||||
Converts "errors.workspace.not_found" to "WORKSPACE_NOT_FOUND"
|
||||
|
||||
Args:
|
||||
error_key: Translation key
|
||||
|
||||
Returns:
|
||||
Error code in UPPER_SNAKE_CASE
|
||||
"""
|
||||
# Remove "errors." prefix if present
|
||||
if error_key.startswith("errors."):
|
||||
error_key = error_key[7:]
|
||||
|
||||
# Convert to UPPER_SNAKE_CASE
|
||||
parts = error_key.split(".")
|
||||
return "_".join(parts).upper()
|
||||
|
||||
|
||||
# Specific exception classes for common errors
|
||||
|
||||
class BadRequestError(I18nException):
|
||||
"""Bad request error (400)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.bad_request",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=400,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedError(I18nException):
|
||||
"""Unauthorized error (401)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.auth.unauthorized",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=401,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class ForbiddenError(I18nException):
|
||||
"""Forbidden error (403)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.auth.forbidden",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=403,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(I18nException):
|
||||
"""Not found error (404)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.not_found",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=404,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class ConflictError(I18nException):
|
||||
"""Conflict error (409)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.conflict",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=409,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(I18nException):
|
||||
"""Validation error (422)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.validation_failed",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=422,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class InternalServerError(I18nException):
|
||||
"""Internal server error (500)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.internal_error",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=500,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class ServiceUnavailableError(I18nException):
|
||||
"""Service unavailable error (503)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_key: str = "errors.common.service_unavailable",
|
||||
error_code: Optional[str] = None,
|
||||
**params
|
||||
):
|
||||
super().__init__(
|
||||
error_key=error_key,
|
||||
status_code=503,
|
||||
error_code=error_code,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
# Domain-specific exception classes
|
||||
|
||||
class WorkspaceNotFoundError(NotFoundError):
|
||||
"""Workspace not found error."""
|
||||
|
||||
def __init__(self, workspace_id: Optional[str] = None, **params):
|
||||
if workspace_id:
|
||||
params["workspace_id"] = workspace_id
|
||||
super().__init__(
|
||||
error_key="errors.workspace.not_found",
|
||||
error_code="WORKSPACE_NOT_FOUND",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class WorkspacePermissionDeniedError(ForbiddenError):
|
||||
"""Workspace permission denied error."""
|
||||
|
||||
def __init__(self, workspace_id: Optional[str] = None, **params):
|
||||
if workspace_id:
|
||||
params["workspace_id"] = workspace_id
|
||||
super().__init__(
|
||||
error_key="errors.workspace.permission_denied",
|
||||
error_code="WORKSPACE_PERMISSION_DENIED",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError):
|
||||
"""User not found error."""
|
||||
|
||||
def __init__(self, user_id: Optional[str] = None, **params):
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
super().__init__(
|
||||
error_key="errors.user.not_found",
|
||||
error_code="USER_NOT_FOUND",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class UserAlreadyExistsError(ConflictError):
|
||||
"""User already exists error."""
|
||||
|
||||
def __init__(self, identifier: Optional[str] = None, **params):
|
||||
if identifier:
|
||||
params["identifier"] = identifier
|
||||
super().__init__(
|
||||
error_key="errors.user.already_exists",
|
||||
error_code="USER_ALREADY_EXISTS",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class TenantNotFoundError(NotFoundError):
|
||||
"""Tenant not found error."""
|
||||
|
||||
def __init__(self, tenant_id: Optional[str] = None, **params):
|
||||
if tenant_id:
|
||||
params["tenant_id"] = tenant_id
|
||||
super().__init__(
|
||||
error_key="errors.tenant.not_found",
|
||||
error_code="TENANT_NOT_FOUND",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class TenantSuspendedError(ForbiddenError):
|
||||
"""Tenant suspended error."""
|
||||
|
||||
def __init__(self, tenant_id: Optional[str] = None, **params):
|
||||
if tenant_id:
|
||||
params["tenant_id"] = tenant_id
|
||||
super().__init__(
|
||||
error_key="errors.tenant.suspended",
|
||||
error_code="TENANT_SUSPENDED",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class InvalidCredentialsError(UnauthorizedError):
|
||||
"""Invalid credentials error."""
|
||||
|
||||
def __init__(self, **params):
|
||||
super().__init__(
|
||||
error_key="errors.auth.invalid_credentials",
|
||||
error_code="INVALID_CREDENTIALS",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredError(UnauthorizedError):
|
||||
"""Token expired error."""
|
||||
|
||||
def __init__(self, **params):
|
||||
super().__init__(
|
||||
error_key="errors.auth.token_expired",
|
||||
error_code="TOKEN_EXPIRED",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class TokenInvalidError(UnauthorizedError):
|
||||
"""Token invalid error."""
|
||||
|
||||
def __init__(self, **params):
|
||||
super().__init__(
|
||||
error_key="errors.auth.token_invalid",
|
||||
error_code="TOKEN_INVALID",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class FileNotFoundError(NotFoundError):
|
||||
"""File not found error."""
|
||||
|
||||
def __init__(self, file_id: Optional[str] = None, **params):
|
||||
if file_id:
|
||||
params["file_id"] = file_id
|
||||
super().__init__(
|
||||
error_key="errors.file.not_found",
|
||||
error_code="FILE_NOT_FOUND",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class FileTooLargeError(BadRequestError):
|
||||
"""File too large error."""
|
||||
|
||||
def __init__(self, max_size: Optional[str] = None, **params):
|
||||
if max_size:
|
||||
params["max_size"] = max_size
|
||||
super().__init__(
|
||||
error_key="errors.file.too_large",
|
||||
error_code="FILE_TOO_LARGE",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class InvalidFileTypeError(BadRequestError):
|
||||
"""Invalid file type error."""
|
||||
|
||||
def __init__(self, file_type: Optional[str] = None, **params):
|
||||
if file_type:
|
||||
params["file_type"] = file_type
|
||||
super().__init__(
|
||||
error_key="errors.file.invalid_type",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class RateLimitExceededError(I18nException):
|
||||
"""Rate limit exceeded error (429)."""
|
||||
|
||||
def __init__(self, **params):
|
||||
super().__init__(
|
||||
error_key="errors.api.rate_limit_exceeded",
|
||||
status_code=429,
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
class QuotaExceededError(ForbiddenError):
|
||||
"""Quota exceeded error."""
|
||||
|
||||
def __init__(self, resource: Optional[str] = None, **params):
|
||||
if resource:
|
||||
params["resource"] = resource
|
||||
super().__init__(
|
||||
error_key="errors.api.quota_exceeded",
|
||||
error_code="QUOTA_EXCEEDED",
|
||||
**params
|
||||
)
|
||||
199
api/app/i18n/loader.py
Normal file
199
api/app/i18n/loader.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Translation file loader for i18n system.
|
||||
|
||||
This module handles loading translation files from multiple directories
|
||||
(community edition + enterprise edition) and provides hot reload support.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranslationLoader:
|
||||
"""
|
||||
Translation file loader that supports:
|
||||
- Loading from multiple directories (community + enterprise)
|
||||
- Hot reload of translation files
|
||||
- Automatic locale detection
|
||||
"""
|
||||
|
||||
def __init__(self, locales_dirs: Optional[List[str]] = None):
|
||||
"""
|
||||
Initialize the translation loader.
|
||||
|
||||
Args:
|
||||
locales_dirs: List of directories containing translation files.
|
||||
If None, will auto-detect from settings.
|
||||
"""
|
||||
if locales_dirs is None:
|
||||
locales_dirs = self._detect_locales_dirs()
|
||||
|
||||
self.locales_dirs = [Path(d) for d in locales_dirs]
|
||||
logger.info(f"TranslationLoader initialized with directories: {self.locales_dirs}")
|
||||
|
||||
def _detect_locales_dirs(self) -> List[str]:
|
||||
"""
|
||||
Auto-detect translation directories from settings.
|
||||
|
||||
Returns:
|
||||
List of translation directory paths
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
dirs = []
|
||||
|
||||
# 1. Core locales directory (community edition, required)
|
||||
core_dir = Path(settings.I18N_CORE_LOCALES_DIR)
|
||||
if core_dir.exists():
|
||||
dirs.append(str(core_dir))
|
||||
logger.debug(f"Found core locales directory: {core_dir}")
|
||||
else:
|
||||
logger.warning(f"Core locales directory not found: {core_dir}")
|
||||
|
||||
# 2. Premium locales directory (enterprise edition, optional)
|
||||
if settings.I18N_PREMIUM_LOCALES_DIR:
|
||||
premium_dir = Path(settings.I18N_PREMIUM_LOCALES_DIR)
|
||||
if premium_dir.exists():
|
||||
dirs.append(str(premium_dir))
|
||||
logger.debug(f"Found premium locales directory: {premium_dir}")
|
||||
else:
|
||||
# Auto-detect premium directory
|
||||
premium_dir = Path("premium/locales")
|
||||
if premium_dir.exists():
|
||||
dirs.append(str(premium_dir))
|
||||
logger.debug(f"Auto-detected premium locales directory: {premium_dir}")
|
||||
|
||||
if not dirs:
|
||||
logger.error("No translation directories found!")
|
||||
|
||||
return dirs
|
||||
|
||||
def get_available_locales(self) -> List[str]:
|
||||
"""
|
||||
Get list of all available locales across all directories.
|
||||
|
||||
Returns:
|
||||
List of locale codes (e.g., ['zh', 'en'])
|
||||
"""
|
||||
locales = set()
|
||||
|
||||
for locales_dir in self.locales_dirs:
|
||||
if not locales_dir.exists():
|
||||
continue
|
||||
|
||||
for locale_dir in locales_dir.iterdir():
|
||||
if locale_dir.is_dir() and not locale_dir.name.startswith('.'):
|
||||
locales.add(locale_dir.name)
|
||||
|
||||
return sorted(list(locales))
|
||||
|
||||
def load_locale(self, locale: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load all translation files for a specific locale from all directories.
|
||||
|
||||
Translation files are merged with priority:
|
||||
- Later directories override earlier directories
|
||||
- Enterprise translations override community translations
|
||||
|
||||
Args:
|
||||
locale: Locale code (e.g., 'zh', 'en')
|
||||
|
||||
Returns:
|
||||
Dictionary of translations organized by namespace
|
||||
Format: {namespace: {key: value, ...}, ...}
|
||||
"""
|
||||
translations = {}
|
||||
|
||||
# Load from each directory in order (later directories override earlier)
|
||||
for locales_dir in self.locales_dirs:
|
||||
locale_dir = locales_dir / locale
|
||||
if not locale_dir.exists():
|
||||
logger.debug(f"Locale directory not found: {locale_dir}")
|
||||
continue
|
||||
|
||||
# Load all JSON files in this locale directory
|
||||
for json_file in locale_dir.glob("*.json"):
|
||||
namespace = json_file.stem
|
||||
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
new_translations = json.load(f)
|
||||
|
||||
# Merge translations (deep merge)
|
||||
if namespace in translations:
|
||||
translations[namespace] = self._deep_merge(
|
||||
translations[namespace],
|
||||
new_translations
|
||||
)
|
||||
logger.debug(
|
||||
f"Merged translations: {locale}/{namespace} from {json_file}"
|
||||
)
|
||||
else:
|
||||
translations[namespace] = new_translations
|
||||
logger.debug(
|
||||
f"Loaded translations: {locale}/{namespace} from {json_file}"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
f"Failed to parse JSON file {json_file}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load translation file {json_file}: {e}"
|
||||
)
|
||||
|
||||
if not translations:
|
||||
logger.warning(f"No translations found for locale: {locale}")
|
||||
|
||||
return translations
|
||||
|
||||
def reload(self, locale: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Reload translation files.
|
||||
|
||||
Args:
|
||||
locale: Specific locale to reload. If None, reloads all locales.
|
||||
|
||||
Returns:
|
||||
Dictionary of reloaded translations
|
||||
Format: {locale: {namespace: {key: value}}}
|
||||
"""
|
||||
if locale:
|
||||
logger.info(f"Reloading translations for locale: {locale}")
|
||||
return {locale: self.load_locale(locale)}
|
||||
else:
|
||||
logger.info("Reloading all translations")
|
||||
all_translations = {}
|
||||
for loc in self.get_available_locales():
|
||||
all_translations[loc] = self.load_locale(loc)
|
||||
return all_translations
|
||||
|
||||
def _deep_merge(self, base: Dict, override: Dict) -> Dict:
|
||||
"""
|
||||
Deep merge two dictionaries.
|
||||
|
||||
Args:
|
||||
base: Base dictionary
|
||||
override: Dictionary with values to override
|
||||
|
||||
Returns:
|
||||
Merged dictionary
|
||||
"""
|
||||
result = base.copy()
|
||||
|
||||
for key, value in override.items():
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
382
api/app/i18n/logger.py
Normal file
382
api/app/i18n/logger.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Translation logging for i18n system.
|
||||
|
||||
This module provides:
|
||||
- TranslationLogger for recording missing translations
|
||||
- Missing translation report generation
|
||||
- Integration with existing logging system
|
||||
- Structured logging for translation events
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Set
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from app.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TranslationLogger:
|
||||
"""
|
||||
Logger for translation events and missing translations.
|
||||
|
||||
Features:
|
||||
- Records missing translations with context
|
||||
- Generates missing translation reports
|
||||
- Integrates with existing logging system
|
||||
- Provides structured logging for analysis
|
||||
"""
|
||||
|
||||
def __init__(self, log_file: Optional[str] = None):
|
||||
"""
|
||||
Initialize translation logger.
|
||||
|
||||
Args:
|
||||
log_file: Optional custom log file path for missing translations
|
||||
"""
|
||||
self.log_file = log_file or "logs/i18n/missing_translations.log"
|
||||
self._missing_translations: Dict[str, Set[str]] = defaultdict(set)
|
||||
self._missing_with_context: List[Dict] = []
|
||||
self._max_context_entries = 10000 # Keep last 10k entries
|
||||
|
||||
# Ensure log directory exists
|
||||
log_path = Path(self.log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create dedicated file handler for missing translations
|
||||
self._file_handler = logging.FileHandler(
|
||||
self.log_file,
|
||||
encoding='utf-8'
|
||||
)
|
||||
self._file_handler.setLevel(logging.WARNING)
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
self._file_handler.setFormatter(formatter)
|
||||
|
||||
# Create dedicated logger for missing translations
|
||||
self._logger = logging.getLogger("i18n.missing_translations")
|
||||
self._logger.setLevel(logging.WARNING)
|
||||
self._logger.addHandler(self._file_handler)
|
||||
self._logger.propagate = False # Don't propagate to root logger
|
||||
|
||||
logger.info(f"TranslationLogger initialized with log file: {self.log_file}")
|
||||
|
||||
def log_missing_translation(
|
||||
self,
|
||||
key: str,
|
||||
locale: str,
|
||||
context: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Log a missing translation.
|
||||
|
||||
Args:
|
||||
key: Translation key that was not found
|
||||
locale: Locale code
|
||||
context: Optional context information (e.g., request path, user info)
|
||||
"""
|
||||
# Add to missing set
|
||||
self._missing_translations[locale].add(key)
|
||||
|
||||
# Create context entry
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"key": key,
|
||||
"locale": locale,
|
||||
"context": context or {}
|
||||
}
|
||||
|
||||
# Keep only recent entries to avoid memory bloat
|
||||
if len(self._missing_with_context) >= self._max_context_entries:
|
||||
self._missing_with_context.pop(0)
|
||||
|
||||
self._missing_with_context.append(entry)
|
||||
|
||||
# Log to file
|
||||
context_str = f" (context: {context})" if context else ""
|
||||
self._logger.warning(
|
||||
f"Missing translation: key='{key}', locale='{locale}'{context_str}"
|
||||
)
|
||||
|
||||
def log_translation_error(
|
||||
self,
|
||||
error_type: str,
|
||||
message: str,
|
||||
key: Optional[str] = None,
|
||||
locale: Optional[str] = None,
|
||||
context: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Log a translation error.
|
||||
|
||||
Args:
|
||||
error_type: Type of error (e.g., "format_error", "parameter_missing")
|
||||
message: Error message
|
||||
key: Translation key (optional)
|
||||
locale: Locale code (optional)
|
||||
context: Optional context information
|
||||
"""
|
||||
error_data = {
|
||||
"error_type": error_type,
|
||||
"message": message,
|
||||
"key": key,
|
||||
"locale": locale,
|
||||
"context": context or {},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
self._logger.error(
|
||||
f"Translation error: {error_type} - {message} "
|
||||
f"(key: {key}, locale: {locale})"
|
||||
)
|
||||
|
||||
def log_translation_success(
|
||||
self,
|
||||
key: str,
|
||||
locale: str,
|
||||
duration_ms: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
Log a successful translation (debug level).
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Locale code
|
||||
duration_ms: Optional duration in milliseconds
|
||||
"""
|
||||
duration_str = f" ({duration_ms:.3f}ms)" if duration_ms else ""
|
||||
logger.debug(
|
||||
f"Translation success: key='{key}', locale='{locale}'{duration_str}"
|
||||
)
|
||||
|
||||
def get_missing_translations(
|
||||
self,
|
||||
locale: Optional[str] = None
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get missing translations.
|
||||
|
||||
Args:
|
||||
locale: Specific locale (optional, returns all if None)
|
||||
|
||||
Returns:
|
||||
Dictionary of missing translations by locale
|
||||
"""
|
||||
if locale:
|
||||
return {locale: sorted(list(self._missing_translations.get(locale, set())))}
|
||||
|
||||
return {
|
||||
loc: sorted(list(keys))
|
||||
for loc, keys in self._missing_translations.items()
|
||||
}
|
||||
|
||||
def get_missing_with_context(
|
||||
self,
|
||||
locale: Optional[str] = None,
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get missing translations with context.
|
||||
|
||||
Args:
|
||||
locale: Filter by locale (optional)
|
||||
limit: Maximum number of entries to return (optional)
|
||||
|
||||
Returns:
|
||||
List of missing translation entries with context
|
||||
"""
|
||||
entries = self._missing_with_context
|
||||
|
||||
# Filter by locale if specified
|
||||
if locale:
|
||||
entries = [e for e in entries if e["locale"] == locale]
|
||||
|
||||
# Apply limit if specified
|
||||
if limit:
|
||||
entries = entries[-limit:]
|
||||
|
||||
return entries
|
||||
|
||||
def generate_report(
|
||||
self,
|
||||
locale: Optional[str] = None,
|
||||
output_file: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Generate a missing translation report.
|
||||
|
||||
Args:
|
||||
locale: Specific locale (optional, generates for all if None)
|
||||
output_file: Optional file path to save report as JSON
|
||||
|
||||
Returns:
|
||||
Report dictionary
|
||||
"""
|
||||
missing = self.get_missing_translations(locale)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_missing": sum(len(keys) for keys in missing.values()),
|
||||
"missing_by_locale": {
|
||||
loc: {
|
||||
"count": len(keys),
|
||||
"keys": keys
|
||||
}
|
||||
for loc, keys in missing.items()
|
||||
},
|
||||
"recent_context": self.get_missing_with_context(locale, limit=100)
|
||||
}
|
||||
|
||||
# Save to file if specified
|
||||
if output_file:
|
||||
output_path = Path(output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Missing translation report saved to: {output_file}")
|
||||
|
||||
return report
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""
|
||||
Get statistics about missing translations.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics
|
||||
"""
|
||||
total_missing = sum(len(keys) for keys in self._missing_translations.values())
|
||||
|
||||
# Count by namespace
|
||||
namespace_counts = defaultdict(int)
|
||||
for locale, keys in self._missing_translations.items():
|
||||
for key in keys:
|
||||
namespace = key.split('.')[0] if '.' in key else 'unknown'
|
||||
namespace_counts[namespace] += 1
|
||||
|
||||
return {
|
||||
"total_missing": total_missing,
|
||||
"locales_affected": len(self._missing_translations),
|
||||
"missing_by_locale": {
|
||||
loc: len(keys)
|
||||
for loc, keys in self._missing_translations.items()
|
||||
},
|
||||
"missing_by_namespace": dict(namespace_counts),
|
||||
"total_context_entries": len(self._missing_with_context)
|
||||
}
|
||||
|
||||
def clear(self, locale: Optional[str] = None):
|
||||
"""
|
||||
Clear missing translation records.
|
||||
|
||||
Args:
|
||||
locale: Specific locale to clear (optional, clears all if None)
|
||||
"""
|
||||
if locale:
|
||||
self._missing_translations.pop(locale, None)
|
||||
self._missing_with_context = [
|
||||
e for e in self._missing_with_context
|
||||
if e["locale"] != locale
|
||||
]
|
||||
logger.info(f"Cleared missing translations for locale: {locale}")
|
||||
else:
|
||||
self._missing_translations.clear()
|
||||
self._missing_with_context.clear()
|
||||
logger.info("Cleared all missing translations")
|
||||
|
||||
def export_to_json(self, output_file: str):
|
||||
"""
|
||||
Export all missing translations to JSON file.
|
||||
|
||||
Args:
|
||||
output_file: Output file path
|
||||
"""
|
||||
data = {
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"missing_translations": self.get_missing_translations(),
|
||||
"statistics": self.get_statistics(),
|
||||
"recent_context": self.get_missing_with_context(limit=1000)
|
||||
}
|
||||
|
||||
output_path = Path(output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Missing translations exported to: {output_file}")
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup file handler on deletion."""
|
||||
try:
|
||||
if hasattr(self, '_file_handler'):
|
||||
self._file_handler.close()
|
||||
self._logger.removeHandler(self._file_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Global translation logger instance
|
||||
_translation_logger: Optional[TranslationLogger] = None
|
||||
|
||||
|
||||
def get_translation_logger() -> TranslationLogger:
|
||||
"""
|
||||
Get the global translation logger instance.
|
||||
|
||||
Returns:
|
||||
TranslationLogger singleton
|
||||
"""
|
||||
global _translation_logger
|
||||
if _translation_logger is None:
|
||||
_translation_logger = TranslationLogger()
|
||||
return _translation_logger
|
||||
|
||||
|
||||
def log_missing_translation(
|
||||
key: str,
|
||||
locale: str,
|
||||
context: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Log a missing translation (convenience function).
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Locale code
|
||||
context: Optional context information
|
||||
"""
|
||||
translation_logger = get_translation_logger()
|
||||
translation_logger.log_missing_translation(key, locale, context)
|
||||
|
||||
|
||||
def log_translation_error(
|
||||
error_type: str,
|
||||
message: str,
|
||||
key: Optional[str] = None,
|
||||
locale: Optional[str] = None,
|
||||
context: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Log a translation error (convenience function).
|
||||
|
||||
Args:
|
||||
error_type: Type of error
|
||||
message: Error message
|
||||
key: Translation key (optional)
|
||||
locale: Locale code (optional)
|
||||
context: Optional context information
|
||||
"""
|
||||
translation_logger = get_translation_logger()
|
||||
translation_logger.log_translation_error(
|
||||
error_type, message, key, locale, context
|
||||
)
|
||||
337
api/app/i18n/metrics.py
Normal file
337
api/app/i18n/metrics.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Performance monitoring and metrics for i18n system.
|
||||
|
||||
This module provides:
|
||||
- Translation request counters
|
||||
- Translation timing metrics
|
||||
- Missing translation tracking
|
||||
- Performance monitoring decorators
|
||||
- Prometheus-compatible metrics
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranslationMetrics:
|
||||
"""
|
||||
Metrics collector for translation operations.
|
||||
|
||||
Tracks:
|
||||
- Translation request counts
|
||||
- Translation timing (latency)
|
||||
- Missing translations
|
||||
- Cache performance
|
||||
- Locale usage
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize metrics collector."""
|
||||
# Request counters by locale
|
||||
self._request_counts: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Missing translation tracker
|
||||
self._missing_translations: Dict[str, set] = defaultdict(set)
|
||||
|
||||
# Timing metrics (in milliseconds)
|
||||
self._timing_data: list = []
|
||||
self._max_timing_samples = 10000 # Keep last 10k samples
|
||||
|
||||
# Locale usage
|
||||
self._locale_usage: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Namespace usage
|
||||
self._namespace_usage: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Error counts
|
||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Start time
|
||||
self._start_time = datetime.now()
|
||||
|
||||
logger.info("TranslationMetrics initialized")
|
||||
|
||||
def record_request(self, locale: str, namespace: str = None):
|
||||
"""
|
||||
Record a translation request.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
namespace: Translation namespace (optional)
|
||||
"""
|
||||
self._request_counts[locale] += 1
|
||||
self._locale_usage[locale] += 1
|
||||
|
||||
if namespace:
|
||||
self._namespace_usage[namespace] += 1
|
||||
|
||||
def record_missing(self, key: str, locale: str):
|
||||
"""
|
||||
Record a missing translation.
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Locale code
|
||||
"""
|
||||
self._missing_translations[locale].add(key)
|
||||
logger.debug(f"Missing translation recorded: {key} (locale: {locale})")
|
||||
|
||||
def record_timing(self, duration_ms: float, locale: str, operation: str = "translate"):
|
||||
"""
|
||||
Record translation operation timing.
|
||||
|
||||
Args:
|
||||
duration_ms: Duration in milliseconds
|
||||
locale: Locale code
|
||||
operation: Operation type
|
||||
"""
|
||||
# Keep only recent samples to avoid memory bloat
|
||||
if len(self._timing_data) >= self._max_timing_samples:
|
||||
self._timing_data.pop(0)
|
||||
|
||||
self._timing_data.append({
|
||||
"duration_ms": duration_ms,
|
||||
"locale": locale,
|
||||
"operation": operation,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
def record_error(self, error_type: str):
|
||||
"""
|
||||
Record an error.
|
||||
|
||||
Args:
|
||||
error_type: Type of error
|
||||
"""
|
||||
self._error_counts[error_type] += 1
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get metrics summary.
|
||||
|
||||
Returns:
|
||||
Dictionary with metrics summary
|
||||
"""
|
||||
total_requests = sum(self._request_counts.values())
|
||||
total_missing = sum(len(keys) for keys in self._missing_translations.values())
|
||||
|
||||
# Calculate timing statistics
|
||||
timing_stats = self._calculate_timing_stats()
|
||||
|
||||
# Calculate uptime
|
||||
uptime_seconds = (datetime.now() - self._start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"uptime_seconds": round(uptime_seconds, 2),
|
||||
"total_requests": total_requests,
|
||||
"requests_per_locale": dict(self._request_counts),
|
||||
"total_missing_translations": total_missing,
|
||||
"missing_by_locale": {
|
||||
locale: len(keys)
|
||||
for locale, keys in self._missing_translations.items()
|
||||
},
|
||||
"timing": timing_stats,
|
||||
"locale_usage": dict(self._locale_usage),
|
||||
"namespace_usage": dict(self._namespace_usage),
|
||||
"error_counts": dict(self._error_counts)
|
||||
}
|
||||
|
||||
def _calculate_timing_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate timing statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with timing statistics
|
||||
"""
|
||||
if not self._timing_data:
|
||||
return {
|
||||
"count": 0,
|
||||
"avg_ms": 0,
|
||||
"min_ms": 0,
|
||||
"max_ms": 0,
|
||||
"p50_ms": 0,
|
||||
"p95_ms": 0,
|
||||
"p99_ms": 0
|
||||
}
|
||||
|
||||
durations = [d["duration_ms"] for d in self._timing_data]
|
||||
durations.sort()
|
||||
|
||||
count = len(durations)
|
||||
avg = sum(durations) / count
|
||||
|
||||
# Calculate percentiles
|
||||
p50_idx = int(count * 0.50)
|
||||
p95_idx = int(count * 0.95)
|
||||
p99_idx = int(count * 0.99)
|
||||
|
||||
return {
|
||||
"count": count,
|
||||
"avg_ms": round(avg, 3),
|
||||
"min_ms": round(durations[0], 3),
|
||||
"max_ms": round(durations[-1], 3),
|
||||
"p50_ms": round(durations[p50_idx], 3),
|
||||
"p95_ms": round(durations[p95_idx], 3),
|
||||
"p99_ms": round(durations[p99_idx], 3)
|
||||
}
|
||||
|
||||
def get_missing_translations(self, locale: Optional[str] = None) -> Dict[str, list]:
|
||||
"""
|
||||
Get missing translations.
|
||||
|
||||
Args:
|
||||
locale: Specific locale (optional, returns all if None)
|
||||
|
||||
Returns:
|
||||
Dictionary of missing translations by locale
|
||||
"""
|
||||
if locale:
|
||||
return {locale: list(self._missing_translations.get(locale, set()))}
|
||||
|
||||
return {
|
||||
locale: list(keys)
|
||||
for locale, keys in self._missing_translations.items()
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
"""Reset all metrics."""
|
||||
self._request_counts.clear()
|
||||
self._missing_translations.clear()
|
||||
self._timing_data.clear()
|
||||
self._locale_usage.clear()
|
||||
self._namespace_usage.clear()
|
||||
self._error_counts.clear()
|
||||
self._start_time = datetime.now()
|
||||
logger.info("Metrics reset")
|
||||
|
||||
def export_prometheus(self) -> str:
|
||||
"""
|
||||
Export metrics in Prometheus format.
|
||||
|
||||
Returns:
|
||||
Prometheus-formatted metrics string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Translation requests counter
|
||||
lines.append("# HELP i18n_translation_requests_total Total number of translation requests")
|
||||
lines.append("# TYPE i18n_translation_requests_total counter")
|
||||
for locale, count in self._request_counts.items():
|
||||
lines.append(f'i18n_translation_requests_total{{locale="{locale}"}} {count}')
|
||||
|
||||
# Missing translations counter
|
||||
lines.append("# HELP i18n_missing_translations_total Total number of missing translations")
|
||||
lines.append("# TYPE i18n_missing_translations_total counter")
|
||||
for locale, keys in self._missing_translations.items():
|
||||
lines.append(f'i18n_missing_translations_total{{locale="{locale}"}} {len(keys)}')
|
||||
|
||||
# Timing metrics
|
||||
timing_stats = self._calculate_timing_stats()
|
||||
lines.append("# HELP i18n_translation_duration_ms Translation operation duration in milliseconds")
|
||||
lines.append("# TYPE i18n_translation_duration_ms summary")
|
||||
lines.append(f'i18n_translation_duration_ms{{quantile="0.5"}} {timing_stats["p50_ms"]}')
|
||||
lines.append(f'i18n_translation_duration_ms{{quantile="0.95"}} {timing_stats["p95_ms"]}')
|
||||
lines.append(f'i18n_translation_duration_ms{{quantile="0.99"}} {timing_stats["p99_ms"]}')
|
||||
lines.append(f'i18n_translation_duration_ms_sum {sum(d["duration_ms"] for d in self._timing_data)}')
|
||||
lines.append(f'i18n_translation_duration_ms_count {timing_stats["count"]}')
|
||||
|
||||
# Error counter
|
||||
lines.append("# HELP i18n_errors_total Total number of i18n errors")
|
||||
lines.append("# TYPE i18n_errors_total counter")
|
||||
for error_type, count in self._error_counts.items():
|
||||
lines.append(f'i18n_errors_total{{type="{error_type}"}} {count}')
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Global metrics instance
|
||||
_metrics: Optional[TranslationMetrics] = None
|
||||
|
||||
|
||||
def get_metrics() -> TranslationMetrics:
|
||||
"""
|
||||
Get the global metrics instance.
|
||||
|
||||
Returns:
|
||||
TranslationMetrics singleton
|
||||
"""
|
||||
global _metrics
|
||||
if _metrics is None:
|
||||
_metrics = TranslationMetrics()
|
||||
return _metrics
|
||||
|
||||
|
||||
def monitor_performance(operation: str = "translate"):
|
||||
"""
|
||||
Decorator to monitor translation operation performance.
|
||||
|
||||
Args:
|
||||
operation: Operation name for metrics
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Example:
|
||||
@monitor_performance("translate")
|
||||
def translate(key: str, locale: str) -> str:
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Record timing
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
|
||||
# Try to extract locale from args/kwargs
|
||||
locale = kwargs.get("locale", "unknown")
|
||||
if not locale and len(args) > 1:
|
||||
locale = args[1] if isinstance(args[1], str) else "unknown"
|
||||
|
||||
metrics = get_metrics()
|
||||
metrics.record_timing(duration_ms, locale, operation)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Record error
|
||||
metrics = get_metrics()
|
||||
metrics.record_error(type(e).__name__)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def track_missing_translation(key: str, locale: str):
|
||||
"""
|
||||
Track a missing translation.
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Locale code
|
||||
"""
|
||||
metrics = get_metrics()
|
||||
metrics.record_missing(key, locale)
|
||||
|
||||
|
||||
def track_translation_request(locale: str, namespace: str = None):
|
||||
"""
|
||||
Track a translation request.
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
namespace: Translation namespace (optional)
|
||||
"""
|
||||
metrics = get_metrics()
|
||||
metrics.record_request(locale, namespace)
|
||||
202
api/app/i18n/middleware.py
Normal file
202
api/app/i18n/middleware.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Language detection middleware for i18n system.
|
||||
|
||||
This middleware determines the language to use for each request based on:
|
||||
1. Query parameter (?lang=en)
|
||||
2. Accept-Language HTTP header
|
||||
3. User language preference (from database)
|
||||
4. Tenant default language
|
||||
5. System default language
|
||||
|
||||
The detected language is injected into request.state.language and
|
||||
added to the response Content-Language header.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LanguageMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Language detection middleware.
|
||||
|
||||
Determines the language for each request based on multiple sources
|
||||
with a clear priority order, validates the language is supported,
|
||||
and injects it into the request context.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Process the request and determine the language.
|
||||
|
||||
Args:
|
||||
request: The incoming request
|
||||
call_next: The next middleware/handler in the chain
|
||||
|
||||
Returns:
|
||||
Response with Content-Language header added
|
||||
"""
|
||||
# Determine the language for this request
|
||||
language = await self._determine_language(request)
|
||||
|
||||
# Validate language is supported
|
||||
from app.core.config import settings
|
||||
if language not in settings.I18N_SUPPORTED_LANGUAGES:
|
||||
logger.warning(
|
||||
f"Unsupported language '{language}' requested, "
|
||||
f"falling back to default: {settings.I18N_DEFAULT_LANGUAGE}"
|
||||
)
|
||||
language = settings.I18N_DEFAULT_LANGUAGE
|
||||
|
||||
# Inject language into request state
|
||||
request.state.language = language
|
||||
|
||||
# Also set in context variable for exception handling
|
||||
from app.i18n.exceptions import set_current_locale
|
||||
set_current_locale(language)
|
||||
|
||||
logger.debug(f"Request language set to: {language}")
|
||||
|
||||
# Process the request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add Content-Language header to response
|
||||
response.headers["Content-Language"] = language
|
||||
|
||||
return response
|
||||
|
||||
async def _determine_language(self, request: Request) -> str:
|
||||
"""
|
||||
Determine the language to use based on priority order.
|
||||
|
||||
Priority:
|
||||
1. Query parameter (?lang=en)
|
||||
2. Accept-Language HTTP header
|
||||
3. User language preference (from database)
|
||||
4. Tenant default language
|
||||
5. System default language
|
||||
|
||||
Args:
|
||||
request: The incoming request
|
||||
|
||||
Returns:
|
||||
Language code (e.g., "zh", "en")
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
# 1. Check query parameter (?lang=en)
|
||||
if "lang" in request.query_params:
|
||||
lang = request.query_params["lang"].strip().lower()
|
||||
if lang:
|
||||
logger.debug(f"Language from query parameter: {lang}")
|
||||
return lang
|
||||
|
||||
# 2. Check Accept-Language HTTP header
|
||||
if "Accept-Language" in request.headers:
|
||||
lang = self._parse_accept_language(
|
||||
request.headers["Accept-Language"]
|
||||
)
|
||||
if lang:
|
||||
logger.debug(f"Language from Accept-Language header: {lang}")
|
||||
return lang
|
||||
|
||||
# 3. Check user language preference (requires authentication)
|
||||
# Note: This assumes user is already loaded into request.state by auth middleware
|
||||
if hasattr(request.state, "user") and request.state.user:
|
||||
user = request.state.user
|
||||
if hasattr(user, "preferred_language") and user.preferred_language:
|
||||
logger.debug(
|
||||
f"Language from user preference: {user.preferred_language}"
|
||||
)
|
||||
return user.preferred_language
|
||||
|
||||
# 4. Check tenant default language
|
||||
# Note: This assumes tenant is already loaded into request.state
|
||||
if hasattr(request.state, "tenant") and request.state.tenant:
|
||||
tenant = request.state.tenant
|
||||
if hasattr(tenant, "default_language") and tenant.default_language:
|
||||
logger.debug(
|
||||
f"Language from tenant default: {tenant.default_language}"
|
||||
)
|
||||
return tenant.default_language
|
||||
|
||||
# 5. Fall back to system default language
|
||||
logger.debug(
|
||||
f"Using system default language: {settings.I18N_DEFAULT_LANGUAGE}"
|
||||
)
|
||||
return settings.I18N_DEFAULT_LANGUAGE
|
||||
|
||||
def _parse_accept_language(self, header: str) -> Optional[str]:
|
||||
"""
|
||||
Parse the Accept-Language HTTP header.
|
||||
|
||||
The Accept-Language header format:
|
||||
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7
|
||||
|
||||
This method:
|
||||
1. Parses all language codes and their quality values
|
||||
2. Extracts the base language code (zh-CN -> zh)
|
||||
3. Sorts by quality value (higher first)
|
||||
4. Returns the first supported language
|
||||
|
||||
Args:
|
||||
header: Accept-Language header value
|
||||
|
||||
Returns:
|
||||
Language code if found and supported, None otherwise
|
||||
|
||||
Examples:
|
||||
_parse_accept_language("zh-CN,zh;q=0.9,en;q=0.8")
|
||||
# => "zh" (if zh is supported)
|
||||
|
||||
_parse_accept_language("en-US,en;q=0.9")
|
||||
# => "en" (if en is supported)
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
if not header:
|
||||
return None
|
||||
|
||||
# Parse language preferences with quality values
|
||||
languages = []
|
||||
|
||||
for item in header.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
|
||||
# Split language code and quality value
|
||||
parts = item.split(";")
|
||||
lang_code = parts[0].strip()
|
||||
|
||||
# Extract base language code (zh-CN -> zh, en-US -> en)
|
||||
base_lang = lang_code.split("-")[0].lower()
|
||||
|
||||
# Extract quality value (default: 1.0)
|
||||
quality = 1.0
|
||||
if len(parts) > 1:
|
||||
# Look for q=0.9 pattern
|
||||
q_match = re.search(r"q=([\d.]+)", parts[1])
|
||||
if q_match:
|
||||
try:
|
||||
quality = float(q_match.group(1))
|
||||
except ValueError:
|
||||
quality = 1.0
|
||||
|
||||
languages.append((base_lang, quality))
|
||||
|
||||
# Sort by quality value (descending)
|
||||
languages.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return the first supported language
|
||||
for lang_code, _ in languages:
|
||||
if lang_code in settings.I18N_SUPPORTED_LANGUAGES:
|
||||
return lang_code
|
||||
|
||||
return None
|
||||
219
api/app/i18n/serializers.py
Normal file
219
api/app/i18n/serializers.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
国际化响应序列化器
|
||||
|
||||
提供基础的 I18nResponseMixin 类,用于为 API 响应添加国际化字段。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Union
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class I18nResponseMixin:
|
||||
"""国际化响应混入类
|
||||
|
||||
为响应数据添加国际化字段,特别是为枚举值添加 _display 后缀的翻译字段。
|
||||
|
||||
使用方法:
|
||||
1. 继承此类
|
||||
2. 实现 _get_enum_fields() 方法定义需要翻译的枚举字段
|
||||
3. 调用 serialize_with_i18n() 方法序列化数据
|
||||
|
||||
示例:
|
||||
class WorkspaceSerializer(I18nResponseMixin):
|
||||
def _get_enum_fields(self) -> Dict[str, str]:
|
||||
return {
|
||||
"role": "workspace_role",
|
||||
"status": "workspace_status"
|
||||
}
|
||||
|
||||
def serialize(self, workspace: Workspace, locale: str = "zh") -> Dict:
|
||||
data = {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"role": workspace.role,
|
||||
"status": workspace.status
|
||||
}
|
||||
return self.serialize_with_i18n(data, locale)
|
||||
"""
|
||||
|
||||
def serialize_with_i18n(
|
||||
self,
|
||||
data: Any,
|
||||
locale: str = "zh"
|
||||
) -> Union[Dict, List[Dict], Any]:
|
||||
"""序列化数据并添加国际化字段
|
||||
|
||||
Args:
|
||||
data: 要序列化的数据(字典、列表或 Pydantic 模型)
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的数据,包含国际化字段
|
||||
"""
|
||||
# 如果是 Pydantic 模型,转换为字典
|
||||
if isinstance(data, BaseModel):
|
||||
data = data.model_dump()
|
||||
|
||||
# 处理不同类型的数据
|
||||
if isinstance(data, dict):
|
||||
return self._serialize_dict(data, locale)
|
||||
elif isinstance(data, list):
|
||||
return [self._serialize_dict(item, locale) if isinstance(item, dict) else item for item in data]
|
||||
else:
|
||||
return data
|
||||
|
||||
def _serialize_dict(self, data: Dict, locale: str) -> Dict:
|
||||
"""序列化字典并添加 _display 字段
|
||||
|
||||
Args:
|
||||
data: 字典数据
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
添加了 _display 字段的字典
|
||||
"""
|
||||
from app.i18n.service import translation_service
|
||||
|
||||
result = data.copy()
|
||||
|
||||
# 获取需要翻译的枚举字段
|
||||
enum_fields = self._get_enum_fields()
|
||||
|
||||
# 为每个枚举字段添加 _display 字段
|
||||
for field, enum_type in enum_fields.items():
|
||||
if field in result and result[field] is not None:
|
||||
value = result[field]
|
||||
# 翻译枚举值
|
||||
display_value = translation_service.translate_enum(
|
||||
enum_type=enum_type,
|
||||
value=str(value),
|
||||
locale=locale
|
||||
)
|
||||
# 添加 _display 字段
|
||||
result[f"{field}_display"] = display_value
|
||||
|
||||
return result
|
||||
|
||||
def _get_enum_fields(self) -> Dict[str, str]:
|
||||
"""获取需要翻译的枚举字段
|
||||
|
||||
子类必须实现此方法,返回字段名到枚举类型的映射。
|
||||
|
||||
Returns:
|
||||
字段名到枚举类型的映射
|
||||
例如: {"role": "workspace_role", "status": "workspace_status"}
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
class WorkspaceSerializer(I18nResponseMixin):
|
||||
"""工作空间序列化器
|
||||
|
||||
为工作空间响应添加国际化字段。
|
||||
"""
|
||||
|
||||
def _get_enum_fields(self) -> Dict[str, str]:
|
||||
"""定义工作空间的枚举字段"""
|
||||
return {
|
||||
"role": "workspace_role",
|
||||
"status": "workspace_status"
|
||||
}
|
||||
|
||||
def serialize(self, workspace_data: Union[Dict, BaseModel], locale: str = "zh") -> Dict:
|
||||
"""序列化工作空间数据
|
||||
|
||||
Args:
|
||||
workspace_data: 工作空间数据(字典或 Pydantic 模型)
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的工作空间数据,包含国际化字段
|
||||
"""
|
||||
return self.serialize_with_i18n(workspace_data, locale)
|
||||
|
||||
def serialize_list(self, workspaces: List[Union[Dict, BaseModel]], locale: str = "zh") -> List[Dict]:
|
||||
"""序列化工作空间列表
|
||||
|
||||
Args:
|
||||
workspaces: 工作空间列表
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的工作空间列表
|
||||
"""
|
||||
return [self.serialize(ws, locale) for ws in workspaces]
|
||||
|
||||
|
||||
class WorkspaceMemberSerializer(I18nResponseMixin):
|
||||
"""工作空间成员序列化器
|
||||
|
||||
为工作空间成员响应添加国际化字段。
|
||||
"""
|
||||
|
||||
def _get_enum_fields(self) -> Dict[str, str]:
|
||||
"""定义工作空间成员的枚举字段"""
|
||||
return {
|
||||
"role": "workspace_role"
|
||||
}
|
||||
|
||||
def serialize(self, member_data: Union[Dict, BaseModel], locale: str = "zh") -> Dict:
|
||||
"""序列化工作空间成员数据
|
||||
|
||||
Args:
|
||||
member_data: 成员数据(字典或 Pydantic 模型)
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的成员数据,包含国际化字段
|
||||
"""
|
||||
return self.serialize_with_i18n(member_data, locale)
|
||||
|
||||
def serialize_list(self, members: List[Union[Dict, BaseModel]], locale: str = "zh") -> List[Dict]:
|
||||
"""序列化工作空间成员列表
|
||||
|
||||
Args:
|
||||
members: 成员列表
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的成员列表
|
||||
"""
|
||||
return [self.serialize(member, locale) for member in members]
|
||||
|
||||
|
||||
class WorkspaceInviteSerializer(I18nResponseMixin):
|
||||
"""工作空间邀请序列化器
|
||||
|
||||
为工作空间邀请响应添加国际化字段。
|
||||
"""
|
||||
|
||||
def _get_enum_fields(self) -> Dict[str, str]:
|
||||
"""定义工作空间邀请的枚举字段"""
|
||||
return {
|
||||
"status": "invite_status",
|
||||
"role": "workspace_role"
|
||||
}
|
||||
|
||||
def serialize(self, invite_data: Union[Dict, BaseModel], locale: str = "zh") -> Dict:
|
||||
"""序列化工作空间邀请数据
|
||||
|
||||
Args:
|
||||
invite_data: 邀请数据(字典或 Pydantic 模型)
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的邀请数据,包含国际化字段
|
||||
"""
|
||||
return self.serialize_with_i18n(invite_data, locale)
|
||||
|
||||
def serialize_list(self, invites: List[Union[Dict, BaseModel]], locale: str = "zh") -> List[Dict]:
|
||||
"""序列化工作空间邀请列表
|
||||
|
||||
Args:
|
||||
invites: 邀请列表
|
||||
locale: 语言代码
|
||||
|
||||
Returns:
|
||||
序列化后的邀请列表
|
||||
"""
|
||||
return [self.serialize(invite, locale) for invite in invites]
|
||||
370
api/app/i18n/service.py
Normal file
370
api/app/i18n/service.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Translation service for i18n system.
|
||||
|
||||
This module provides the core translation functionality including:
|
||||
- Translation lookup with fallback mechanism
|
||||
- Parameterized message support
|
||||
- Enum value translation
|
||||
- Memory caching for performance
|
||||
- Performance monitoring and metrics
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.i18n.loader import TranslationLoader
|
||||
from app.i18n.cache import TranslationCache
|
||||
from app.i18n.metrics import get_metrics, monitor_performance, track_missing_translation, track_translation_request
|
||||
from app.i18n.logger import get_translation_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranslationService:
|
||||
"""
|
||||
Translation service that provides:
|
||||
- Fast translation lookup with memory cache
|
||||
- Parameterized message support ({param} syntax)
|
||||
- Fallback mechanism (current locale → default locale → key)
|
||||
- Enum value translation
|
||||
- Deep merge of multi-directory translations
|
||||
"""
|
||||
|
||||
def __init__(self, locales_dirs: Optional[list] = None):
|
||||
"""
|
||||
Initialize the translation service.
|
||||
|
||||
Args:
|
||||
locales_dirs: List of directories containing translation files.
|
||||
If None, will auto-detect from settings.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
self.loader = TranslationLoader(locales_dirs)
|
||||
self.default_locale = settings.I18N_DEFAULT_LANGUAGE
|
||||
self.fallback_locale = settings.I18N_FALLBACK_LANGUAGE
|
||||
self.log_missing = settings.I18N_LOG_MISSING_TRANSLATIONS
|
||||
self.enable_cache = settings.I18N_ENABLE_TRANSLATION_CACHE
|
||||
|
||||
# Initialize advanced cache with LRU
|
||||
lru_cache_size = getattr(settings, 'I18N_LRU_CACHE_SIZE', 1000)
|
||||
self.cache = TranslationCache(
|
||||
max_lru_size=lru_cache_size,
|
||||
enable_lazy_load=False # Load all at startup for now
|
||||
)
|
||||
|
||||
# Load all translations into cache
|
||||
self._load_all_locales()
|
||||
|
||||
# Initialize metrics
|
||||
self.metrics = get_metrics()
|
||||
|
||||
# Initialize translation logger
|
||||
self.translation_logger = get_translation_logger()
|
||||
|
||||
logger.info(
|
||||
f"TranslationService initialized with default locale: {self.default_locale}, "
|
||||
f"LRU cache size: {lru_cache_size}"
|
||||
)
|
||||
|
||||
def _load_all_locales(self):
|
||||
"""Load all available locales into memory cache."""
|
||||
available_locales = self.loader.get_available_locales()
|
||||
logger.info(f"Loading translations for locales: {available_locales}")
|
||||
|
||||
for locale in available_locales:
|
||||
locale_data = self.loader.load_locale(locale)
|
||||
self.cache.set_locale_data(locale, locale_data)
|
||||
|
||||
logger.info(f"Loaded {len(available_locales)} locales into cache")
|
||||
|
||||
@monitor_performance("translate")
|
||||
def translate(
|
||||
self,
|
||||
key: str,
|
||||
locale: Optional[str] = None,
|
||||
**params
|
||||
) -> str:
|
||||
"""
|
||||
Translate a key to the target locale.
|
||||
|
||||
Supports:
|
||||
- Dot-separated keys (e.g., "common.success.created")
|
||||
- Parameterized messages (e.g., "Hello {name}")
|
||||
- Fallback mechanism
|
||||
|
||||
Args:
|
||||
key: Translation key (format: "namespace.key.subkey")
|
||||
locale: Target locale (defaults to default locale)
|
||||
**params: Parameters for parameterized messages
|
||||
|
||||
Returns:
|
||||
Translated string, or the key itself if translation not found
|
||||
|
||||
Examples:
|
||||
translate("common.success.created", "zh")
|
||||
# => "创建成功"
|
||||
|
||||
translate("common.validation.required", "zh", field="名称")
|
||||
# => "名称不能为空"
|
||||
"""
|
||||
if locale is None:
|
||||
locale = self.default_locale
|
||||
|
||||
# Parse key (namespace.key.subkey)
|
||||
parts = key.split(".", 1)
|
||||
if len(parts) < 2:
|
||||
if self.log_missing:
|
||||
logger.warning(f"Invalid translation key format: {key}")
|
||||
return key
|
||||
|
||||
namespace = parts[0]
|
||||
key_path = parts[1].split(".")
|
||||
|
||||
# Track request
|
||||
track_translation_request(locale, namespace)
|
||||
|
||||
# Get translation from cache
|
||||
translation = self.cache.get_translation(locale, namespace, key_path)
|
||||
|
||||
# Fallback to default locale if not found
|
||||
if translation is None and locale != self.fallback_locale:
|
||||
translation = self.cache.get_translation(
|
||||
self.fallback_locale, namespace, key_path
|
||||
)
|
||||
|
||||
# If still not found, return the key itself
|
||||
if translation is None:
|
||||
if self.log_missing:
|
||||
logger.warning(
|
||||
f"Missing translation: {key} (locale: {locale})"
|
||||
)
|
||||
track_missing_translation(key, locale)
|
||||
|
||||
# Log to translation logger with context
|
||||
self.translation_logger.log_missing_translation(
|
||||
key=key,
|
||||
locale=locale,
|
||||
context={"namespace": namespace}
|
||||
)
|
||||
return key
|
||||
|
||||
# Apply parameters if provided
|
||||
if params:
|
||||
try:
|
||||
translation = translation.format(**params)
|
||||
except KeyError as e:
|
||||
error_msg = f"Missing parameter in translation '{key}': {e}"
|
||||
logger.error(error_msg)
|
||||
self.translation_logger.log_translation_error(
|
||||
error_type="parameter_missing",
|
||||
message=error_msg,
|
||||
key=key,
|
||||
locale=locale,
|
||||
context={"params": list(params.keys())}
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = f"Error formatting translation '{key}': {e}"
|
||||
logger.error(error_msg)
|
||||
self.translation_logger.log_translation_error(
|
||||
error_type="format_error",
|
||||
message=error_msg,
|
||||
key=key,
|
||||
locale=locale
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
def _get_translation(
|
||||
self,
|
||||
locale: str,
|
||||
namespace: str,
|
||||
key_path: list
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get translation from cache (deprecated, use cache.get_translation).
|
||||
|
||||
Args:
|
||||
locale: Locale code
|
||||
namespace: Translation namespace
|
||||
key_path: List of nested keys
|
||||
|
||||
Returns:
|
||||
Translation string or None if not found
|
||||
"""
|
||||
return self.cache.get_translation(locale, namespace, key_path)
|
||||
|
||||
@monitor_performance("translate_enum")
|
||||
def translate_enum(
|
||||
self,
|
||||
enum_type: str,
|
||||
value: str,
|
||||
locale: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Translate an enum value.
|
||||
|
||||
Args:
|
||||
enum_type: Enum type name (e.g., "workspace_role")
|
||||
value: Enum value (e.g., "manager")
|
||||
locale: Target locale
|
||||
|
||||
Returns:
|
||||
Translated enum display name
|
||||
|
||||
Examples:
|
||||
translate_enum("workspace_role", "manager", "zh")
|
||||
# => "管理员"
|
||||
|
||||
translate_enum("invite_status", "pending", "en")
|
||||
# => "Pending"
|
||||
"""
|
||||
key = f"enums.{enum_type}.{value}"
|
||||
return self.translate(key, locale)
|
||||
|
||||
def has_translation(self, key: str, locale: str) -> bool:
|
||||
"""
|
||||
Check if a translation exists for the given key and locale.
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Locale code
|
||||
|
||||
Returns:
|
||||
True if translation exists, False otherwise
|
||||
"""
|
||||
parts = key.split(".", 1)
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
|
||||
namespace = parts[0]
|
||||
key_path = parts[1].split(".")
|
||||
|
||||
translation = self.cache.get_translation(locale, namespace, key_path)
|
||||
return translation is not None
|
||||
|
||||
def reload(self, locale: Optional[str] = None):
|
||||
"""
|
||||
Reload translation files.
|
||||
|
||||
Args:
|
||||
locale: Specific locale to reload. If None, reloads all locales.
|
||||
"""
|
||||
logger.info(f"Reloading translations for locale: {locale or 'all'}")
|
||||
|
||||
if locale:
|
||||
locale_data = self.loader.load_locale(locale)
|
||||
self.cache.set_locale_data(locale, locale_data)
|
||||
# Clear LRU cache for this locale
|
||||
self.cache.clear_locale(locale)
|
||||
else:
|
||||
self._load_all_locales()
|
||||
# Clear all LRU cache
|
||||
self.cache.clear_lru()
|
||||
|
||||
logger.info("Translation reload completed")
|
||||
|
||||
def get_available_locales(self) -> list:
|
||||
"""
|
||||
Get list of all available locales.
|
||||
|
||||
Returns:
|
||||
List of locale codes
|
||||
"""
|
||||
return self.cache.get_loaded_locales()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
return self.cache.get_stats()
|
||||
|
||||
def get_metrics_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get metrics summary.
|
||||
|
||||
Returns:
|
||||
Dictionary with metrics summary
|
||||
"""
|
||||
return self.metrics.get_summary()
|
||||
|
||||
def get_memory_usage(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get memory usage information.
|
||||
|
||||
Returns:
|
||||
Dictionary with memory usage information
|
||||
"""
|
||||
return self.cache.get_memory_usage()
|
||||
|
||||
def get_loaded_dirs(self) -> list:
|
||||
"""
|
||||
Get list of loaded translation directories.
|
||||
|
||||
Returns:
|
||||
List of directory paths
|
||||
"""
|
||||
return self.loader.locales_dirs
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_translation_service: Optional[TranslationService] = None
|
||||
|
||||
|
||||
def get_translation_service() -> TranslationService:
|
||||
"""
|
||||
Get the global translation service instance.
|
||||
|
||||
Returns:
|
||||
TranslationService singleton
|
||||
"""
|
||||
global _translation_service
|
||||
if _translation_service is None:
|
||||
_translation_service = TranslationService()
|
||||
return _translation_service
|
||||
|
||||
|
||||
# Convenience functions for easy access
|
||||
def t(key: str, locale: Optional[str] = None, **params) -> str:
|
||||
"""
|
||||
Translate a key (convenience function).
|
||||
|
||||
Args:
|
||||
key: Translation key
|
||||
locale: Target locale (optional, uses default if not provided)
|
||||
**params: Parameters for parameterized messages
|
||||
|
||||
Returns:
|
||||
Translated string
|
||||
|
||||
Examples:
|
||||
t("common.success.created")
|
||||
t("common.validation.required", field="名称")
|
||||
t("workspace.member_count", count=5)
|
||||
"""
|
||||
service = get_translation_service()
|
||||
return service.translate(key, locale, **params)
|
||||
|
||||
|
||||
def t_enum(enum_type: str, value: str, locale: Optional[str] = None) -> str:
|
||||
"""
|
||||
Translate an enum value (convenience function).
|
||||
|
||||
Args:
|
||||
enum_type: Enum type name
|
||||
value: Enum value
|
||||
locale: Target locale
|
||||
|
||||
Returns:
|
||||
Translated enum display name
|
||||
|
||||
Examples:
|
||||
t_enum("workspace_role", "manager")
|
||||
t_enum("invite_status", "pending", "en")
|
||||
"""
|
||||
service = get_translation_service()
|
||||
return service.translate_enum(enum_type, value, locale)
|
||||
26
api/app/locales/en/README.md
Normal file
26
api/app/locales/en/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# English Translation Files
|
||||
|
||||
This directory contains English translation files.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `common.json` - Common translations (success messages, actions, validation)
|
||||
- `auth.json` - Authentication module translations
|
||||
- `workspace.json` - Workspace module translations
|
||||
- `tenant.json` - Tenant module translations
|
||||
- `errors.json` - Error message translations
|
||||
- `enums.json` - Enum value translations
|
||||
|
||||
## Translation File Format
|
||||
|
||||
All translation files use JSON format and support nested structures.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"success": {
|
||||
"created": "Created successfully",
|
||||
"updated": "Updated successfully"
|
||||
}
|
||||
}
|
||||
```
|
||||
55
api/app/locales/en/auth.json
Normal file
55
api/app/locales/en/auth.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"login": {
|
||||
"success": "Login successful",
|
||||
"failed": "Login failed",
|
||||
"invalid_credentials": "Invalid username or password",
|
||||
"account_locked": "Account has been locked",
|
||||
"account_disabled": "Account has been disabled"
|
||||
},
|
||||
"logout": {
|
||||
"success": "Logout successful",
|
||||
"failed": "Logout failed"
|
||||
},
|
||||
"token": {
|
||||
"refresh_success": "Token refreshed successfully",
|
||||
"invalid": "Invalid token",
|
||||
"expired": "Token has expired",
|
||||
"blacklisted": "Token has been invalidated",
|
||||
"invalid_refresh_token": "Invalid refresh token",
|
||||
"refresh_token_blacklisted": "Refresh token has been invalidated"
|
||||
},
|
||||
"registration": {
|
||||
"success": "Registration successful",
|
||||
"failed": "Registration failed",
|
||||
"email_exists": "Email already in use",
|
||||
"username_exists": "Username already taken"
|
||||
},
|
||||
"password": {
|
||||
"reset_success": "Password reset successful",
|
||||
"reset_failed": "Password reset failed",
|
||||
"change_success": "Password changed successfully",
|
||||
"change_failed": "Password change failed",
|
||||
"incorrect": "Incorrect password",
|
||||
"too_weak": "Password is too weak",
|
||||
"mismatch": "Passwords do not match"
|
||||
},
|
||||
"invite": {
|
||||
"invalid": "Invalid or expired invite code",
|
||||
"email_mismatch": "Invite email does not match login email",
|
||||
"accept_success": "Invite accepted successfully",
|
||||
"accept_failed": "Failed to accept invite",
|
||||
"password_verification_failed": "Failed to accept invite, password verification error",
|
||||
"bind_workspace_success": "Workspace bound successfully",
|
||||
"bind_workspace_failed": "Failed to bind workspace"
|
||||
},
|
||||
"user": {
|
||||
"not_found": "User not found",
|
||||
"already_exists": "User already exists",
|
||||
"created_with_invite": "User created successfully and joined workspace"
|
||||
},
|
||||
"session": {
|
||||
"expired": "Session expired, please login again",
|
||||
"invalid": "Invalid session",
|
||||
"single_session_enabled": "Single sign-on enabled, other device sessions will be logged out"
|
||||
}
|
||||
}
|
||||
132
api/app/locales/en/common.json
Normal file
132
api/app/locales/en/common.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"success": {
|
||||
"created": "Created successfully",
|
||||
"updated": "Updated successfully",
|
||||
"deleted": "Deleted successfully",
|
||||
"retrieved": "Retrieved successfully",
|
||||
"saved": "Saved successfully",
|
||||
"uploaded": "Uploaded successfully",
|
||||
"downloaded": "Downloaded successfully",
|
||||
"sent": "Sent successfully",
|
||||
"completed": "Completed",
|
||||
"confirmed": "Confirmed",
|
||||
"cancelled": "Cancelled",
|
||||
"archived": "Archived",
|
||||
"restored": "Restored"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"view": "View",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"submit": "Submit",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"send": "Send",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"refresh": "Refresh",
|
||||
"reset": "Reset",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"finish": "Finish",
|
||||
"close": "Close",
|
||||
"open": "Open",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"duplicate": "Duplicate",
|
||||
"share": "Share",
|
||||
"invite": "Invite",
|
||||
"remove": "Remove",
|
||||
"add": "Add",
|
||||
"select": "Select",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"validation": {
|
||||
"required": "{field} is required",
|
||||
"invalid_format": "{field} format is invalid",
|
||||
"too_long": "{field} cannot exceed {max} characters",
|
||||
"too_short": "{field} must be at least {min} characters",
|
||||
"invalid_email": "Invalid email format",
|
||||
"invalid_url": "Invalid URL format",
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_date": "Invalid date format",
|
||||
"invalid_number": "Must be a valid number",
|
||||
"out_of_range": "{field} must be between {min} and {max}",
|
||||
"already_exists": "{field} already exists",
|
||||
"not_found": "{field} not found",
|
||||
"invalid_value": "Invalid value for {field}",
|
||||
"password_mismatch": "Passwords do not match",
|
||||
"weak_password": "Password is too weak, please use a stronger password",
|
||||
"invalid_credentials": "Invalid username or password",
|
||||
"unauthorized": "Unauthorized access",
|
||||
"forbidden": "Permission denied",
|
||||
"expired": "{field} has expired",
|
||||
"invalid_token": "Invalid token",
|
||||
"file_too_large": "File size cannot exceed {max}",
|
||||
"invalid_file_type": "Unsupported file type",
|
||||
"duplicate": "Duplicate {field}"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"archived": "Archived",
|
||||
"deleted": "Deleted",
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"suspended": "Suspended",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"messages": {
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"processing": "Processing...",
|
||||
"uploading": "Uploading...",
|
||||
"downloading": "Downloading...",
|
||||
"no_data": "No data available",
|
||||
"no_results": "No results found",
|
||||
"confirm_delete": "Are you sure you want to delete? This action cannot be undone.",
|
||||
"confirm_action": "Are you sure you want to perform this action?",
|
||||
"operation_success": "Operation successful",
|
||||
"operation_failed": "Operation failed",
|
||||
"please_wait": "Please wait...",
|
||||
"try_again": "Please try again",
|
||||
"contact_support": "If the problem persists, please contact support"
|
||||
},
|
||||
"pagination": {
|
||||
"page": "Page {page}",
|
||||
"of": "of {total}",
|
||||
"items": "{total} items",
|
||||
"per_page": "{count} per page",
|
||||
"showing": "Showing {from} to {to} of {total}",
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{count} minutes ago",
|
||||
"hours_ago": "{count} hours ago",
|
||||
"days_ago": "{count} days ago",
|
||||
"weeks_ago": "{count} weeks ago",
|
||||
"months_ago": "{count} months ago",
|
||||
"years_ago": "{count} years ago",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow"
|
||||
}
|
||||
}
|
||||
132
api/app/locales/en/enums.json
Normal file
132
api/app/locales/en/enums.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"workspace_role": {
|
||||
"owner": "Owner",
|
||||
"manager": "Manager",
|
||||
"member": "Member",
|
||||
"guest": "Guest"
|
||||
},
|
||||
"workspace_status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"archived": "Archived",
|
||||
"suspended": "Suspended",
|
||||
"deleted": "Deleted"
|
||||
},
|
||||
"invite_status": {
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"rejected": "Rejected",
|
||||
"revoked": "Revoked",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"user_status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"suspended": "Suspended",
|
||||
"deleted": "Deleted",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"tenant_status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"suspended": "Suspended",
|
||||
"expired": "Expired",
|
||||
"trial": "Trial"
|
||||
},
|
||||
"file_status": {
|
||||
"uploading": "Uploading",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"deleted": "Deleted"
|
||||
},
|
||||
"task_status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"internal": "Internal",
|
||||
"shared": "Shared"
|
||||
},
|
||||
"permission": {
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"delete": "Delete",
|
||||
"admin": "Admin",
|
||||
"owner": "Owner"
|
||||
},
|
||||
"notification_type": {
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"language": {
|
||||
"zh": "Chinese (Simplified)",
|
||||
"en": "English",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"fr": "French",
|
||||
"de": "German",
|
||||
"es": "Spanish"
|
||||
},
|
||||
"timezone": {
|
||||
"utc": "UTC",
|
||||
"asia_shanghai": "Asia/Shanghai",
|
||||
"asia_tokyo": "Asia/Tokyo",
|
||||
"america_new_york": "America/New_York",
|
||||
"europe_london": "Europe/London"
|
||||
},
|
||||
"date_format": {
|
||||
"short": "Short",
|
||||
"medium": "Medium",
|
||||
"long": "Long",
|
||||
"full": "Full"
|
||||
},
|
||||
"sort_order": {
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
},
|
||||
"filter_operator": {
|
||||
"equals": "Equals",
|
||||
"not_equals": "Not Equals",
|
||||
"contains": "Contains",
|
||||
"not_contains": "Not Contains",
|
||||
"starts_with": "Starts With",
|
||||
"ends_with": "Ends With",
|
||||
"greater_than": "Greater Than",
|
||||
"less_than": "Less Than",
|
||||
"greater_or_equal": "Greater or Equal",
|
||||
"less_or_equal": "Less or Equal",
|
||||
"in": "In",
|
||||
"not_in": "Not In",
|
||||
"is_null": "Is Null",
|
||||
"is_not_null": "Is Not Null"
|
||||
},
|
||||
"log_level": {
|
||||
"debug": "Debug",
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"api_method": {
|
||||
"get": "GET",
|
||||
"post": "POST",
|
||||
"put": "PUT",
|
||||
"patch": "PATCH",
|
||||
"delete": "DELETE"
|
||||
}
|
||||
}
|
||||
138
api/app/locales/en/errors.json
Normal file
138
api/app/locales/en/errors.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"common": {
|
||||
"internal_error": "Internal server error",
|
||||
"network_error": "Network connection error",
|
||||
"timeout": "Request timeout",
|
||||
"service_unavailable": "Service temporarily unavailable",
|
||||
"bad_request": "Bad request parameters",
|
||||
"unauthorized": "Unauthorized access",
|
||||
"forbidden": "Access forbidden",
|
||||
"not_found": "Resource not found",
|
||||
"method_not_allowed": "Method not allowed",
|
||||
"conflict": "Resource conflict",
|
||||
"too_many_requests": "Too many requests, please try again later",
|
||||
"validation_failed": "Validation failed",
|
||||
"database_error": "Database operation failed",
|
||||
"file_operation_error": "File operation failed"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Invalid username or password",
|
||||
"token_expired": "Session expired, please login again",
|
||||
"token_invalid": "Invalid authentication token",
|
||||
"token_missing": "Authentication token missing",
|
||||
"unauthorized": "Unauthorized access",
|
||||
"forbidden": "Permission denied",
|
||||
"account_locked": "Account has been locked",
|
||||
"account_disabled": "Account has been disabled",
|
||||
"account_not_verified": "Account not verified",
|
||||
"password_incorrect": "Incorrect password",
|
||||
"password_too_weak": "Password is too weak",
|
||||
"password_expired": "Password expired, please change it",
|
||||
"email_not_verified": "Email not verified",
|
||||
"phone_not_verified": "Phone number not verified",
|
||||
"verification_code_invalid": "Invalid verification code",
|
||||
"verification_code_expired": "Verification code expired",
|
||||
"login_failed": "Login failed",
|
||||
"logout_failed": "Logout failed",
|
||||
"session_expired": "Session expired",
|
||||
"already_logged_in": "Already logged in",
|
||||
"not_logged_in": "Not logged in"
|
||||
},
|
||||
"user": {
|
||||
"not_found": "User not found",
|
||||
"already_exists": "User already exists",
|
||||
"email_already_exists": "Email already in use",
|
||||
"phone_already_exists": "Phone number already in use",
|
||||
"username_already_exists": "Username already taken",
|
||||
"invalid_email": "Invalid email format",
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_username": "Invalid username format",
|
||||
"create_failed": "Failed to create user",
|
||||
"update_failed": "Failed to update user",
|
||||
"delete_failed": "Failed to delete user",
|
||||
"cannot_delete_self": "Cannot delete yourself",
|
||||
"cannot_update_self_role": "Cannot update your own role",
|
||||
"profile_update_failed": "Failed to update profile",
|
||||
"avatar_upload_failed": "Failed to upload avatar",
|
||||
"password_change_failed": "Failed to change password",
|
||||
"old_password_incorrect": "Old password is incorrect"
|
||||
},
|
||||
"workspace": {
|
||||
"not_found": "Workspace not found",
|
||||
"already_exists": "Workspace already exists",
|
||||
"name_required": "Workspace name is required",
|
||||
"name_too_long": "Workspace name is too long",
|
||||
"create_failed": "Failed to create workspace",
|
||||
"update_failed": "Failed to update workspace",
|
||||
"delete_failed": "Failed to delete workspace",
|
||||
"permission_denied": "Permission denied to access this workspace",
|
||||
"not_member": "Not a workspace member",
|
||||
"already_member": "Already a workspace member",
|
||||
"member_limit_reached": "Member limit reached",
|
||||
"cannot_leave_last_manager": "Cannot leave, you are the last manager",
|
||||
"cannot_remove_last_manager": "Cannot remove the last manager",
|
||||
"cannot_remove_self": "Cannot remove yourself",
|
||||
"invite_not_found": "Invite not found",
|
||||
"invite_expired": "Invite has expired",
|
||||
"invite_already_accepted": "Invite already accepted",
|
||||
"invite_already_revoked": "Invite already revoked",
|
||||
"invite_send_failed": "Failed to send invite",
|
||||
"archived": "Workspace is archived",
|
||||
"suspended": "Workspace is suspended"
|
||||
},
|
||||
"tenant": {
|
||||
"not_found": "Tenant not found",
|
||||
"already_exists": "Tenant already exists",
|
||||
"create_failed": "Failed to create tenant",
|
||||
"update_failed": "Failed to update tenant",
|
||||
"delete_failed": "Failed to delete tenant",
|
||||
"suspended": "Tenant is suspended",
|
||||
"expired": "Tenant has expired",
|
||||
"license_invalid": "Invalid license",
|
||||
"license_expired": "License has expired",
|
||||
"quota_exceeded": "Quota exceeded"
|
||||
},
|
||||
"file": {
|
||||
"not_found": "File not found",
|
||||
"upload_failed": "File upload failed",
|
||||
"download_failed": "File download failed",
|
||||
"delete_failed": "File deletion failed",
|
||||
"too_large": "File size exceeds limit",
|
||||
"invalid_type": "Unsupported file type",
|
||||
"invalid_format": "Invalid file format",
|
||||
"corrupted": "File is corrupted",
|
||||
"storage_full": "Storage is full",
|
||||
"access_denied": "Access denied to this file"
|
||||
},
|
||||
"api": {
|
||||
"rate_limit_exceeded": "API rate limit exceeded",
|
||||
"quota_exceeded": "API quota exceeded",
|
||||
"invalid_api_key": "Invalid API key",
|
||||
"api_key_expired": "API key has expired",
|
||||
"api_key_revoked": "API key has been revoked",
|
||||
"endpoint_not_found": "API endpoint not found",
|
||||
"method_not_allowed": "Method not allowed",
|
||||
"invalid_request": "Invalid request",
|
||||
"missing_parameter": "Missing required parameter: {param}",
|
||||
"invalid_parameter": "Invalid parameter: {param}"
|
||||
},
|
||||
"database": {
|
||||
"connection_failed": "Database connection failed",
|
||||
"query_failed": "Database query failed",
|
||||
"transaction_failed": "Database transaction failed",
|
||||
"constraint_violation": "Data constraint violation",
|
||||
"duplicate_key": "Duplicate data",
|
||||
"foreign_key_violation": "Foreign key constraint violation",
|
||||
"deadlock": "Database deadlock"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_input": "Invalid input data",
|
||||
"missing_field": "Missing required field: {field}",
|
||||
"invalid_field": "Invalid field: {field}",
|
||||
"field_too_long": "Field too long: {field}",
|
||||
"field_too_short": "Field too short: {field}",
|
||||
"invalid_format": "Invalid format: {field}",
|
||||
"invalid_value": "Invalid value: {field}",
|
||||
"out_of_range": "Value out of range: {field}"
|
||||
}
|
||||
}
|
||||
27
api/app/locales/en/i18n.json
Normal file
27
api/app/locales/en/i18n.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"language": {
|
||||
"not_found": "Language {locale} not found",
|
||||
"already_exists": "Language {locale} already exists",
|
||||
"add_instructions": "Language {locale} validated successfully. Please create translation files in {dir} directory to complete the addition.",
|
||||
"update_instructions": "Language {locale} update validated successfully. Please update I18N_SUPPORTED_LANGUAGES environment variable to apply configuration changes."
|
||||
},
|
||||
"namespace": {
|
||||
"not_found": "Namespace {namespace} not found in language {locale}"
|
||||
},
|
||||
"translation": {
|
||||
"invalid_key_format": "Invalid translation key format: {key}. Should use format: namespace.key.subkey",
|
||||
"update_instructions": "Translation {locale}/{key} update validated successfully. Please modify the corresponding JSON translation file to apply changes."
|
||||
},
|
||||
"reload": {
|
||||
"disabled": "Translation hot reload is disabled. Please enable I18N_ENABLE_HOT_RELOAD in configuration.",
|
||||
"success": "Translations reloaded successfully",
|
||||
"failed": "Translation reload failed: {error}"
|
||||
},
|
||||
"metrics": {
|
||||
"reset_success": "Performance metrics reset successfully"
|
||||
},
|
||||
"logs": {
|
||||
"export_success": "Missing translations exported to: {file}",
|
||||
"clear_success": "Missing translation logs cleared successfully"
|
||||
}
|
||||
}
|
||||
63
api/app/locales/en/tenant.json
Normal file
63
api/app/locales/en/tenant.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"info": {
|
||||
"get_success": "Tenant information retrieved successfully",
|
||||
"get_failed": "Failed to retrieve tenant information",
|
||||
"update_success": "Tenant information updated successfully",
|
||||
"update_failed": "Failed to update tenant information"
|
||||
},
|
||||
"create": {
|
||||
"success": "Tenant created successfully",
|
||||
"failed": "Failed to create tenant"
|
||||
},
|
||||
"delete": {
|
||||
"success": "Tenant deleted successfully",
|
||||
"failed": "Failed to delete tenant"
|
||||
},
|
||||
"status": {
|
||||
"activate_success": "Tenant activated successfully",
|
||||
"activate_failed": "Failed to activate tenant",
|
||||
"deactivate_success": "Tenant deactivated successfully",
|
||||
"deactivate_failed": "Failed to deactivate tenant"
|
||||
},
|
||||
"language": {
|
||||
"get_success": "Tenant language configuration retrieved successfully",
|
||||
"get_failed": "Failed to retrieve tenant language configuration",
|
||||
"update_success": "Tenant language configuration updated successfully",
|
||||
"update_failed": "Failed to update tenant language configuration",
|
||||
"invalid_language": "Unsupported language code",
|
||||
"default_not_in_supported": "Default language must be in the supported languages list"
|
||||
},
|
||||
"list": {
|
||||
"get_success": "Tenant list retrieved successfully",
|
||||
"get_failed": "Failed to retrieve tenant list"
|
||||
},
|
||||
"users": {
|
||||
"list_success": "Tenant user list retrieved successfully",
|
||||
"list_failed": "Failed to retrieve tenant user list",
|
||||
"assign_success": "User assigned to tenant successfully",
|
||||
"assign_failed": "Failed to assign user to tenant",
|
||||
"remove_success": "User removed from tenant successfully",
|
||||
"remove_failed": "Failed to remove user from tenant"
|
||||
},
|
||||
"statistics": {
|
||||
"get_success": "Tenant statistics retrieved successfully",
|
||||
"get_failed": "Failed to retrieve tenant statistics"
|
||||
},
|
||||
"validation": {
|
||||
"name_required": "Tenant name is required",
|
||||
"name_invalid": "Invalid tenant name format",
|
||||
"name_too_long": "Tenant name cannot exceed {max} characters",
|
||||
"description_too_long": "Tenant description cannot exceed {max} characters",
|
||||
"language_code_invalid": "Invalid language code format",
|
||||
"supported_languages_empty": "Supported languages list cannot be empty"
|
||||
},
|
||||
"errors": {
|
||||
"not_found": "Tenant not found",
|
||||
"already_exists": "Tenant name already exists",
|
||||
"permission_denied": "Permission denied to access this tenant",
|
||||
"has_users": "Cannot delete tenant, associated users exist",
|
||||
"has_workspaces": "Cannot delete tenant, associated workspaces exist",
|
||||
"already_active": "Tenant is already active",
|
||||
"already_inactive": "Tenant is already inactive"
|
||||
}
|
||||
}
|
||||
72
api/app/locales/en/users.json
Normal file
72
api/app/locales/en/users.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"info": {
|
||||
"get_success": "User information retrieved successfully",
|
||||
"get_failed": "Failed to retrieve user information",
|
||||
"update_success": "User information updated successfully",
|
||||
"update_failed": "Failed to update user information"
|
||||
},
|
||||
"create": {
|
||||
"success": "User created successfully",
|
||||
"failed": "Failed to create user",
|
||||
"superuser_success": "Superuser created successfully",
|
||||
"superuser_failed": "Failed to create superuser"
|
||||
},
|
||||
"delete": {
|
||||
"success": "User deleted successfully",
|
||||
"failed": "Failed to delete user",
|
||||
"deactivate_success": "User deactivated successfully",
|
||||
"deactivate_failed": "Failed to deactivate user"
|
||||
},
|
||||
"activate": {
|
||||
"success": "User activated successfully",
|
||||
"failed": "Failed to activate user"
|
||||
},
|
||||
"language": {
|
||||
"get_success": "Language preference retrieved successfully",
|
||||
"get_failed": "Failed to retrieve language preference",
|
||||
"update_success": "Language preference updated successfully",
|
||||
"update_failed": "Failed to update language preference",
|
||||
"invalid_language": "Unsupported language code",
|
||||
"current": "Current language preference"
|
||||
},
|
||||
"email": {
|
||||
"change_success": "Email changed successfully",
|
||||
"change_failed": "Failed to change email",
|
||||
"code_sent": "Verification code has been sent to your email",
|
||||
"code_send_failed": "Failed to send verification code",
|
||||
"code_invalid": "Invalid or expired verification code",
|
||||
"already_exists": "Email already in use"
|
||||
},
|
||||
"list": {
|
||||
"get_success": "User list retrieved successfully",
|
||||
"get_failed": "Failed to retrieve user list",
|
||||
"superusers_success": "Tenant superuser list retrieved successfully",
|
||||
"superusers_failed": "Failed to retrieve tenant superuser list"
|
||||
},
|
||||
"validation": {
|
||||
"username_required": "Username is required",
|
||||
"username_invalid": "Invalid username format",
|
||||
"username_too_long": "Username cannot exceed {max} characters",
|
||||
"email_required": "Email is required",
|
||||
"email_invalid": "Invalid email format",
|
||||
"password_required": "Password is required",
|
||||
"password_too_short": "Password must be at least {min} characters",
|
||||
"password_too_long": "Password cannot exceed {max} characters",
|
||||
"old_password_required": "Old password is required",
|
||||
"new_password_required": "New password is required",
|
||||
"verification_code_required": "Verification code is required",
|
||||
"verification_code_invalid": "Invalid verification code format"
|
||||
},
|
||||
"errors": {
|
||||
"not_found": "User not found",
|
||||
"already_exists": "User already exists",
|
||||
"permission_denied": "Permission denied to access this user",
|
||||
"cannot_delete_self": "Cannot delete yourself",
|
||||
"cannot_deactivate_self": "Cannot deactivate yourself",
|
||||
"already_deactivated": "User is already deactivated",
|
||||
"already_activated": "User is already activated",
|
||||
"password_verification_failed": "Password verification failed",
|
||||
"old_password_incorrect": "Old password is incorrect",
|
||||
"same_as_old_password": "New password cannot be the same as old password"
|
||||
}
|
||||
}
|
||||
44
api/app/locales/en/workspace.json
Normal file
44
api/app/locales/en/workspace.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"list_retrieved": "Workspace list retrieved successfully",
|
||||
"created": "Workspace created successfully",
|
||||
"updated": "Workspace updated successfully",
|
||||
"deleted": "Workspace deleted successfully",
|
||||
"switched": "Workspace switched successfully",
|
||||
"not_found": "Workspace not found or access denied",
|
||||
"already_exists": "Workspace already exists",
|
||||
"permission_denied": "No permission to access this workspace",
|
||||
"name_required": "Workspace name is required",
|
||||
"invalid_name": "Invalid workspace name format",
|
||||
"members": {
|
||||
"list_retrieved": "Workspace members list retrieved successfully",
|
||||
"role_updated": "Member role updated successfully",
|
||||
"deleted": "Member deleted successfully",
|
||||
"not_found": "Member not found",
|
||||
"cannot_remove_self": "Cannot remove yourself",
|
||||
"cannot_remove_last_manager": "Cannot remove the last manager",
|
||||
"already_member": "User is already a workspace member"
|
||||
},
|
||||
"invites": {
|
||||
"created": "Invite created successfully",
|
||||
"list_retrieved": "Invite list retrieved successfully",
|
||||
"validated": "Invite validated successfully",
|
||||
"revoked": "Invite revoked successfully",
|
||||
"accepted": "Invite accepted",
|
||||
"not_found": "Invite not found",
|
||||
"expired": "Invite has expired",
|
||||
"already_used": "Invite has already been used",
|
||||
"invalid_token": "Invalid invite token",
|
||||
"email_required": "Email address is required",
|
||||
"invalid_email": "Invalid email address format"
|
||||
},
|
||||
"storage": {
|
||||
"type_retrieved": "Storage type retrieved successfully",
|
||||
"type_updated": "Storage type updated successfully",
|
||||
"invalid_type": "Invalid storage type"
|
||||
},
|
||||
"models": {
|
||||
"config_retrieved": "Model configuration retrieved successfully",
|
||||
"config_updated": "Model configuration updated successfully",
|
||||
"invalid_config": "Invalid model configuration"
|
||||
}
|
||||
}
|
||||
26
api/app/locales/zh/README.md
Normal file
26
api/app/locales/zh/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 中文翻译文件
|
||||
|
||||
此目录包含中文(简体)的翻译文件。
|
||||
|
||||
## 文件结构
|
||||
|
||||
- `common.json` - 通用翻译(成功消息、操作、验证)
|
||||
- `auth.json` - 认证模块翻译
|
||||
- `workspace.json` - 工作空间模块翻译
|
||||
- `tenant.json` - 租户模块翻译
|
||||
- `errors.json` - 错误消息翻译
|
||||
- `enums.json` - 枚举值翻译
|
||||
|
||||
## 翻译文件格式
|
||||
|
||||
所有翻译文件使用 JSON 格式,支持嵌套结构。
|
||||
|
||||
示例:
|
||||
```json
|
||||
{
|
||||
"success": {
|
||||
"created": "创建成功",
|
||||
"updated": "更新成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
55
api/app/locales/zh/auth.json
Normal file
55
api/app/locales/zh/auth.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"login": {
|
||||
"success": "登录成功",
|
||||
"failed": "登录失败",
|
||||
"invalid_credentials": "用户名或密码错误",
|
||||
"account_locked": "账户已被锁定",
|
||||
"account_disabled": "账户已被禁用"
|
||||
},
|
||||
"logout": {
|
||||
"success": "登出成功",
|
||||
"failed": "登出失败"
|
||||
},
|
||||
"token": {
|
||||
"refresh_success": "token刷新成功",
|
||||
"invalid": "无效的token",
|
||||
"expired": "token已过期",
|
||||
"blacklisted": "token已失效",
|
||||
"invalid_refresh_token": "无效的refresh token",
|
||||
"refresh_token_blacklisted": "Refresh token已失效"
|
||||
},
|
||||
"registration": {
|
||||
"success": "注册成功",
|
||||
"failed": "注册失败",
|
||||
"email_exists": "邮箱已被使用",
|
||||
"username_exists": "用户名已被使用"
|
||||
},
|
||||
"password": {
|
||||
"reset_success": "密码重置成功",
|
||||
"reset_failed": "密码重置失败",
|
||||
"change_success": "密码修改成功",
|
||||
"change_failed": "密码修改失败",
|
||||
"incorrect": "密码错误",
|
||||
"too_weak": "密码强度不够",
|
||||
"mismatch": "两次输入的密码不一致"
|
||||
},
|
||||
"invite": {
|
||||
"invalid": "邀请码无效或已过期",
|
||||
"email_mismatch": "邀请邮箱与登录邮箱不匹配",
|
||||
"accept_success": "接受邀请成功",
|
||||
"accept_failed": "接受邀请失败",
|
||||
"password_verification_failed": "接受邀请失败,密码验证错误",
|
||||
"bind_workspace_success": "绑定工作空间成功",
|
||||
"bind_workspace_failed": "绑定工作空间失败"
|
||||
},
|
||||
"user": {
|
||||
"not_found": "用户不存在",
|
||||
"already_exists": "用户已存在",
|
||||
"created_with_invite": "用户创建成功并已加入工作空间"
|
||||
},
|
||||
"session": {
|
||||
"expired": "会话已过期,请重新登录",
|
||||
"invalid": "无效的会话",
|
||||
"single_session_enabled": "单点登录已启用,其他设备的登录将被注销"
|
||||
}
|
||||
}
|
||||
132
api/app/locales/zh/common.json
Normal file
132
api/app/locales/zh/common.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"success": {
|
||||
"created": "创建成功",
|
||||
"updated": "更新成功",
|
||||
"deleted": "删除成功",
|
||||
"retrieved": "获取成功",
|
||||
"saved": "保存成功",
|
||||
"uploaded": "上传成功",
|
||||
"downloaded": "下载成功",
|
||||
"sent": "发送成功",
|
||||
"completed": "完成",
|
||||
"confirmed": "已确认",
|
||||
"cancelled": "已取消",
|
||||
"archived": "已归档",
|
||||
"restored": "已恢复"
|
||||
},
|
||||
"actions": {
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"delete": "删除",
|
||||
"view": "查看",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"submit": "提交",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"send": "发送",
|
||||
"search": "搜索",
|
||||
"filter": "筛选",
|
||||
"sort": "排序",
|
||||
"export": "导出",
|
||||
"import": "导入",
|
||||
"refresh": "刷新",
|
||||
"reset": "重置",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"finish": "完成",
|
||||
"close": "关闭",
|
||||
"open": "打开",
|
||||
"archive": "归档",
|
||||
"restore": "恢复",
|
||||
"duplicate": "复制",
|
||||
"share": "分享",
|
||||
"invite": "邀请",
|
||||
"remove": "移除",
|
||||
"add": "添加",
|
||||
"select": "选择",
|
||||
"clear": "清除"
|
||||
},
|
||||
"validation": {
|
||||
"required": "{field}不能为空",
|
||||
"invalid_format": "{field}格式不正确",
|
||||
"too_long": "{field}长度不能超过{max}个字符",
|
||||
"too_short": "{field}长度不能少于{min}个字符",
|
||||
"invalid_email": "邮箱格式不正确",
|
||||
"invalid_url": "URL格式不正确",
|
||||
"invalid_phone": "手机号格式不正确",
|
||||
"invalid_date": "日期格式不正确",
|
||||
"invalid_number": "必须是有效的数字",
|
||||
"out_of_range": "{field}必须在{min}和{max}之间",
|
||||
"already_exists": "{field}已存在",
|
||||
"not_found": "{field}不存在",
|
||||
"invalid_value": "{field}的值无效",
|
||||
"password_mismatch": "两次输入的密码不一致",
|
||||
"weak_password": "密码强度不够,请使用更复杂的密码",
|
||||
"invalid_credentials": "用户名或密码错误",
|
||||
"unauthorized": "未授权访问",
|
||||
"forbidden": "没有权限执行此操作",
|
||||
"expired": "{field}已过期",
|
||||
"invalid_token": "无效的令牌",
|
||||
"file_too_large": "文件大小不能超过{max}",
|
||||
"invalid_file_type": "不支持的文件类型",
|
||||
"duplicate": "重复的{field}"
|
||||
},
|
||||
"status": {
|
||||
"active": "活跃",
|
||||
"inactive": "未激活",
|
||||
"pending": "待处理",
|
||||
"processing": "处理中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"cancelled": "已取消",
|
||||
"archived": "已归档",
|
||||
"deleted": "已删除",
|
||||
"draft": "草稿",
|
||||
"published": "已发布",
|
||||
"suspended": "已暂停",
|
||||
"expired": "已过期"
|
||||
},
|
||||
"messages": {
|
||||
"loading": "加载中...",
|
||||
"saving": "保存中...",
|
||||
"processing": "处理中...",
|
||||
"uploading": "上传中...",
|
||||
"downloading": "下载中...",
|
||||
"no_data": "暂无数据",
|
||||
"no_results": "没有找到结果",
|
||||
"confirm_delete": "确定要删除吗?此操作不可恢复。",
|
||||
"confirm_action": "确定要执行此操作吗?",
|
||||
"operation_success": "操作成功",
|
||||
"operation_failed": "操作失败",
|
||||
"please_wait": "请稍候...",
|
||||
"try_again": "请重试",
|
||||
"contact_support": "如果问题持续,请联系技术支持"
|
||||
},
|
||||
"pagination": {
|
||||
"page": "第{page}页",
|
||||
"of": "共{total}页",
|
||||
"items": "共{total}条",
|
||||
"per_page": "每页{count}条",
|
||||
"showing": "显示第{from}到第{to}条,共{total}条",
|
||||
"first": "首页",
|
||||
"last": "末页",
|
||||
"next": "下一页",
|
||||
"previous": "上一页"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "刚刚",
|
||||
"minutes_ago": "{count}分钟前",
|
||||
"hours_ago": "{count}小时前",
|
||||
"days_ago": "{count}天前",
|
||||
"weeks_ago": "{count}周前",
|
||||
"months_ago": "{count}个月前",
|
||||
"years_ago": "{count}年前",
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"tomorrow": "明天"
|
||||
}
|
||||
}
|
||||
132
api/app/locales/zh/enums.json
Normal file
132
api/app/locales/zh/enums.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"workspace_role": {
|
||||
"owner": "所有者",
|
||||
"manager": "管理员",
|
||||
"member": "成员",
|
||||
"guest": "访客"
|
||||
},
|
||||
"workspace_status": {
|
||||
"active": "活跃",
|
||||
"inactive": "未激活",
|
||||
"archived": "已归档",
|
||||
"suspended": "已暂停",
|
||||
"deleted": "已删除"
|
||||
},
|
||||
"invite_status": {
|
||||
"pending": "待处理",
|
||||
"accepted": "已接受",
|
||||
"rejected": "已拒绝",
|
||||
"revoked": "已撤销",
|
||||
"expired": "已过期"
|
||||
},
|
||||
"user_status": {
|
||||
"active": "活跃",
|
||||
"inactive": "未激活",
|
||||
"suspended": "已暂停",
|
||||
"deleted": "已删除",
|
||||
"pending": "待激活"
|
||||
},
|
||||
"tenant_status": {
|
||||
"active": "活跃",
|
||||
"inactive": "未激活",
|
||||
"suspended": "已暂停",
|
||||
"expired": "已过期",
|
||||
"trial": "试用中"
|
||||
},
|
||||
"file_status": {
|
||||
"uploading": "上传中",
|
||||
"processing": "处理中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"deleted": "已删除"
|
||||
},
|
||||
"task_status": {
|
||||
"pending": "待处理",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"cancelled": "已取消",
|
||||
"paused": "已暂停"
|
||||
},
|
||||
"priority": {
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"urgent": "紧急"
|
||||
},
|
||||
"visibility": {
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"internal": "内部",
|
||||
"shared": "共享"
|
||||
},
|
||||
"permission": {
|
||||
"read": "读取",
|
||||
"write": "写入",
|
||||
"delete": "删除",
|
||||
"admin": "管理",
|
||||
"owner": "所有者"
|
||||
},
|
||||
"notification_type": {
|
||||
"info": "信息",
|
||||
"warning": "警告",
|
||||
"error": "错误",
|
||||
"success": "成功"
|
||||
},
|
||||
"language": {
|
||||
"zh": "中文(简体)",
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"ko": "한국어",
|
||||
"fr": "Français",
|
||||
"de": "Deutsch",
|
||||
"es": "Español"
|
||||
},
|
||||
"timezone": {
|
||||
"utc": "UTC",
|
||||
"asia_shanghai": "亚洲/上海",
|
||||
"asia_tokyo": "亚洲/东京",
|
||||
"america_new_york": "美洲/纽约",
|
||||
"europe_london": "欧洲/伦敦"
|
||||
},
|
||||
"date_format": {
|
||||
"short": "短日期",
|
||||
"medium": "中等日期",
|
||||
"long": "长日期",
|
||||
"full": "完整日期"
|
||||
},
|
||||
"sort_order": {
|
||||
"asc": "升序",
|
||||
"desc": "降序"
|
||||
},
|
||||
"filter_operator": {
|
||||
"equals": "等于",
|
||||
"not_equals": "不等于",
|
||||
"contains": "包含",
|
||||
"not_contains": "不包含",
|
||||
"starts_with": "开始于",
|
||||
"ends_with": "结束于",
|
||||
"greater_than": "大于",
|
||||
"less_than": "小于",
|
||||
"greater_or_equal": "大于等于",
|
||||
"less_or_equal": "小于等于",
|
||||
"in": "在列表中",
|
||||
"not_in": "不在列表中",
|
||||
"is_null": "为空",
|
||||
"is_not_null": "不为空"
|
||||
},
|
||||
"log_level": {
|
||||
"debug": "调试",
|
||||
"info": "信息",
|
||||
"warning": "警告",
|
||||
"error": "错误",
|
||||
"critical": "严重"
|
||||
},
|
||||
"api_method": {
|
||||
"get": "GET",
|
||||
"post": "POST",
|
||||
"put": "PUT",
|
||||
"patch": "PATCH",
|
||||
"delete": "DELETE"
|
||||
}
|
||||
}
|
||||
138
api/app/locales/zh/errors.json
Normal file
138
api/app/locales/zh/errors.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"common": {
|
||||
"internal_error": "服务器内部错误",
|
||||
"network_error": "网络连接错误",
|
||||
"timeout": "请求超时",
|
||||
"service_unavailable": "服务暂时不可用",
|
||||
"bad_request": "请求参数错误",
|
||||
"unauthorized": "未授权访问",
|
||||
"forbidden": "没有权限访问",
|
||||
"not_found": "请求的资源不存在",
|
||||
"method_not_allowed": "不支持的请求方法",
|
||||
"conflict": "资源冲突",
|
||||
"too_many_requests": "请求过于频繁,请稍后再试",
|
||||
"validation_failed": "数据验证失败",
|
||||
"database_error": "数据库操作失败",
|
||||
"file_operation_error": "文件操作失败"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "用户名或密码错误",
|
||||
"token_expired": "登录已过期,请重新登录",
|
||||
"token_invalid": "无效的登录令牌",
|
||||
"token_missing": "缺少登录令牌",
|
||||
"unauthorized": "未授权访问",
|
||||
"forbidden": "没有权限执行此操作",
|
||||
"account_locked": "账户已被锁定",
|
||||
"account_disabled": "账户已被禁用",
|
||||
"account_not_verified": "账户未验证",
|
||||
"password_incorrect": "密码错误",
|
||||
"password_too_weak": "密码强度不够",
|
||||
"password_expired": "密码已过期,请修改密码",
|
||||
"email_not_verified": "邮箱未验证",
|
||||
"phone_not_verified": "手机号未验证",
|
||||
"verification_code_invalid": "验证码无效",
|
||||
"verification_code_expired": "验证码已过期",
|
||||
"login_failed": "登录失败",
|
||||
"logout_failed": "登出失败",
|
||||
"session_expired": "会话已过期",
|
||||
"already_logged_in": "已经登录",
|
||||
"not_logged_in": "未登录"
|
||||
},
|
||||
"user": {
|
||||
"not_found": "用户不存在",
|
||||
"already_exists": "用户已存在",
|
||||
"email_already_exists": "邮箱已被使用",
|
||||
"phone_already_exists": "手机号已被使用",
|
||||
"username_already_exists": "用户名已被使用",
|
||||
"invalid_email": "邮箱格式不正确",
|
||||
"invalid_phone": "手机号格式不正确",
|
||||
"invalid_username": "用户名格式不正确",
|
||||
"create_failed": "创建用户失败",
|
||||
"update_failed": "更新用户失败",
|
||||
"delete_failed": "删除用户失败",
|
||||
"cannot_delete_self": "不能删除自己",
|
||||
"cannot_update_self_role": "不能修改自己的角色",
|
||||
"profile_update_failed": "更新个人资料失败",
|
||||
"avatar_upload_failed": "上传头像失败",
|
||||
"password_change_failed": "修改密码失败",
|
||||
"old_password_incorrect": "原密码错误"
|
||||
},
|
||||
"workspace": {
|
||||
"not_found": "工作空间不存在",
|
||||
"already_exists": "工作空间已存在",
|
||||
"name_required": "工作空间名称不能为空",
|
||||
"name_too_long": "工作空间名称过长",
|
||||
"create_failed": "创建工作空间失败",
|
||||
"update_failed": "更新工作空间失败",
|
||||
"delete_failed": "删除工作空间失败",
|
||||
"permission_denied": "没有权限访问此工作空间",
|
||||
"not_member": "不是工作空间成员",
|
||||
"already_member": "已经是工作空间成员",
|
||||
"member_limit_reached": "成员数量已达上限",
|
||||
"cannot_leave_last_manager": "不能离开,您是最后一个管理员",
|
||||
"cannot_remove_last_manager": "不能移除最后一个管理员",
|
||||
"cannot_remove_self": "不能移除自己",
|
||||
"invite_not_found": "邀请不存在",
|
||||
"invite_expired": "邀请已过期",
|
||||
"invite_already_accepted": "邀请已被接受",
|
||||
"invite_already_revoked": "邀请已被撤销",
|
||||
"invite_send_failed": "发送邀请失败",
|
||||
"archived": "工作空间已归档",
|
||||
"suspended": "工作空间已暂停"
|
||||
},
|
||||
"tenant": {
|
||||
"not_found": "租户不存在",
|
||||
"already_exists": "租户已存在",
|
||||
"create_failed": "创建租户失败",
|
||||
"update_failed": "更新租户失败",
|
||||
"delete_failed": "删除租户失败",
|
||||
"suspended": "租户已暂停",
|
||||
"expired": "租户已过期",
|
||||
"license_invalid": "许可证无效",
|
||||
"license_expired": "许可证已过期",
|
||||
"quota_exceeded": "配额已超限"
|
||||
},
|
||||
"file": {
|
||||
"not_found": "文件不存在",
|
||||
"upload_failed": "文件上传失败",
|
||||
"download_failed": "文件下载失败",
|
||||
"delete_failed": "文件删除失败",
|
||||
"too_large": "文件大小超过限制",
|
||||
"invalid_type": "不支持的文件类型",
|
||||
"invalid_format": "文件格式不正确",
|
||||
"corrupted": "文件已损坏",
|
||||
"storage_full": "存储空间已满",
|
||||
"access_denied": "没有权限访问此文件"
|
||||
},
|
||||
"api": {
|
||||
"rate_limit_exceeded": "API调用频率超限",
|
||||
"quota_exceeded": "API调用配额已用完",
|
||||
"invalid_api_key": "无效的API密钥",
|
||||
"api_key_expired": "API密钥已过期",
|
||||
"api_key_revoked": "API密钥已被撤销",
|
||||
"endpoint_not_found": "API端点不存在",
|
||||
"method_not_allowed": "不支持的请求方法",
|
||||
"invalid_request": "无效的请求",
|
||||
"missing_parameter": "缺少必需参数:{param}",
|
||||
"invalid_parameter": "参数无效:{param}"
|
||||
},
|
||||
"database": {
|
||||
"connection_failed": "数据库连接失败",
|
||||
"query_failed": "数据库查询失败",
|
||||
"transaction_failed": "数据库事务失败",
|
||||
"constraint_violation": "数据约束冲突",
|
||||
"duplicate_key": "数据重复",
|
||||
"foreign_key_violation": "外键约束冲突",
|
||||
"deadlock": "数据库死锁"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_input": "输入数据无效",
|
||||
"missing_field": "缺少必需字段:{field}",
|
||||
"invalid_field": "字段无效:{field}",
|
||||
"field_too_long": "字段过长:{field}",
|
||||
"field_too_short": "字段过短:{field}",
|
||||
"invalid_format": "格式不正确:{field}",
|
||||
"invalid_value": "值无效:{field}",
|
||||
"out_of_range": "值超出范围:{field}"
|
||||
}
|
||||
}
|
||||
27
api/app/locales/zh/i18n.json
Normal file
27
api/app/locales/zh/i18n.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"language": {
|
||||
"not_found": "语言 {locale} 不存在",
|
||||
"already_exists": "语言 {locale} 已存在",
|
||||
"add_instructions": "语言 {locale} 验证成功。请在 {dir} 目录下创建翻译文件以完成添加。",
|
||||
"update_instructions": "语言 {locale} 更新验证成功。请更新环境变量 I18N_SUPPORTED_LANGUAGES 以应用配置更改。"
|
||||
},
|
||||
"namespace": {
|
||||
"not_found": "命名空间 {namespace} 在语言 {locale} 中不存在"
|
||||
},
|
||||
"translation": {
|
||||
"invalid_key_format": "翻译键格式无效: {key}。应使用格式: namespace.key.subkey",
|
||||
"update_instructions": "翻译 {locale}/{key} 更新验证成功。请修改对应的 JSON 翻译文件以应用更改。"
|
||||
},
|
||||
"reload": {
|
||||
"disabled": "翻译热重载功能已禁用。请在配置中启用 I18N_ENABLE_HOT_RELOAD。",
|
||||
"success": "翻译重载成功",
|
||||
"failed": "翻译重载失败: {error}"
|
||||
},
|
||||
"metrics": {
|
||||
"reset_success": "性能指标已重置"
|
||||
},
|
||||
"logs": {
|
||||
"export_success": "缺失翻译已导出到: {file}",
|
||||
"clear_success": "缺失翻译日志已清除"
|
||||
}
|
||||
}
|
||||
63
api/app/locales/zh/tenant.json
Normal file
63
api/app/locales/zh/tenant.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"info": {
|
||||
"get_success": "租户信息获取成功",
|
||||
"get_failed": "租户信息获取失败",
|
||||
"update_success": "租户信息更新成功",
|
||||
"update_failed": "租户信息更新失败"
|
||||
},
|
||||
"create": {
|
||||
"success": "租户创建成功",
|
||||
"failed": "租户创建失败"
|
||||
},
|
||||
"delete": {
|
||||
"success": "租户删除成功",
|
||||
"failed": "租户删除失败"
|
||||
},
|
||||
"status": {
|
||||
"activate_success": "租户启用成功",
|
||||
"activate_failed": "租户启用失败",
|
||||
"deactivate_success": "租户禁用成功",
|
||||
"deactivate_failed": "租户禁用失败"
|
||||
},
|
||||
"language": {
|
||||
"get_success": "租户语言配置获取成功",
|
||||
"get_failed": "租户语言配置获取失败",
|
||||
"update_success": "租户语言配置更新成功",
|
||||
"update_failed": "租户语言配置更新失败",
|
||||
"invalid_language": "不支持的语言代码",
|
||||
"default_not_in_supported": "默认语言必须在支持的语言列表中"
|
||||
},
|
||||
"list": {
|
||||
"get_success": "租户列表获取成功",
|
||||
"get_failed": "租户列表获取失败"
|
||||
},
|
||||
"users": {
|
||||
"list_success": "租户用户列表获取成功",
|
||||
"list_failed": "租户用户列表获取失败",
|
||||
"assign_success": "用户分配到租户成功",
|
||||
"assign_failed": "用户分配到租户失败",
|
||||
"remove_success": "用户从租户移除成功",
|
||||
"remove_failed": "用户从租户移除失败"
|
||||
},
|
||||
"statistics": {
|
||||
"get_success": "租户统计信息获取成功",
|
||||
"get_failed": "租户统计信息获取失败"
|
||||
},
|
||||
"validation": {
|
||||
"name_required": "租户名称不能为空",
|
||||
"name_invalid": "租户名称格式不正确",
|
||||
"name_too_long": "租户名称长度不能超过{max}个字符",
|
||||
"description_too_long": "租户描述长度不能超过{max}个字符",
|
||||
"language_code_invalid": "语言代码格式不正确",
|
||||
"supported_languages_empty": "支持的语言列表不能为空"
|
||||
},
|
||||
"errors": {
|
||||
"not_found": "租户不存在",
|
||||
"already_exists": "租户名称已存在",
|
||||
"permission_denied": "没有权限访问此租户",
|
||||
"has_users": "无法删除租户,存在关联的用户",
|
||||
"has_workspaces": "无法删除租户,存在关联的工作空间",
|
||||
"already_active": "租户已处于激活状态",
|
||||
"already_inactive": "租户已处于禁用状态"
|
||||
}
|
||||
}
|
||||
72
api/app/locales/zh/users.json
Normal file
72
api/app/locales/zh/users.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"info": {
|
||||
"get_success": "用户信息获取成功",
|
||||
"get_failed": "用户信息获取失败",
|
||||
"update_success": "用户信息更新成功",
|
||||
"update_failed": "用户信息更新失败"
|
||||
},
|
||||
"create": {
|
||||
"success": "用户创建成功",
|
||||
"failed": "用户创建失败",
|
||||
"superuser_success": "超级管理员创建成功",
|
||||
"superuser_failed": "超级管理员创建失败"
|
||||
},
|
||||
"delete": {
|
||||
"success": "用户删除成功",
|
||||
"failed": "用户删除失败",
|
||||
"deactivate_success": "用户停用成功",
|
||||
"deactivate_failed": "用户停用失败"
|
||||
},
|
||||
"activate": {
|
||||
"success": "用户激活成功",
|
||||
"failed": "用户激活失败"
|
||||
},
|
||||
"language": {
|
||||
"get_success": "语言偏好获取成功",
|
||||
"get_failed": "语言偏好获取失败",
|
||||
"update_success": "语言偏好更新成功",
|
||||
"update_failed": "语言偏好更新失败",
|
||||
"invalid_language": "不支持的语言代码",
|
||||
"current": "当前语言偏好"
|
||||
},
|
||||
"email": {
|
||||
"change_success": "邮箱修改成功",
|
||||
"change_failed": "邮箱修改失败",
|
||||
"code_sent": "验证码已发送到您的邮箱,请查收",
|
||||
"code_send_failed": "验证码发送失败",
|
||||
"code_invalid": "验证码无效或已过期",
|
||||
"already_exists": "该邮箱已被使用"
|
||||
},
|
||||
"list": {
|
||||
"get_success": "用户列表获取成功",
|
||||
"get_failed": "用户列表获取失败",
|
||||
"superusers_success": "租户超管列表获取成功",
|
||||
"superusers_failed": "租户超管列表获取失败"
|
||||
},
|
||||
"validation": {
|
||||
"username_required": "用户名不能为空",
|
||||
"username_invalid": "用户名格式不正确",
|
||||
"username_too_long": "用户名长度不能超过{max}个字符",
|
||||
"email_required": "邮箱不能为空",
|
||||
"email_invalid": "邮箱格式不正确",
|
||||
"password_required": "密码不能为空",
|
||||
"password_too_short": "密码长度不能少于{min}个字符",
|
||||
"password_too_long": "密码长度不能超过{max}个字符",
|
||||
"old_password_required": "旧密码不能为空",
|
||||
"new_password_required": "新密码不能为空",
|
||||
"verification_code_required": "验证码不能为空",
|
||||
"verification_code_invalid": "验证码格式不正确"
|
||||
},
|
||||
"errors": {
|
||||
"not_found": "用户不存在",
|
||||
"already_exists": "用户已存在",
|
||||
"permission_denied": "没有权限访问此用户",
|
||||
"cannot_delete_self": "不能删除自己",
|
||||
"cannot_deactivate_self": "不能停用自己",
|
||||
"already_deactivated": "用户已被停用",
|
||||
"already_activated": "用户已处于激活状态",
|
||||
"password_verification_failed": "密码验证失败",
|
||||
"old_password_incorrect": "旧密码不正确",
|
||||
"same_as_old_password": "新密码不能与旧密码相同"
|
||||
}
|
||||
}
|
||||
44
api/app/locales/zh/workspace.json
Normal file
44
api/app/locales/zh/workspace.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"list_retrieved": "工作空间列表获取成功",
|
||||
"created": "工作空间创建成功",
|
||||
"updated": "工作空间更新成功",
|
||||
"deleted": "工作空间删除成功",
|
||||
"switched": "工作空间切换成功",
|
||||
"not_found": "工作空间不存在或无权访问",
|
||||
"already_exists": "工作空间已存在",
|
||||
"permission_denied": "没有权限访问此工作空间",
|
||||
"name_required": "工作空间名称不能为空",
|
||||
"invalid_name": "工作空间名称格式不正确",
|
||||
"members": {
|
||||
"list_retrieved": "工作空间成员列表获取成功",
|
||||
"role_updated": "成员角色更新成功",
|
||||
"deleted": "成员删除成功",
|
||||
"not_found": "成员不存在",
|
||||
"cannot_remove_self": "不能删除自己",
|
||||
"cannot_remove_last_manager": "不能删除最后一个管理员",
|
||||
"already_member": "用户已经是工作空间成员"
|
||||
},
|
||||
"invites": {
|
||||
"created": "邀请创建成功",
|
||||
"list_retrieved": "邀请列表获取成功",
|
||||
"validated": "邀请验证成功",
|
||||
"revoked": "邀请撤销成功",
|
||||
"accepted": "邀请已接受",
|
||||
"not_found": "邀请不存在",
|
||||
"expired": "邀请已过期",
|
||||
"already_used": "邀请已被使用",
|
||||
"invalid_token": "无效的邀请令牌",
|
||||
"email_required": "邮箱地址不能为空",
|
||||
"invalid_email": "邮箱地址格式不正确"
|
||||
},
|
||||
"storage": {
|
||||
"type_retrieved": "存储类型获取成功",
|
||||
"type_updated": "存储类型更新成功",
|
||||
"invalid_type": "无效的存储类型"
|
||||
},
|
||||
"models": {
|
||||
"config_retrieved": "模型配置获取成功",
|
||||
"config_updated": "模型配置更新成功",
|
||||
"invalid_config": "无效的模型配置"
|
||||
}
|
||||
}
|
||||
196
api/app/main.py
196
api/app/main.py
@@ -92,6 +92,10 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add i18n language detection middleware
|
||||
from app.i18n.middleware import LanguageMiddleware
|
||||
app.add_middleware(LanguageMiddleware)
|
||||
|
||||
logger.info("FastAPI应用程序启动")
|
||||
|
||||
|
||||
@@ -129,6 +133,11 @@ from app.core.exceptions import (
|
||||
from app.core.sensitive_filter import SensitiveDataFilter
|
||||
import traceback
|
||||
|
||||
# Import i18n exception support
|
||||
from app.i18n.exceptions import I18nException
|
||||
from app.i18n.service import get_translation_service
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
|
||||
# 处理验证异常
|
||||
@app.exception_handler(ValidationException)
|
||||
@@ -156,6 +165,131 @@ async def validation_exception_handler(request: Request, exc: ValidationExceptio
|
||||
)
|
||||
|
||||
|
||||
# 处理 i18n 异常(国际化异常)
|
||||
@app.exception_handler(I18nException)
|
||||
async def i18n_exception_handler(request: Request, exc: I18nException):
|
||||
"""
|
||||
处理国际化异常
|
||||
|
||||
I18nException 已经自动翻译了错误消息,直接返回即可
|
||||
"""
|
||||
# 获取当前语言
|
||||
language = getattr(request.state, "language", settings.I18N_DEFAULT_LANGUAGE)
|
||||
|
||||
# 获取异常详情(已经包含翻译后的消息)
|
||||
detail = exc.detail
|
||||
|
||||
# 过滤敏感信息
|
||||
if isinstance(detail, dict):
|
||||
filtered_message = SensitiveDataFilter.filter_string(detail.get("message", ""))
|
||||
filtered_detail = {
|
||||
**detail,
|
||||
"message": filtered_message
|
||||
}
|
||||
else:
|
||||
filtered_detail = SensitiveDataFilter.filter_string(str(detail))
|
||||
|
||||
logger.warning(
|
||||
f"I18n exception: {exc.error_key}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"error_code": exc.error_code,
|
||||
"error_key": exc.error_key,
|
||||
"language": language,
|
||||
"status_code": exc.status_code,
|
||||
"params": exc.params
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"success": False,
|
||||
**filtered_detail
|
||||
},
|
||||
headers=exc.headers
|
||||
)
|
||||
|
||||
|
||||
# 处理 Pydantic 验证错误(国际化支持)
|
||||
@app.exception_handler(PydanticValidationError)
|
||||
async def pydantic_validation_exception_handler(request: Request, exc: PydanticValidationError):
|
||||
"""
|
||||
处理 Pydantic 验证错误,支持国际化
|
||||
"""
|
||||
# 获取当前语言
|
||||
language = getattr(request.state, "language", settings.I18N_DEFAULT_LANGUAGE)
|
||||
|
||||
# 获取翻译服务
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# 翻译验证错误消息
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = ".".join(str(loc) for loc in error["loc"])
|
||||
error_type = error["type"]
|
||||
|
||||
# 尝试翻译错误消息
|
||||
if error_type == "value_error.missing":
|
||||
message = translation_service.translate(
|
||||
"errors.validation.missing_field",
|
||||
language,
|
||||
field=field
|
||||
)
|
||||
elif error_type == "value_error.any_str.max_length":
|
||||
message = translation_service.translate(
|
||||
"errors.validation.field_too_long",
|
||||
language,
|
||||
field=field
|
||||
)
|
||||
elif error_type == "value_error.any_str.min_length":
|
||||
message = translation_service.translate(
|
||||
"errors.validation.field_too_short",
|
||||
language,
|
||||
field=field
|
||||
)
|
||||
else:
|
||||
# 使用通用验证错误消息
|
||||
message = translation_service.translate(
|
||||
"errors.validation.invalid_field",
|
||||
language,
|
||||
field=field
|
||||
)
|
||||
|
||||
errors.append({
|
||||
"field": field,
|
||||
"message": message,
|
||||
"type": error_type
|
||||
})
|
||||
|
||||
# 翻译主错误消息
|
||||
main_message = translation_service.translate(
|
||||
"errors.common.validation_failed",
|
||||
language
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Pydantic validation error: {len(errors)} errors",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"language": language,
|
||||
"errors": errors
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"success": False,
|
||||
"error_code": "VALIDATION_FAILED",
|
||||
"message": main_message,
|
||||
"errors": errors
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 处理资源不存在异常
|
||||
@app.exception_handler(ResourceNotFoundException)
|
||||
async def not_found_exception_handler(request: Request, exc: ResourceNotFoundException):
|
||||
@@ -354,31 +488,66 @@ async def business_exception_handler(request: Request, exc: BusinessException):
|
||||
)
|
||||
|
||||
|
||||
# 统一异常处理:将HTTPException转换为统一响应结构
|
||||
# 统一异常处理:将HTTPException转换为统一响应结构(支持国际化)
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""处理HTTP异常"""
|
||||
# 过滤敏感信息
|
||||
filtered_detail = SensitiveDataFilter.filter_string(str(exc.detail))
|
||||
|
||||
"""处理HTTP异常,支持国际化"""
|
||||
# 获取当前语言
|
||||
language = getattr(request.state, "language", settings.I18N_DEFAULT_LANGUAGE)
|
||||
|
||||
# 获取翻译服务
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# 尝试翻译标准HTTP错误
|
||||
error_key_map = {
|
||||
400: "errors.common.bad_request",
|
||||
401: "errors.common.unauthorized",
|
||||
403: "errors.common.forbidden",
|
||||
404: "errors.common.not_found",
|
||||
405: "errors.common.method_not_allowed",
|
||||
409: "errors.common.conflict",
|
||||
422: "errors.common.validation_failed",
|
||||
429: "errors.common.too_many_requests",
|
||||
500: "errors.common.internal_error",
|
||||
503: "errors.common.service_unavailable",
|
||||
}
|
||||
|
||||
# 如果有对应的翻译键,使用翻译
|
||||
if exc.status_code in error_key_map:
|
||||
translated_message = translation_service.translate(
|
||||
error_key_map[exc.status_code],
|
||||
language
|
||||
)
|
||||
else:
|
||||
# 否则过滤原始消息
|
||||
translated_message = SensitiveDataFilter.filter_string(str(exc.detail))
|
||||
|
||||
logger.warning(
|
||||
f"HTTP exception: {filtered_detail}",
|
||||
f"HTTP exception: {translated_message}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"status_code": exc.status_code
|
||||
"status_code": exc.status_code,
|
||||
"language": language
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=fail(code=exc.status_code, msg=filtered_detail, error=filtered_detail)
|
||||
content=fail(code=exc.status_code, msg=translated_message, error=translated_message)
|
||||
)
|
||||
|
||||
|
||||
# 捕获未处理的异常,返回统一错误结构
|
||||
# 捕获未处理的异常,返回统一错误结构(支持国际化)
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
"""处理未捕获的异常"""
|
||||
"""处理未捕获的异常,支持国际化"""
|
||||
# 获取当前语言
|
||||
language = getattr(request.state, "language", settings.I18N_DEFAULT_LANGUAGE)
|
||||
|
||||
# 获取翻译服务
|
||||
translation_service = get_translation_service()
|
||||
|
||||
# 记录完整的堆栈跟踪(日志过滤器会自动过滤敏感信息)
|
||||
logger.error(
|
||||
f"Unhandled exception: {exc}",
|
||||
@@ -386,6 +555,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"exception_type": type(exc).__name__,
|
||||
"language": language,
|
||||
"traceback": traceback.format_exc()
|
||||
},
|
||||
exc_info=True
|
||||
@@ -394,7 +564,11 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
# 生产环境隐藏详细错误信息
|
||||
environment = os.getenv("ENVIRONMENT", "development")
|
||||
if environment == "production":
|
||||
message = "服务器内部错误,请稍后重试"
|
||||
# 使用翻译的通用错误消息
|
||||
message = translation_service.translate(
|
||||
"errors.common.internal_error",
|
||||
language
|
||||
)
|
||||
else:
|
||||
# 开发环境也要过滤敏感信息
|
||||
message = SensitiveDataFilter.filter_string(str(exc))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, text
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db import Base
|
||||
|
||||
@@ -20,6 +20,10 @@ class Tenants(Base):
|
||||
external_id = Column(String(100), nullable=True, index=True) # 外部企业ID
|
||||
external_source = Column(String(50), nullable=True) # 来源系统
|
||||
|
||||
# 国际化语言配置字段
|
||||
default_language = Column(String(10), nullable=False, default='zh', server_default='zh', index=True) # 租户默认语言
|
||||
supported_languages = Column(ARRAY(String(10)), nullable=False, default=lambda: ['zh', 'en'], server_default=text("'{zh,en}'")) # 租户支持的语言列表
|
||||
|
||||
# Relationship to users - one tenant has many users
|
||||
users = relationship("User", back_populates="tenant")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db import Base
|
||||
@@ -22,6 +22,9 @@ class User(Base):
|
||||
external_id = Column(String(100), nullable=True) # 外部用户ID
|
||||
external_source = Column(String(50), nullable=True) # 来源系统
|
||||
|
||||
# 用户语言偏好
|
||||
preferred_language = Column(String(10), server_default=text("'zh'"), default='zh', nullable=False, index=True) # 用户偏好语言,默认中文
|
||||
|
||||
current_workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=True) # 当前工作空间ID,可为空
|
||||
|
||||
# Foreign key to tenant - each user belongs to exactly one tenant
|
||||
|
||||
73
api/app/schemas/i18n_schema.py
Normal file
73
api/app/schemas/i18n_schema.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
I18n Management API Schemas
|
||||
|
||||
This module defines Pydantic schemas for i18n management APIs.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Language Management Schemas
|
||||
# ============================================================================
|
||||
|
||||
class LanguageInfo(BaseModel):
|
||||
"""Language information"""
|
||||
code: str = Field(..., description="Language code (e.g., 'zh', 'en')")
|
||||
name: str = Field(..., description="Language name (e.g., 'Chinese', 'English')")
|
||||
native_name: str = Field(..., description="Native language name (e.g., '中文', 'English')")
|
||||
is_enabled: bool = Field(..., description="Whether the language is enabled")
|
||||
is_default: bool = Field(..., description="Whether this is the default language")
|
||||
|
||||
|
||||
class LanguageListResponse(BaseModel):
|
||||
"""Response for language list"""
|
||||
languages: List[LanguageInfo] = Field(..., description="List of available languages")
|
||||
|
||||
|
||||
class LanguageCreateRequest(BaseModel):
|
||||
"""Request to add a new language"""
|
||||
code: str = Field(..., description="Language code (e.g., 'ja', 'ko')", min_length=2, max_length=10)
|
||||
name: str = Field(..., description="Language name", min_length=1, max_length=100)
|
||||
native_name: str = Field(..., description="Native language name", min_length=1, max_length=100)
|
||||
is_enabled: bool = Field(default=True, description="Whether to enable the language")
|
||||
|
||||
|
||||
class LanguageUpdateRequest(BaseModel):
|
||||
"""Request to update language configuration"""
|
||||
is_enabled: Optional[bool] = Field(None, description="Whether the language is enabled")
|
||||
is_default: Optional[bool] = Field(None, description="Whether this is the default language")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Translation Management Schemas
|
||||
# ============================================================================
|
||||
|
||||
class TranslationResponse(BaseModel):
|
||||
"""Response for translation data"""
|
||||
translations: Dict[str, Dict[str, Any]] = Field(
|
||||
...,
|
||||
description="Translations organized by locale and namespace"
|
||||
)
|
||||
|
||||
|
||||
class TranslationUpdateRequest(BaseModel):
|
||||
"""Request to update a translation"""
|
||||
value: str = Field(..., description="New translation value", min_length=1)
|
||||
description: Optional[str] = Field(None, description="Optional description of the translation")
|
||||
|
||||
|
||||
class MissingTranslationsResponse(BaseModel):
|
||||
"""Response for missing translations"""
|
||||
missing_translations: Dict[str, List[str]] = Field(
|
||||
...,
|
||||
description="Missing translation keys organized by locale"
|
||||
)
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Response for translation reload"""
|
||||
success: bool = Field(..., description="Whether the reload was successful")
|
||||
reloaded_locales: List[str] = Field(..., description="List of reloaded locales")
|
||||
total_locales: int = Field(..., description="Total number of available locales")
|
||||
@@ -11,6 +11,8 @@ class TenantBase(BaseModel):
|
||||
name: str = Field(..., description="租户名称", max_length=255)
|
||||
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
||||
is_active: bool = Field(True, description="是否激活")
|
||||
default_language: Optional[str] = Field('zh', description="租户默认语言", max_length=10)
|
||||
supported_languages: Optional[List[str]] = Field(['zh', 'en'], description="租户支持的语言列表")
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
@@ -18,6 +20,26 @@ class TenantBase(BaseModel):
|
||||
if not v or not v.strip():
|
||||
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
||||
return v.strip()
|
||||
|
||||
@field_validator('default_language')
|
||||
@classmethod
|
||||
def validate_default_language(cls, v):
|
||||
if v:
|
||||
# Validate language code format (2-letter code, optionally with region)
|
||||
import re
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', v):
|
||||
raise ValidationException('语言代码格式不正确', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
@field_validator('supported_languages')
|
||||
@classmethod
|
||||
def validate_supported_languages(cls, v):
|
||||
if v:
|
||||
import re
|
||||
for lang in v:
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', lang):
|
||||
raise ValidationException(f'语言代码格式不正确: {lang}', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
@@ -30,6 +52,8 @@ class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="租户名称", max_length=255)
|
||||
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
||||
default_language: Optional[str] = Field(None, description="租户默认语言", max_length=10)
|
||||
supported_languages: Optional[List[str]] = Field(None, description="租户支持的语言列表")
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
@@ -37,6 +61,25 @@ class TenantUpdate(BaseModel):
|
||||
if v is not None and (not v or not v.strip()):
|
||||
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
||||
return v.strip() if v else v
|
||||
|
||||
@field_validator('default_language')
|
||||
@classmethod
|
||||
def validate_default_language(cls, v):
|
||||
if v:
|
||||
import re
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', v):
|
||||
raise ValidationException('语言代码格式不正确', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
@field_validator('supported_languages')
|
||||
@classmethod
|
||||
def validate_supported_languages(cls, v):
|
||||
if v:
|
||||
import re
|
||||
for lang in v:
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', lang):
|
||||
raise ValidationException(f'语言代码格式不正确: {lang}', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
|
||||
class Tenant(TenantBase):
|
||||
@@ -62,4 +105,29 @@ class TenantList(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
pages: int
|
||||
pages: int
|
||||
|
||||
|
||||
class TenantLanguageConfig(BaseModel):
|
||||
"""租户语言配置Schema"""
|
||||
default_language: str = Field(..., description="租户默认语言", max_length=10)
|
||||
supported_languages: List[str] = Field(..., description="租户支持的语言列表")
|
||||
|
||||
@field_validator('default_language')
|
||||
@classmethod
|
||||
def validate_default_language(cls, v):
|
||||
import re
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', v):
|
||||
raise ValidationException('语言代码格式不正确', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
@field_validator('supported_languages')
|
||||
@classmethod
|
||||
def validate_supported_languages(cls, v):
|
||||
if not v:
|
||||
raise ValidationException('支持的语言列表不能为空', code=BizCode.VALIDATION_FAILED)
|
||||
import re
|
||||
for lang in v:
|
||||
if not re.match(r'^[a-z]{2}(-[A-Z]{2})?$', lang):
|
||||
raise ValidationException(f'语言代码格式不正确: {lang}', code=BizCode.VALIDATION_FAILED)
|
||||
return v
|
||||
|
||||
@@ -58,6 +58,16 @@ class VerifyPasswordRequest(BaseModel):
|
||||
password: str = Field(..., description="密码")
|
||||
|
||||
|
||||
class LanguagePreferenceRequest(BaseModel):
|
||||
"""语言偏好设置请求"""
|
||||
language: str = Field(..., min_length=2, max_length=10, description="语言代码,如 'zh', 'en'")
|
||||
|
||||
|
||||
class LanguagePreferenceResponse(BaseModel):
|
||||
"""语言偏好响应"""
|
||||
language: str = Field(..., description="当前语言偏好")
|
||||
|
||||
|
||||
class ChangePasswordResponse(BaseModel):
|
||||
"""修改密码响应"""
|
||||
message: str
|
||||
@@ -74,6 +84,7 @@ class User(UserBase):
|
||||
current_workspace_id: Optional[uuid.UUID] = None
|
||||
current_workspace_name: Optional[str] = None
|
||||
role: Optional[WorkspaceRole] = None
|
||||
preferred_language: Optional[str] = "zh" # 用户语言偏好
|
||||
|
||||
# 将 datetime 转换为毫秒时间戳
|
||||
@validator("created_at", pre=True)
|
||||
|
||||
@@ -80,6 +80,7 @@ def authenticate_user_or_raise(db: Session, email: str, password: str) -> User:
|
||||
from app.core.exceptions import BusinessException
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.logging_config import get_auth_logger
|
||||
from app.i18n.service import t
|
||||
|
||||
logger = get_auth_logger()
|
||||
|
||||
@@ -87,17 +88,17 @@ def authenticate_user_or_raise(db: Session, email: str, password: str) -> User:
|
||||
user = user_repository.get_user_by_email(db, email=email)
|
||||
if not user:
|
||||
logger.warning(f"用户不存在: {email}")
|
||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
raise BusinessException(t("auth.user.not_found"), code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 检查用户状态
|
||||
if not user.is_active:
|
||||
logger.warning(f"用户未激活: {email}")
|
||||
raise BusinessException("用户未激活", code=BizCode.USER_NOT_FOUND)
|
||||
raise BusinessException(t("auth.login.account_disabled"), code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 验证密码
|
||||
if not verify_password(password, user.hashed_password):
|
||||
logger.warning(f"密码错误: {email}")
|
||||
raise BusinessException("密码错误", code=BizCode.PASSWORD_ERROR)
|
||||
raise BusinessException(t("auth.password.incorrect"), code=BizCode.PASSWORD_ERROR)
|
||||
|
||||
logger.info(f"用户认证成功: {email}")
|
||||
return user
|
||||
@@ -254,6 +255,8 @@ def decode_access_token(token: str) -> dict:
|
||||
Raises:
|
||||
BusinessException: token 无效
|
||||
"""
|
||||
from app.i18n.service import t
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, TOKEN_SECRET_KEY, algorithms=[TOKEN_ALGORITHM])
|
||||
return {
|
||||
@@ -261,4 +264,4 @@ def decode_access_token(token: str) -> dict:
|
||||
"share_token": payload["share_token"]
|
||||
}
|
||||
except jwt.InvalidTokenError:
|
||||
raise BusinessException("无效的访问 token", BizCode.INVALID_TOKEN)
|
||||
raise BusinessException(t("auth.token.invalid"), BizCode.INVALID_TOKEN)
|
||||
@@ -217,4 +217,55 @@ class TenantService:
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active
|
||||
)
|
||||
)
|
||||
|
||||
def get_tenant_language_config(self, tenant_id: uuid.UUID) -> Optional[dict]:
|
||||
"""获取租户语言配置"""
|
||||
tenant = self.tenant_repo.get_tenant_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise BusinessException("租户不存在", code=BizCode.TENANT_NOT_FOUND)
|
||||
|
||||
return {
|
||||
"default_language": tenant.default_language,
|
||||
"supported_languages": tenant.supported_languages
|
||||
}
|
||||
|
||||
def update_tenant_language_config(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
default_language: str,
|
||||
supported_languages: list
|
||||
) -> Optional[dict]:
|
||||
"""更新租户语言配置"""
|
||||
# 检查租户是否存在
|
||||
tenant = self.tenant_repo.get_tenant_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise BusinessException("租户不存在", code=BizCode.TENANT_NOT_FOUND)
|
||||
|
||||
# 验证默认语言在支持的语言列表中
|
||||
if default_language not in supported_languages:
|
||||
raise BusinessException(
|
||||
"默认语言必须在支持的语言列表中",
|
||||
code=BizCode.VALIDATION_FAILED
|
||||
)
|
||||
|
||||
try:
|
||||
# 更新语言配置
|
||||
tenant.default_language = default_language
|
||||
tenant.supported_languages = supported_languages
|
||||
self.db.commit()
|
||||
self.db.refresh(tenant)
|
||||
|
||||
business_logger.info(
|
||||
f"更新租户语言配置成功: {tenant.name} (ID: {tenant.id}), "
|
||||
f"默认语言: {default_language}, 支持语言: {supported_languages}"
|
||||
)
|
||||
|
||||
return {
|
||||
"default_language": tenant.default_language,
|
||||
"supported_languages": tenant.supported_languages
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
business_logger.error(f"更新租户语言配置失败: {str(e)}")
|
||||
raise BusinessException(f"更新租户语言配置失败: {str(e)}", code=BizCode.DB_ERROR)
|
||||
|
||||
@@ -438,24 +438,26 @@ def update_last_login_time(db: Session, user_id: uuid.UUID) -> User:
|
||||
|
||||
async def change_password(db: Session, user_id: uuid.UUID, old_password: str, new_password: str, current_user: User) -> User:
|
||||
"""普通用户修改自己的密码"""
|
||||
from app.i18n.service import t
|
||||
|
||||
business_logger.info(f"用户修改密码请求: user_id={user_id}, current_user={current_user.id}")
|
||||
|
||||
# 检查权限:只能修改自己的密码
|
||||
if current_user.id != user_id:
|
||||
business_logger.warning(f"用户尝试修改他人密码: current_user={current_user.id}, target_user={user_id}")
|
||||
raise PermissionDeniedException("You can only change your own password")
|
||||
raise PermissionDeniedException(t("auth.password.change_failed"))
|
||||
|
||||
try:
|
||||
# 获取用户
|
||||
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
|
||||
if not db_user:
|
||||
business_logger.warning(f"用户不存在: {user_id}")
|
||||
raise BusinessException("User not found", code=BizCode.USER_NOT_FOUND)
|
||||
raise BusinessException(t("auth.user.not_found"), code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 验证旧密码
|
||||
if not verify_password(old_password, db_user.hashed_password):
|
||||
business_logger.warning(f"用户旧密码验证失败: {user_id}")
|
||||
raise BusinessException("当前密码不正确", code=BizCode.VALIDATION_FAILED)
|
||||
raise BusinessException(t("auth.password.incorrect"), code=BizCode.VALIDATION_FAILED)
|
||||
|
||||
# 更新密码
|
||||
db_user.hashed_password = get_password_hash(new_password)
|
||||
@@ -471,7 +473,7 @@ async def change_password(db: Session, user_id: uuid.UUID, old_password: str, ne
|
||||
except Exception as e:
|
||||
business_logger.error(f"修改用户密码失败: user_id={user_id} - {str(e)}")
|
||||
db.rollback()
|
||||
raise BusinessException(f"修改用户密码失败: user_id={user_id} - {str(e)}", code=BizCode.DB_ERROR)
|
||||
raise BusinessException(t("auth.password.change_failed"), code=BizCode.DB_ERROR)
|
||||
|
||||
|
||||
async def admin_change_password(db: Session, target_user_id: uuid.UUID, new_password: str = None, current_user: User = None) -> tuple[User, str]:
|
||||
@@ -487,6 +489,8 @@ async def admin_change_password(db: Session, target_user_id: uuid.UUID, new_pass
|
||||
Returns:
|
||||
tuple[User, str]: (更新后的用户对象, 实际使用的密码)
|
||||
"""
|
||||
from app.i18n.service import t
|
||||
|
||||
business_logger.info(f"管理员修改用户密码请求: admin={current_user.id}, target_user={target_user_id}")
|
||||
|
||||
# 检查权限:只有超级管理员可以修改他人密码
|
||||
@@ -496,7 +500,7 @@ async def admin_change_password(db: Session, target_user_id: uuid.UUID, new_pass
|
||||
try:
|
||||
permission_service.check_superuser(
|
||||
subject,
|
||||
error_message="只有超级管理员可以修改他人密码"
|
||||
error_message=t("auth.password.change_failed")
|
||||
)
|
||||
except PermissionDeniedException as e:
|
||||
business_logger.warning(f"非超管用户尝试修改他人密码: current_user={current_user.id}")
|
||||
@@ -507,12 +511,12 @@ async def admin_change_password(db: Session, target_user_id: uuid.UUID, new_pass
|
||||
target_user = user_repository.get_user_by_id(db=db, user_id=target_user_id)
|
||||
if not target_user:
|
||||
business_logger.warning(f"目标用户不存在: {target_user_id}")
|
||||
raise BusinessException("目标用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
raise BusinessException(t("auth.user.not_found"), code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 检查租户权限:超管只能修改同租户用户的密码
|
||||
if current_user.tenant_id != target_user.tenant_id:
|
||||
business_logger.warning(f"跨租户密码修改尝试: admin_tenant={current_user.tenant_id}, target_tenant={target_user.tenant_id}")
|
||||
raise BusinessException("不可跨租户修改用户密码", code=BizCode.FORBIDDEN)
|
||||
raise BusinessException(t("auth.password.change_failed"), code=BizCode.FORBIDDEN)
|
||||
|
||||
# 如果没有提供新密码,则生成随机密码
|
||||
actual_password = new_password if new_password else generate_random_password()
|
||||
@@ -532,7 +536,7 @@ async def admin_change_password(db: Session, target_user_id: uuid.UUID, new_pass
|
||||
except Exception as e:
|
||||
business_logger.error(f"管理员修改用户密码失败: admin={current_user.id}, target_user={target_user_id} - {str(e)}")
|
||||
db.rollback()
|
||||
raise BusinessException(f"管理员修改用户密码失败: admin={current_user.id}, target_user={target_user_id} - {str(e)}", code=BizCode.DB_ERROR)
|
||||
raise BusinessException(t("auth.password.change_failed"), code=BizCode.DB_ERROR)
|
||||
|
||||
|
||||
def generate_random_password(length: int = 12) -> str:
|
||||
@@ -740,3 +744,54 @@ async def verify_and_change_email(db: Session, user_id: uuid.UUID, new_email: Em
|
||||
#
|
||||
# business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}")
|
||||
# return db_user
|
||||
|
||||
|
||||
def get_user_language_preference(db: Session, user_id: uuid.UUID, current_user: User) -> str:
|
||||
"""获取用户语言偏好"""
|
||||
business_logger.info(f"获取用户语言偏好: user_id={user_id}")
|
||||
|
||||
# 权限检查:只能获取自己的语言偏好
|
||||
if current_user.id != user_id:
|
||||
raise PermissionDeniedException("只能获取自己的语言偏好")
|
||||
|
||||
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
|
||||
if not db_user:
|
||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
language = db_user.preferred_language or "zh"
|
||||
business_logger.info(f"用户语言偏好: {db_user.username}, language={language}")
|
||||
return language
|
||||
|
||||
|
||||
def update_user_language_preference(
|
||||
db: Session,
|
||||
user_id: uuid.UUID,
|
||||
language: str,
|
||||
current_user: User
|
||||
) -> User:
|
||||
"""更新用户语言偏好"""
|
||||
business_logger.info(f"更新用户语言偏好: user_id={user_id}, language={language}")
|
||||
|
||||
# 权限检查:只能修改自己的语言偏好
|
||||
if current_user.id != user_id:
|
||||
raise PermissionDeniedException("只能修改自己的语言偏好")
|
||||
|
||||
# 验证语言代码是否支持
|
||||
from app.core.config import settings
|
||||
if language not in settings.I18N_SUPPORTED_LANGUAGES:
|
||||
raise BusinessException(
|
||||
f"不支持的语言代码: {language}。支持的语言: {', '.join(settings.I18N_SUPPORTED_LANGUAGES)}",
|
||||
code=BizCode.VALIDATION_FAILED
|
||||
)
|
||||
|
||||
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
|
||||
if not db_user:
|
||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
# 更新语言偏好
|
||||
db_user.preferred_language = language
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
|
||||
business_logger.info(f"用户语言偏好更新成功: {db_user.username}, language={language}")
|
||||
return db_user
|
||||
|
||||
Reference in New Issue
Block a user