From 9fb3dad5bd79cb85da607f19f1053e0210154ad5 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 10 Jul 2026 11:49:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A0=E5=AF=86=E5=B9=B6=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E7=B3=BB=E7=BB=9F=E6=95=8F=E6=84=9F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/backend/app/api/admin_settings.py | 21 +++++-- .../apps/backend/app/core/config.py | 1 + ai_knowledge_base_v2/apps/backend/app/main.py | 2 + .../backend/app/services/feishu_service.py | 3 +- .../backend/app/services/model_service.py | 17 ++++-- .../backend/app/services/secret_service.py | 58 +++++++++++++++++++ .../backend/app/services/sms_code_service.py | 7 +-- 7 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/secret_service.py diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py index 2cef455..7015bda 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py @@ -22,6 +22,7 @@ from app.services.feishu_service import FeishuKnowledgeService from app.services.knowledge_service import KnowledgeScope from app.services.model_service import ModelClientService from app.services.rag_service import RagResult +from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService router = APIRouter() @@ -110,7 +111,7 @@ def create_model( model_name=payload.modelName, base_url=payload.baseUrl, api_url=payload.apiUrl, - api_key=payload.apiKey, + api_key=SecretService.encrypt(payload.apiKey), auth_type=payload.authType, api_version=payload.apiVersion, temperature=payload.temperature, @@ -152,8 +153,8 @@ def update_model( model.model_name = payload.modelName model.base_url = payload.baseUrl model.api_url = payload.apiUrl - if payload.apiKey: - model.api_key = payload.apiKey + if payload.apiKey and payload.apiKey != MASKED_SECRET: + model.api_key = SecretService.encrypt(payload.apiKey) model.auth_type = payload.authType model.api_version = payload.apiVersion model.temperature = payload.temperature @@ -237,7 +238,12 @@ def save_config( config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey)) if config is None: config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue) - config.config_value = payload.configValue + if payload.configKey in SENSITIVE_CONFIG_KEYS: + if payload.configValue == MASKED_SECRET: + return api_success(_config_dict(config)) + config.config_value = SecretService.encrypt(payload.configValue) + else: + config.config_value = payload.configValue config.description = payload.description config.updated_by = current_admin.id db.add(config) @@ -257,7 +263,7 @@ def _model_dict(model: ModelConfig) -> dict: "modelName": model.model_name, "baseUrl": model.base_url, "apiUrl": model.api_url, - "apiKeyMasked": "******" if model.api_key else "", + "apiKeyMasked": SecretService.masked(model.api_key), "authType": model.auth_type, "apiVersion": model.api_version, "temperature": float(model.temperature) if model.temperature is not None else None, @@ -309,10 +315,13 @@ def clear_feishu_cache(current_admin: Admin = Depends(get_current_admin)) -> dic def _config_dict(config: SystemConfig) -> dict: + value = config.config_value + if config.config_key in SENSITIVE_CONFIG_KEYS: + value = SecretService.masked(value) return { "id": config.id, "configKey": config.config_key, - "configValue": config.config_value, + "configValue": value, "description": config.description, "updatedAt": config.updated_at, } diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index 9ece30c..a3c9024 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -33,6 +33,7 @@ class Settings(BaseSettings): auto_create_tables: bool = False jwt_secret_key: str = "change-me-in-production" + config_encryption_key: str = "" jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 * 30 diff --git a/ai_knowledge_base_v2/apps/backend/app/main.py b/ai_knowledge_base_v2/apps/backend/app/main.py index 37d16da..f015ff9 100644 --- a/ai_knowledge_base_v2/apps/backend/app/main.py +++ b/ai_knowledge_base_v2/apps/backend/app/main.py @@ -9,11 +9,13 @@ from app.api.router import api_router from app.core.config import get_settings from app.core.database import create_tables from app.core.exception_handlers import register_exception_handlers +from app.services.secret_service import SecretService @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() + SecretService.validate_production_key() if settings.auto_create_tables: create_tables() yield diff --git a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py index d01cda5..075d0bd 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py @@ -13,6 +13,7 @@ from app.core.config import get_settings from app.models.ai_config import SystemConfig from app.services.external_errors import ExternalServiceError from app.services.knowledge_service import KnowledgeScope +from app.services.secret_service import SecretService if TYPE_CHECKING: from app.services.rag_service import RetrievedChunk @@ -812,7 +813,7 @@ def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig: return FeishuRetrievalConfig( search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url), app_id=_config_text(db, "feishu_app_id", settings.feishu_app_id), - app_secret=_config_text(db, "feishu_app_secret", settings.feishu_app_secret), + app_secret=SecretService.decrypt(_config_text(db, "feishu_app_secret", settings.feishu_app_secret)), timeout_seconds=_config_int(db, "feishu_timeout_seconds", settings.feishu_timeout_seconds, minimum=1, maximum=120), retry_count=_config_int(db, "feishu_retry_count", settings.feishu_retry_count, minimum=0, maximum=10), ) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index 7040b49..d7bd41f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -13,6 +13,7 @@ from app.core.config import get_settings from app.models.ai_config import ModelConfig, SystemConfig from app.services.external_errors import ExternalServiceError from app.services.rag_service import NO_HIT_ANSWER, RagResult +from app.services.secret_service import SecretService @dataclass(frozen=True) @@ -343,7 +344,7 @@ def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str: _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature)) _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p)) - headers = {"Authorization": f"Bearer {model.api_key}", "Content-Type": "application/json"} + headers = {"Authorization": f"Bearer {_model_api_key(model)}", "Content-Type": "application/json"} try: response = httpx.post(base_url, json=payload, headers=headers, timeout=model.timeout_second) @@ -398,16 +399,16 @@ def _resolve_gemini_endpoint(model: ModelConfig) -> str: if model.api_url: return model.api_url base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/") - query = urlencode({"key": model.api_key}) + query = urlencode({"key": _model_api_key(model)}) return f"{base_url}/models/{model.model_name}:generateContent?{query}" def _auth_headers(model: ModelConfig) -> dict[str, str]: headers = {"Content-Type": "application/json"} if model.auth_type == "api_key": - headers["x-api-key"] = model.api_key + headers["x-api-key"] = _model_api_key(model) else: - headers["Authorization"] = f"Bearer {model.api_key}" + headers["Authorization"] = f"Bearer {_model_api_key(model)}" if model.api_version: headers["api-version"] = model.api_version return headers @@ -416,9 +417,9 @@ def _auth_headers(model: ModelConfig) -> dict[str, str]: def _anthropic_headers(model: ModelConfig) -> dict[str, str]: headers = {"Content-Type": "application/json"} if model.auth_type == "bearer": - headers["Authorization"] = f"Bearer {model.api_key}" + headers["Authorization"] = f"Bearer {_model_api_key(model)}" else: - headers["x-api-key"] = model.api_key + headers["x-api-key"] = _model_api_key(model) if model.api_version: headers["anthropic-version"] = model.api_version elif model.provider == "anthropic": @@ -430,6 +431,10 @@ def _decimal_to_float(value: Any) -> float | None: return float(value) if value is not None else None +def _model_api_key(model: ModelConfig) -> str: + return SecretService.decrypt(model.api_key) + + def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None: if value is not None: target[key] = value diff --git a/ai_knowledge_base_v2/apps/backend/app/services/secret_service.py b/ai_knowledge_base_v2/apps/backend/app/services/secret_service.py new file mode 100644 index 0000000..54a67e6 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/secret_service.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import get_settings + + +ENCRYPTED_PREFIX = "enc:v1:" +MASKED_SECRET = "******" +SENSITIVE_CONFIG_KEYS = {"aliyun_sms_access_key_secret", "feishu_app_secret"} + + +class SecretService: + @staticmethod + def encrypt(value: str) -> str: + if not value or value.startswith(ENCRYPTED_PREFIX): + return value + token = SecretService._fernet().encrypt(value.encode("utf-8")).decode("ascii") + return f"{ENCRYPTED_PREFIX}{token}" + + @staticmethod + def decrypt(value: str | None) -> str: + if not value: + return "" + if not value.startswith(ENCRYPTED_PREFIX): + return value + try: + return SecretService._fernet().decrypt(value[len(ENCRYPTED_PREFIX) :].encode("ascii")).decode("utf-8") + except InvalidToken as exc: + raise RuntimeError("敏感配置无法解密,请检查 CONFIG_ENCRYPTION_KEY 是否与保存时一致") from exc + + @staticmethod + def masked(value: str | None) -> str: + return MASKED_SECRET if value else "" + + @staticmethod + def validate_production_key() -> None: + settings = get_settings() + if settings.app_env.strip().lower() in {"local", "dev", "development", "docker", "test", "testing"}: + return + if not settings.config_encryption_key.strip(): + raise RuntimeError("生产环境必须配置 CONFIG_ENCRYPTION_KEY") + SecretService._fernet() + + @staticmethod + def _fernet() -> Fernet: + settings = get_settings() + configured = settings.config_encryption_key.strip() + if configured: + try: + return Fernet(configured.encode("ascii")) + except (ValueError, TypeError) as exc: + raise RuntimeError("CONFIG_ENCRYPTION_KEY 格式无效,必须是 Fernet 密钥") from exc + fallback = base64.urlsafe_b64encode(hashlib.sha256(settings.jwt_secret_key.encode("utf-8")).digest()) + return Fernet(fallback) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py index 513de9f..3e7070a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.core.config import get_settings from app.models.ai_config import SystemConfig from app.services.redis_client import get_sync_redis_client +from app.services.secret_service import SecretService logger = logging.getLogger(__name__) @@ -102,10 +103,8 @@ def _load_sms_config(db: Session) -> SmsProviderConfig: "aliyun_sms_access_key_id", settings.aliyun_sms_access_key_id, ), - aliyun_sms_access_key_secret=_config_text( - values, - "aliyun_sms_access_key_secret", - settings.aliyun_sms_access_key_secret, + aliyun_sms_access_key_secret=SecretService.decrypt( + _config_text(values, "aliyun_sms_access_key_secret", settings.aliyun_sms_access_key_secret) ), aliyun_sms_sign_name=_config_text(values, "aliyun_sms_sign_name", settings.aliyun_sms_sign_name), aliyun_sms_template_code=_config_text(