Merge branch 'feature/i18n' into develop
* feature/i18n: [add] i18n support zh,en
This commit is contained in:
@@ -16,6 +16,7 @@ from . import (
|
|||||||
file_controller,
|
file_controller,
|
||||||
file_storage_controller,
|
file_storage_controller,
|
||||||
home_page_controller,
|
home_page_controller,
|
||||||
|
i18n_controller,
|
||||||
implicit_memory_controller,
|
implicit_memory_controller,
|
||||||
knowledge_controller,
|
knowledge_controller,
|
||||||
knowledgeshare_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(file_storage_controller.router)
|
||||||
manager_router.include_router(ontology_controller.router)
|
manager_router.include_router(ontology_controller.router)
|
||||||
manager_router.include_router(skill_controller.router)
|
manager_router.include_router(skill_controller.router)
|
||||||
|
manager_router.include_router(i18n_controller.router)
|
||||||
|
|
||||||
__all__ = ["manager_router"]
|
__all__ = ["manager_router"]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from app.core.exceptions import BusinessException
|
|||||||
from app.core.error_codes import BizCode
|
from app.core.error_codes import BizCode
|
||||||
from app.dependencies import get_current_user, oauth2_scheme
|
from app.dependencies import get_current_user, oauth2_scheme
|
||||||
from app.models.user_model import User
|
from app.models.user_model import User
|
||||||
|
from app.i18n.dependencies import get_translator
|
||||||
|
|
||||||
# 获取专用日志器
|
# 获取专用日志器
|
||||||
auth_logger = get_auth_logger()
|
auth_logger = get_auth_logger()
|
||||||
@@ -26,7 +28,8 @@ router = APIRouter(tags=["Authentication"])
|
|||||||
@router.post("/token", response_model=ApiResponse)
|
@router.post("/token", response_model=ApiResponse)
|
||||||
async def login_for_access_token(
|
async def login_for_access_token(
|
||||||
form_data: TokenRequest,
|
form_data: TokenRequest,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""用户登录获取token"""
|
"""用户登录获取token"""
|
||||||
auth_logger.info(f"用户登录请求: {form_data.email}")
|
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)
|
invite_info = workspace_service.validate_invite_token(db, form_data.invite)
|
||||||
|
|
||||||
if not invite_info.is_valid:
|
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:
|
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}")
|
auth_logger.info(f"邀请码验证成功: workspace={invite_info.workspace_name}")
|
||||||
try:
|
try:
|
||||||
# 尝试认证用户
|
# 尝试认证用户
|
||||||
@@ -69,7 +72,7 @@ async def login_for_access_token(
|
|||||||
elif e.code == BizCode.PASSWORD_ERROR:
|
elif e.code == BizCode.PASSWORD_ERROR:
|
||||||
# 用户存在但密码错误
|
# 用户存在但密码错误
|
||||||
auth_logger.warning(f"接受邀请失败,密码验证错误: {form_data.email}")
|
auth_logger.warning(f"接受邀请失败,密码验证错误: {form_data.email}")
|
||||||
raise BusinessException("接受邀请失败,密码验证错误", BizCode.LOGIN_FAILED)
|
raise BusinessException(t("auth.invite.password_verification_failed"), BizCode.LOGIN_FAILED)
|
||||||
else:
|
else:
|
||||||
# 其他认证失败情况,直接抛出
|
# 其他认证失败情况,直接抛出
|
||||||
raise
|
raise
|
||||||
@@ -82,7 +85,7 @@ async def login_for_access_token(
|
|||||||
except BusinessException as e:
|
except BusinessException as e:
|
||||||
|
|
||||||
# 其他认证失败情况,直接抛出
|
# 其他认证失败情况,直接抛出
|
||||||
raise BusinessException(e.message,BizCode.LOGIN_FAILED)
|
raise BusinessException(e.message, BizCode.LOGIN_FAILED)
|
||||||
|
|
||||||
# 创建 tokens
|
# 创建 tokens
|
||||||
access_token, access_token_id = security.create_access_token(subject=user.id)
|
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,
|
expires_at=access_expires_at,
|
||||||
refresh_expires_at=refresh_expires_at
|
refresh_expires_at=refresh_expires_at
|
||||||
),
|
),
|
||||||
msg="登录成功"
|
msg=t("auth.login.success")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=ApiResponse)
|
@router.post("/refresh", response_model=ApiResponse)
|
||||||
async def refresh_token(
|
async def refresh_token(
|
||||||
refresh_request: RefreshTokenRequest,
|
refresh_request: RefreshTokenRequest,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""刷新token"""
|
"""刷新token"""
|
||||||
auth_logger.info("收到token刷新请求")
|
auth_logger.info("收到token刷新请求")
|
||||||
@@ -125,18 +129,18 @@ async def refresh_token(
|
|||||||
# 验证 refresh token
|
# 验证 refresh token
|
||||||
userId = security.verify_token(refresh_request.refresh_token, "refresh")
|
userId = security.verify_token(refresh_request.refresh_token, "refresh")
|
||||||
if not userId:
|
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)
|
user = auth_service.get_user_by_id(db, userId)
|
||||||
if not user:
|
if not user:
|
||||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
raise BusinessException(t("auth.user.not_found"), code=BizCode.USER_NOT_FOUND)
|
||||||
|
|
||||||
# 检查 refresh token 黑名单
|
# 检查 refresh token 黑名单
|
||||||
if settings.ENABLE_SINGLE_SESSION:
|
if settings.ENABLE_SINGLE_SESSION:
|
||||||
refresh_token_id = security.get_token_id(refresh_request.refresh_token)
|
refresh_token_id = security.get_token_id(refresh_request.refresh_token)
|
||||||
if refresh_token_id and await SessionService.is_token_blacklisted(refresh_token_id):
|
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
|
# 生成新 tokens
|
||||||
new_access_token, new_access_token_id = security.create_access_token(subject=user.id)
|
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,
|
expires_at=access_expires_at,
|
||||||
refresh_expires_at=refresh_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(
|
async def logout(
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""登出当前用户:加入token黑名单并清理会话"""
|
"""登出当前用户:加入token黑名单并清理会话"""
|
||||||
auth_logger.info(f"用户 {current_user.username} 请求登出")
|
auth_logger.info(f"用户 {current_user.username} 请求登出")
|
||||||
|
|
||||||
token_id = security.get_token_id(token)
|
token_id = security.get_token_id(token)
|
||||||
if not token_id:
|
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)
|
await SessionService.blacklist_token(token_id)
|
||||||
@@ -192,5 +197,5 @@ async def logout(
|
|||||||
await SessionService.clear_user_session(current_user.username)
|
await SessionService.clear_user_session(current_user.username)
|
||||||
|
|
||||||
auth_logger.info(f"用户 {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 fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from app.core.error_codes import BizCode
|
from app.core.error_codes import BizCode
|
||||||
from app.core.exceptions import BusinessException
|
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.logging_config import get_api_logger
|
||||||
from app.core.response_utils import success
|
from app.core.response_utils import success
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
|
from app.i18n.dependencies import get_translator
|
||||||
|
|
||||||
# 获取API专用日志器
|
# 获取API专用日志器
|
||||||
api_logger = get_api_logger()
|
api_logger = get_api_logger()
|
||||||
@@ -33,7 +35,8 @@ router = APIRouter(
|
|||||||
def create_superuser(
|
def create_superuser(
|
||||||
user: user_schema.UserCreate,
|
user: user_schema.UserCreate,
|
||||||
db: Session = Depends(get_db),
|
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}")
|
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})")
|
api_logger.info(f"超级管理员创建成功: {result.username} (ID: {result.id})")
|
||||||
|
|
||||||
result_schema = user_schema.User.model_validate(result)
|
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)
|
@router.delete("/{user_id}", response_model=ApiResponse)
|
||||||
@@ -50,6 +53,7 @@ def delete_user(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""停用用户(软删除)"""
|
"""停用用户(软删除)"""
|
||||||
api_logger.info(f"用户停用请求: user_id={user_id}, 操作者: {current_user.username}")
|
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
|
db=db, user_id_to_deactivate=user_id, current_user=current_user
|
||||||
)
|
)
|
||||||
api_logger.info(f"用户停用成功: {result.username} (ID: {result.id})")
|
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)
|
@router.post("/{user_id}/activate", response_model=ApiResponse)
|
||||||
def activate_user(
|
def activate_user(
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""激活用户"""
|
"""激活用户"""
|
||||||
api_logger.info(f"用户激活请求: user_id={user_id}, 操作者: {current_user.username}")
|
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})")
|
api_logger.info(f"用户激活成功: {result.username} (ID: {result.id})")
|
||||||
|
|
||||||
result_schema = user_schema.User.model_validate(result)
|
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)
|
@router.get("", response_model=ApiResponse)
|
||||||
def get_current_user_info(
|
def get_current_user_info(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""获取当前用户信息"""
|
"""获取当前用户信息"""
|
||||||
api_logger.info(f"当前用户信息请求: {current_user.username}")
|
api_logger.info(f"当前用户信息请求: {current_user.username}")
|
||||||
@@ -105,7 +111,7 @@ def get_current_user_info(
|
|||||||
break
|
break
|
||||||
|
|
||||||
api_logger.info(f"当前用户信息获取成功: {result.username}, 角色: {result_schema.role}, 工作空间: {result_schema.current_workspace_name}")
|
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)
|
@router.get("/superusers", response_model=ApiResponse)
|
||||||
@@ -113,6 +119,7 @@ def get_tenant_superusers(
|
|||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_superuser),
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""获取当前租户下的超管账号列表(仅超级管理员可访问)"""
|
"""获取当前租户下的超管账号列表(仅超级管理员可访问)"""
|
||||||
api_logger.info(f"获取租户超管列表请求: {current_user.username}")
|
api_logger.info(f"获取租户超管列表请求: {current_user.username}")
|
||||||
@@ -125,7 +132,7 @@ def get_tenant_superusers(
|
|||||||
api_logger.info(f"租户超管列表获取成功: count={len(superusers)}")
|
api_logger.info(f"租户超管列表获取成功: count={len(superusers)}")
|
||||||
|
|
||||||
superusers_schema = [user_schema.User.model_validate(u) for u in 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,
|
user_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""根据用户ID获取用户信息"""
|
"""根据用户ID获取用户信息"""
|
||||||
api_logger.info(f"获取用户信息请求: user_id={user_id}, 操作者: {current_user.username}")
|
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}")
|
api_logger.info(f"用户信息获取成功: {result.username}")
|
||||||
|
|
||||||
result_schema = user_schema.User.model_validate(result)
|
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)
|
@router.put("/change-password", response_model=ApiResponse)
|
||||||
@@ -152,6 +160,7 @@ async def change_password(
|
|||||||
request: ChangePasswordRequest,
|
request: ChangePasswordRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""修改当前用户密码"""
|
"""修改当前用户密码"""
|
||||||
api_logger.info(f"用户密码修改请求: {current_user.username}")
|
api_logger.info(f"用户密码修改请求: {current_user.username}")
|
||||||
@@ -164,7 +173,7 @@ async def change_password(
|
|||||||
current_user=current_user
|
current_user=current_user
|
||||||
)
|
)
|
||||||
api_logger.info(f"用户密码修改成功: {current_user.username}")
|
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)
|
@router.put("/admin/change-password", response_model=ApiResponse)
|
||||||
@@ -172,6 +181,7 @@ async def admin_change_password(
|
|||||||
request: AdminChangePasswordRequest,
|
request: AdminChangePasswordRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_superuser),
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""超级管理员修改指定用户的密码"""
|
"""超级管理员修改指定用户的密码"""
|
||||||
api_logger.info(f"管理员密码修改请求: 管理员 {current_user.username} 修改用户 {request.user_id}")
|
api_logger.info(f"管理员密码修改请求: 管理员 {current_user.username} 修改用户 {request.user_id}")
|
||||||
@@ -186,16 +196,17 @@ async def admin_change_password(
|
|||||||
# 根据是否生成了随机密码来构造响应
|
# 根据是否生成了随机密码来构造响应
|
||||||
if request.new_password:
|
if request.new_password:
|
||||||
api_logger.info(f"管理员密码修改成功: 用户 {request.user_id}")
|
api_logger.info(f"管理员密码修改成功: 用户 {request.user_id}")
|
||||||
return success(msg="密码修改成功")
|
return success(msg=t("auth.password.change_success"))
|
||||||
else:
|
else:
|
||||||
api_logger.info(f"管理员密码重置成功: 用户 {request.user_id}, 随机密码已生成")
|
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)
|
@router.post("/verify_pwd", response_model=ApiResponse)
|
||||||
def verify_pwd(
|
def verify_pwd(
|
||||||
request: VerifyPasswordRequest,
|
request: VerifyPasswordRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""验证当前用户密码"""
|
"""验证当前用户密码"""
|
||||||
api_logger.info(f"用户验证密码请求: {current_user.username}")
|
api_logger.info(f"用户验证密码请求: {current_user.username}")
|
||||||
@@ -203,8 +214,8 @@ def verify_pwd(
|
|||||||
is_valid = verify_password(request.password, current_user.hashed_password)
|
is_valid = verify_password(request.password, current_user.hashed_password)
|
||||||
api_logger.info(f"用户密码验证结果: {current_user.username}, valid={is_valid}")
|
api_logger.info(f"用户密码验证结果: {current_user.username}, valid={is_valid}")
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise BusinessException("密码验证失败", code=BizCode.VALIDATION_FAILED)
|
raise BusinessException(t("users.errors.password_verification_failed"), code=BizCode.VALIDATION_FAILED)
|
||||||
return success(data={"valid": is_valid}, msg="验证完成")
|
return success(data={"valid": is_valid}, msg=t("common.success.retrieved"))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-email-code", response_model=ApiResponse)
|
@router.post("/send-email-code", response_model=ApiResponse)
|
||||||
@@ -212,6 +223,7 @@ async def send_email_code(
|
|||||||
request: SendEmailCodeRequest,
|
request: SendEmailCodeRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""发送邮箱验证码"""
|
"""发送邮箱验证码"""
|
||||||
api_logger.info(f"用户请求发送邮箱验证码: {current_user.username}, email={request.email}")
|
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)
|
await user_service.send_email_code_method(db=db, email=request.email, user_id=current_user.id)
|
||||||
|
|
||||||
api_logger.info(f"邮箱验证码已发送: {current_user.username}")
|
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)
|
@router.put("/change-email", response_model=ApiResponse)
|
||||||
@@ -227,6 +239,7 @@ async def change_email(
|
|||||||
request: VerifyEmailCodeRequest,
|
request: VerifyEmailCodeRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: Callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""验证验证码并修改邮箱"""
|
"""验证验证码并修改邮箱"""
|
||||||
api_logger.info(f"用户修改邮箱: {current_user.username}, new_email={request.new_email}")
|
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}")
|
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,
|
get_current_user,
|
||||||
workspace_access_guard,
|
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.tenant_model import Tenants
|
||||||
from app.models.user_model import User
|
from app.models.user_model import User
|
||||||
from app.models.workspace_model import InviteStatus
|
from app.models.workspace_model import InviteStatus
|
||||||
@@ -65,7 +71,9 @@ def get_workspaces(
|
|||||||
include_current: bool = Query(True, description="是否包含当前工作空间"),
|
include_current: bool = Query(True, description="是否包含当前工作空间"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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)} 个工作空间")
|
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)
|
@router.post("", response_model=ApiResponse)
|
||||||
@@ -98,6 +111,8 @@ def create_workspace(
|
|||||||
language_type: str = Header(default="zh", alias="X-Language-Type"),
|
language_type: str = Header(default="zh", alias="X-Language-Type"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_superuser),
|
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
|
from app.core.language_utils import get_language_from_header
|
||||||
@@ -118,8 +133,13 @@ def create_workspace(
|
|||||||
f"工作空间创建成功 - 名称: {workspace.name}, ID: {result.id}, "
|
f"工作空间创建成功 - 名称: {workspace.name}, ID: {result.id}, "
|
||||||
f"创建者: {current_user.username}, language={language}"
|
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)
|
@router.put("", response_model=ApiResponse)
|
||||||
@cur_workspace_access_guard()
|
@cur_workspace_access_guard()
|
||||||
@@ -127,6 +147,8 @@ def update_workspace(
|
|||||||
workspace: WorkspaceUpdate,
|
workspace: WorkspaceUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -139,14 +161,21 @@ def update_workspace(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间更新成功 - ID: {workspace_id}, 用户: {current_user.username}")
|
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)
|
@router.get("/members", response_model=ApiResponse)
|
||||||
@cur_workspace_access_guard()
|
@cur_workspace_access_guard()
|
||||||
def get_cur_workspace_members(
|
def get_cur_workspace_members(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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} 的成员列表")
|
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {current_user.current_workspace_id} 的成员列表")
|
||||||
@@ -157,8 +186,14 @@ def get_cur_workspace_members(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间成员列表获取成功 - ID: {current_user.current_workspace_id}, 数量: {len(members)}")
|
api_logger.info(f"工作空间成员列表获取成功 - ID: {current_user.current_workspace_id}, 数量: {len(members)}")
|
||||||
|
|
||||||
|
# 转换为表格项并使用序列化器添加国际化字段
|
||||||
table_items = _convert_members_to_table_items(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)
|
@router.put("/members", response_model=ApiResponse)
|
||||||
@@ -168,6 +203,7 @@ def update_workspace_members(
|
|||||||
updates: List[WorkspaceMemberUpdate],
|
updates: List[WorkspaceMemberUpdate],
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
workspace_id = current_user.current_workspace_id
|
workspace_id = current_user.current_workspace_id
|
||||||
api_logger.info(f"用户 {current_user.username} 请求更新工作空间 {workspace_id} 的成员角色")
|
api_logger.info(f"用户 {current_user.username} 请求更新工作空间 {workspace_id} 的成员角色")
|
||||||
@@ -178,7 +214,7 @@ def update_workspace_members(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间成员角色更新成功 - ID: {workspace_id}, 数量: {len(members)}")
|
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)
|
@router.delete("/members/{member_id}", response_model=ApiResponse)
|
||||||
@@ -187,6 +223,7 @@ def delete_workspace_member(
|
|||||||
member_id: uuid.UUID,
|
member_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
workspace_id = current_user.current_workspace_id
|
workspace_id = current_user.current_workspace_id
|
||||||
api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}")
|
api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}")
|
||||||
@@ -198,7 +235,7 @@ def delete_workspace_member(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间成员删除成功 - ID: {workspace_id}, 成员: {member_id}")
|
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,
|
invite_data: WorkspaceInviteCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -220,7 +259,12 @@ def create_workspace_invite(
|
|||||||
user=current_user
|
user=current_user
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间邀请创建成功 - 工作空间: {workspace_id}, 邮箱: {invite_data.email}")
|
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)
|
@router.get("/invites", response_model=ApiResponse)
|
||||||
@@ -232,6 +276,8 @@ def get_workspace_invites(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -246,18 +292,30 @@ def get_workspace_invites(
|
|||||||
offset=offset
|
offset=offset
|
||||||
)
|
)
|
||||||
api_logger.info(f"成功获取 {len(invites)} 个邀请记录")
|
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)
|
@public_router.get("/invites/validate/{token}", response_model=ApiResponse)
|
||||||
def get_workspace_invite_info(
|
def get_workspace_invite_info(
|
||||||
token: str,
|
token: str,
|
||||||
db: Session = Depends(get_db),
|
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)
|
result = workspace_service.validate_invite_token(db=db, token=token)
|
||||||
api_logger.info(f"工作空间邀请验证成功 - 邀请: {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)
|
@router.delete("/invites/{invite_id}", response_model=ApiResponse)
|
||||||
@@ -267,6 +325,8 @@ def revoke_workspace_invite(
|
|||||||
invite_id: uuid.UUID,
|
invite_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -279,7 +339,12 @@ def revoke_workspace_invite(
|
|||||||
user=current_user
|
user=current_user
|
||||||
)
|
)
|
||||||
api_logger.info(f"工作空间邀请撤销成功 - 邀请: {invite_id}")
|
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,
|
workspace_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""切换工作空间"""
|
"""切换工作空间"""
|
||||||
api_logger.info(f"用户 {current_user.username} 请求切换工作空间为 {workspace_id}")
|
api_logger.info(f"用户 {current_user.username} 请求切换工作空间为 {workspace_id}")
|
||||||
@@ -312,7 +378,7 @@ def switch_workspace(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
api_logger.info(f"成功切换工作空间为 {workspace_id}")
|
api_logger.info(f"成功切换工作空间为 {workspace_id}")
|
||||||
return success(msg="工作空间切换成功")
|
return success(msg=t("workspace.switched"))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/storage", response_model=ApiResponse)
|
@router.get("/storage", response_model=ApiResponse)
|
||||||
@@ -320,6 +386,7 @@ def switch_workspace(
|
|||||||
def get_workspace_storage_type(
|
def get_workspace_storage_type(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""获取当前工作空间的存储类型"""
|
"""获取当前工作空间的存储类型"""
|
||||||
workspace_id = current_user.current_workspace_id
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -331,7 +398,7 @@ def get_workspace_storage_type(
|
|||||||
user=current_user
|
user=current_user
|
||||||
)
|
)
|
||||||
api_logger.info(f"成功获取工作空间 {workspace_id} 的存储类型: {storage_type}")
|
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)
|
@router.get("/workspace_models", response_model=ApiResponse)
|
||||||
@@ -339,6 +406,8 @@ def get_workspace_storage_type(
|
|||||||
def workspace_models_configs(
|
def workspace_models_configs(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
language: str = Depends(get_current_language),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""获取当前工作空间的模型配置(llm, embedding, rerank)"""
|
"""获取当前工作空间的模型配置(llm, embedding, rerank)"""
|
||||||
workspace_id = current_user.current_workspace_id
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -354,14 +423,14 @@ def workspace_models_configs(
|
|||||||
api_logger.warning(f"工作空间 {workspace_id} 不存在或无权访问")
|
api_logger.warning(f"工作空间 {workspace_id} 不存在或无权访问")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="工作空间不存在或无权访问"
|
detail=t("workspace.not_found")
|
||||||
)
|
)
|
||||||
|
|
||||||
api_logger.info(
|
api_logger.info(
|
||||||
f"成功获取工作空间 {workspace_id} 的模型配置: "
|
f"成功获取工作空间 {workspace_id} 的模型配置: "
|
||||||
f"llm={configs.get('llm')}, embedding={configs.get('embedding')}, rerank={configs.get('rerank')}"
|
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)
|
@router.put("/workspace_models", response_model=ApiResponse)
|
||||||
@@ -370,6 +439,7 @@ def update_workspace_models_configs(
|
|||||||
models_update: WorkspaceModelsUpdate,
|
models_update: WorkspaceModelsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
t: callable = Depends(get_translator)
|
||||||
):
|
):
|
||||||
"""更新当前工作空间的模型配置(llm, embedding, rerank)"""
|
"""更新当前工作空间的模型配置(llm, embedding, rerank)"""
|
||||||
workspace_id = current_user.current_workspace_id
|
workspace_id = current_user.current_workspace_id
|
||||||
@@ -386,5 +456,5 @@ def update_workspace_models_configs(
|
|||||||
f"成功更新工作空间 {workspace_id} 的模型配置: "
|
f"成功更新工作空间 {workspace_id} 的模型配置: "
|
||||||
f"llm={updated_workspace.llm}, embedding={updated_workspace.embedding}, rerank={updated_workspace.rerank}"
|
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
|
# This controls the language used for memory summary titles and other generated content
|
||||||
DEFAULT_LANGUAGE: str = os.getenv("DEFAULT_LANGUAGE", "zh")
|
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
|
# Logging settings
|
||||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||||
LOG_FORMAT: str = os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
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=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add i18n language detection middleware
|
||||||
|
from app.i18n.middleware import LanguageMiddleware
|
||||||
|
app.add_middleware(LanguageMiddleware)
|
||||||
|
|
||||||
logger.info("FastAPI应用程序启动")
|
logger.info("FastAPI应用程序启动")
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +133,11 @@ from app.core.exceptions import (
|
|||||||
from app.core.sensitive_filter import SensitiveDataFilter
|
from app.core.sensitive_filter import SensitiveDataFilter
|
||||||
import traceback
|
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)
|
@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)
|
@app.exception_handler(ResourceNotFoundException)
|
||||||
async def not_found_exception_handler(request: Request, exc: 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)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
"""处理HTTP异常"""
|
"""处理HTTP异常,支持国际化"""
|
||||||
# 过滤敏感信息
|
# 获取当前语言
|
||||||
filtered_detail = SensitiveDataFilter.filter_string(str(exc.detail))
|
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(
|
logger.warning(
|
||||||
f"HTTP exception: {filtered_detail}",
|
f"HTTP exception: {translated_message}",
|
||||||
extra={
|
extra={
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"status_code": exc.status_code
|
"status_code": exc.status_code,
|
||||||
|
"language": language
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
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)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(request: Request, exc: 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(
|
logger.error(
|
||||||
f"Unhandled exception: {exc}",
|
f"Unhandled exception: {exc}",
|
||||||
@@ -386,6 +555,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
|||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"exception_type": type(exc).__name__,
|
"exception_type": type(exc).__name__,
|
||||||
|
"language": language,
|
||||||
"traceback": traceback.format_exc()
|
"traceback": traceback.format_exc()
|
||||||
},
|
},
|
||||||
exc_info=True
|
exc_info=True
|
||||||
@@ -394,7 +564,11 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
|||||||
# 生产环境隐藏详细错误信息
|
# 生产环境隐藏详细错误信息
|
||||||
environment = os.getenv("ENVIRONMENT", "development")
|
environment = os.getenv("ENVIRONMENT", "development")
|
||||||
if environment == "production":
|
if environment == "production":
|
||||||
message = "服务器内部错误,请稍后重试"
|
# 使用翻译的通用错误消息
|
||||||
|
message = translation_service.translate(
|
||||||
|
"errors.common.internal_error",
|
||||||
|
language
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 开发环境也要过滤敏感信息
|
# 开发环境也要过滤敏感信息
|
||||||
message = SensitiveDataFilter.filter_string(str(exc))
|
message = SensitiveDataFilter.filter_string(str(exc))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, DateTime, Boolean
|
from sqlalchemy import Column, String, DateTime, Boolean, text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ class Tenants(Base):
|
|||||||
external_id = Column(String(100), nullable=True, index=True) # 外部企业ID
|
external_id = Column(String(100), nullable=True, index=True) # 外部企业ID
|
||||||
external_source = Column(String(50), nullable=True) # 来源系统
|
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
|
# Relationship to users - one tenant has many users
|
||||||
users = relationship("User", back_populates="tenant")
|
users = relationship("User", back_populates="tenant")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
@@ -22,6 +22,9 @@ class User(Base):
|
|||||||
external_id = Column(String(100), nullable=True) # 外部用户ID
|
external_id = Column(String(100), nullable=True) # 外部用户ID
|
||||||
external_source = Column(String(50), nullable=True) # 来源系统
|
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,可为空
|
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
|
# 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)
|
name: str = Field(..., description="租户名称", max_length=255)
|
||||||
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
||||||
is_active: bool = Field(True, description="是否激活")
|
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')
|
@field_validator('name')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -18,6 +20,26 @@ class TenantBase(BaseModel):
|
|||||||
if not v or not v.strip():
|
if not v or not v.strip():
|
||||||
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
||||||
return v.strip()
|
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):
|
class TenantCreate(TenantBase):
|
||||||
@@ -30,6 +52,8 @@ class TenantUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="租户名称", max_length=255)
|
name: Optional[str] = Field(None, description="租户名称", max_length=255)
|
||||||
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
description: Optional[str] = Field(None, description="租户描述", max_length=1000)
|
||||||
is_active: Optional[bool] = Field(None, description="是否激活")
|
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')
|
@field_validator('name')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -37,6 +61,25 @@ class TenantUpdate(BaseModel):
|
|||||||
if v is not None and (not v or not v.strip()):
|
if v is not None and (not v or not v.strip()):
|
||||||
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
raise ValidationException('租户名称不能为空', code=BizCode.VALIDATION_FAILED)
|
||||||
return v.strip() if v else v
|
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):
|
class Tenant(TenantBase):
|
||||||
@@ -62,4 +105,29 @@ class TenantList(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
size: 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="密码")
|
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):
|
class ChangePasswordResponse(BaseModel):
|
||||||
"""修改密码响应"""
|
"""修改密码响应"""
|
||||||
message: str
|
message: str
|
||||||
@@ -74,6 +84,7 @@ class User(UserBase):
|
|||||||
current_workspace_id: Optional[uuid.UUID] = None
|
current_workspace_id: Optional[uuid.UUID] = None
|
||||||
current_workspace_name: Optional[str] = None
|
current_workspace_name: Optional[str] = None
|
||||||
role: Optional[WorkspaceRole] = None
|
role: Optional[WorkspaceRole] = None
|
||||||
|
preferred_language: Optional[str] = "zh" # 用户语言偏好
|
||||||
|
|
||||||
# 将 datetime 转换为毫秒时间戳
|
# 将 datetime 转换为毫秒时间戳
|
||||||
@validator("created_at", pre=True)
|
@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.exceptions import BusinessException
|
||||||
from app.core.error_codes import BizCode
|
from app.core.error_codes import BizCode
|
||||||
from app.core.logging_config import get_auth_logger
|
from app.core.logging_config import get_auth_logger
|
||||||
|
from app.i18n.service import t
|
||||||
|
|
||||||
logger = get_auth_logger()
|
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)
|
user = user_repository.get_user_by_email(db, email=email)
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"用户不存在: {email}")
|
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:
|
if not user.is_active:
|
||||||
logger.warning(f"用户未激活: {email}")
|
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):
|
if not verify_password(password, user.hashed_password):
|
||||||
logger.warning(f"密码错误: {email}")
|
logger.warning(f"密码错误: {email}")
|
||||||
raise BusinessException("密码错误", code=BizCode.PASSWORD_ERROR)
|
raise BusinessException(t("auth.password.incorrect"), code=BizCode.PASSWORD_ERROR)
|
||||||
|
|
||||||
logger.info(f"用户认证成功: {email}")
|
logger.info(f"用户认证成功: {email}")
|
||||||
return user
|
return user
|
||||||
@@ -254,6 +255,8 @@ def decode_access_token(token: str) -> dict:
|
|||||||
Raises:
|
Raises:
|
||||||
BusinessException: token 无效
|
BusinessException: token 无效
|
||||||
"""
|
"""
|
||||||
|
from app.i18n.service import t
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, TOKEN_SECRET_KEY, algorithms=[TOKEN_ALGORITHM])
|
payload = jwt.decode(token, TOKEN_SECRET_KEY, algorithms=[TOKEN_ALGORITHM])
|
||||||
return {
|
return {
|
||||||
@@ -261,4 +264,4 @@ def decode_access_token(token: str) -> dict:
|
|||||||
"share_token": payload["share_token"]
|
"share_token": payload["share_token"]
|
||||||
}
|
}
|
||||||
except jwt.InvalidTokenError:
|
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,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
is_active=is_active
|
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:
|
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}")
|
business_logger.info(f"用户修改密码请求: user_id={user_id}, current_user={current_user.id}")
|
||||||
|
|
||||||
# 检查权限:只能修改自己的密码
|
# 检查权限:只能修改自己的密码
|
||||||
if current_user.id != user_id:
|
if current_user.id != user_id:
|
||||||
business_logger.warning(f"用户尝试修改他人密码: current_user={current_user.id}, target_user={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:
|
try:
|
||||||
# 获取用户
|
# 获取用户
|
||||||
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
|
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
business_logger.warning(f"用户不存在: {user_id}")
|
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):
|
if not verify_password(old_password, db_user.hashed_password):
|
||||||
business_logger.warning(f"用户旧密码验证失败: {user_id}")
|
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)
|
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:
|
except Exception as e:
|
||||||
business_logger.error(f"修改用户密码失败: user_id={user_id} - {str(e)}")
|
business_logger.error(f"修改用户密码失败: user_id={user_id} - {str(e)}")
|
||||||
db.rollback()
|
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]:
|
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:
|
Returns:
|
||||||
tuple[User, str]: (更新后的用户对象, 实际使用的密码)
|
tuple[User, str]: (更新后的用户对象, 实际使用的密码)
|
||||||
"""
|
"""
|
||||||
|
from app.i18n.service import t
|
||||||
|
|
||||||
business_logger.info(f"管理员修改用户密码请求: admin={current_user.id}, target_user={target_user_id}")
|
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:
|
try:
|
||||||
permission_service.check_superuser(
|
permission_service.check_superuser(
|
||||||
subject,
|
subject,
|
||||||
error_message="只有超级管理员可以修改他人密码"
|
error_message=t("auth.password.change_failed")
|
||||||
)
|
)
|
||||||
except PermissionDeniedException as e:
|
except PermissionDeniedException as e:
|
||||||
business_logger.warning(f"非超管用户尝试修改他人密码: current_user={current_user.id}")
|
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)
|
target_user = user_repository.get_user_by_id(db=db, user_id=target_user_id)
|
||||||
if not target_user:
|
if not target_user:
|
||||||
business_logger.warning(f"目标用户不存在: {target_user_id}")
|
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:
|
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}")
|
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()
|
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:
|
except Exception as e:
|
||||||
business_logger.error(f"管理员修改用户密码失败: admin={current_user.id}, target_user={target_user_id} - {str(e)}")
|
business_logger.error(f"管理员修改用户密码失败: admin={current_user.id}, target_user={target_user_id} - {str(e)}")
|
||||||
db.rollback()
|
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:
|
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}")
|
# business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}")
|
||||||
# return db_user
|
# 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