Files
012-kaopeilian/backend/app/core/config.py
yuliang_guo 2f47193059
All checks were successful
continuous-integration/drone/push Build is passing
feat: 集成MinIO对象存储服务
- 新增storage_service.py封装MinIO操作
- 修改upload.py使用storage_service上传文件
- 修改course_service.py使用storage_service删除文件
- 适配preview.py支持从MinIO获取文件
- 适配knowledge_analysis_v2.py支持MinIO存储
- 在config.py添加MinIO配置项
- 添加minio依赖到requirements.txt

支持特性:
- 自动降级到本地存储(MinIO不可用时)
- 保持URL格式兼容(/static/uploads/)
- 文件自动缓存到本地(用于预览和分析)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:06:22 +08:00

390 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
系统配置
支持两种配置来源:
1. 环境变量 / .env 文件(传统方式,向后兼容)
2. 数据库 tenant_configs 表(新方式,支持热更新)
配置优先级:数据库 > 环境变量 > 默认值
"""
import os
import json
from functools import lru_cache
from typing import Optional, Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""系统配置"""
# 应用基础配置
APP_NAME: str = "KaoPeiLian"
APP_VERSION: str = "1.0.0"
# DEBUG 模式:生产环境必须设置为 False
# 通过环境变量 DEBUG=false 或在 .env 文件中设置
DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False")
# 租户配置(用于多租户部署)
TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl")
# 服务器配置
HOST: str = Field(default="0.0.0.0")
PORT: int = Field(default=8000)
# 数据库配置
DATABASE_URL: Optional[str] = Field(default=None)
MYSQL_HOST: str = Field(default="localhost")
MYSQL_PORT: int = Field(default=3306)
MYSQL_USER: str = Field(default="root")
MYSQL_PASSWORD: str = Field(default="password")
MYSQL_DATABASE: str = Field(default="kaopeilian")
@property
def database_url(self) -> str:
"""构建数据库连接URL"""
if self.DATABASE_URL:
return self.DATABASE_URL
# 使用urllib.parse.quote_plus来正确编码特殊字符
import urllib.parse
password = urllib.parse.quote_plus(self.MYSQL_PASSWORD)
return f"mysql+aiomysql://{self.MYSQL_USER}:{password}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}?charset=utf8mb4"
# Redis配置
REDIS_URL: str = Field(default="redis://localhost:6379/0")
# JWT配置
# 安全警告:必须在生产环境设置 SECRET_KEY 环境变量
# 可以使用命令生成python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY: str = Field(
default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION",
description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥"
)
ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
# 跨域配置
CORS_ORIGINS: list[str] = Field(
default=[
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1:5173",
]
)
@field_validator('CORS_ORIGINS', mode='before')
@classmethod
def parse_cors_origins(cls, v):
"""解析 CORS_ORIGINS 环境变量(支持 JSON 格式字符串)"""
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError:
# 如果不是 JSON 格式,尝试按逗号分割
return [origin.strip() for origin in v.split(',')]
return v
# 日志配置
LOG_LEVEL: str = Field(default="INFO")
LOG_FORMAT: str = Field(default="text") # text 或 json
LOG_DIR: str = Field(default="logs")
# 上传配置
UPLOAD_DIR: str = Field(default="uploads")
MAX_UPLOAD_SIZE: int = Field(default=15 * 1024 * 1024) # 15MB
@property
def UPLOAD_PATH(self) -> str:
"""获取上传文件的完整路径"""
import os
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR)
# MinIO对象存储配置
MINIO_ENABLED: bool = Field(default=True, description="是否启用MinIO存储")
MINIO_ENDPOINT: str = Field(default="kaopeilian-minio:9000", description="MinIO服务地址")
MINIO_ACCESS_KEY: str = Field(default="kaopeilian_admin", description="MinIO访问密钥")
MINIO_SECRET_KEY: str = Field(default="KplMinio2026!@#", description="MinIO秘密密钥")
MINIO_SECURE: bool = Field(default=False, description="是否使用HTTPS")
MINIO_PUBLIC_URL: str = Field(default="", description="MinIO公开访问URL留空则使用Nginx代理")
# Coze 平台配置(陪练对话、播课等)
COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn")
COZE_WORKSPACE_ID: Optional[str] = Field(default=None)
COZE_API_TOKEN: Optional[str] = Field(default="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi")
COZE_TRAINING_BOT_ID: Optional[str] = Field(default=None)
COZE_CHAT_BOT_ID: Optional[str] = Field(default=None)
COZE_PRACTICE_BOT_ID: Optional[str] = Field(default="7560643598174683145") # 陪练专用Bot ID
# 播课工作流配置(多租户需在环境变量中覆盖,参见:应用配置清单.md
COZE_BROADCAST_WORKFLOW_ID: str = Field(default="7577983042284486666") # 默认:演示版播课工作流
COZE_BROADCAST_SPACE_ID: str = Field(default="7474971491470688296") # 播课工作流空间ID
COZE_BROADCAST_BOT_ID: Optional[str] = Field(default=None) # 播课工作流专用Bot ID
# OAuth配置可选
COZE_OAUTH_CLIENT_ID: Optional[str] = Field(default=None)
COZE_OAUTH_PUBLIC_KEY_ID: Optional[str] = Field(default=None)
COZE_OAUTH_PRIVATE_KEY_PATH: Optional[str] = Field(default=None)
# WebSocket语音配置
COZE_WS_BASE_URL: str = Field(default="wss://ws.coze.cn")
COZE_AUDIO_FORMAT: str = Field(default="pcm") # 音频格式
COZE_SAMPLE_RATE: int = Field(default=16000) # 采样率Hz
COZE_AUDIO_CHANNELS: int = Field(default=1) # 声道数(单声道)
COZE_AUDIO_BIT_DEPTH: int = Field(default=16) # 位深度
# 服务器公开访问域名
PUBLIC_DOMAIN: str = Field(default="http://aiedu.ireborn.com.cn")
# 言迹智能工牌API配置
YANJI_API_BASE: str = Field(default="https://open.yanjiai.com") # 正式环境
YANJI_CLIENT_ID: str = Field(default="1Fld4LCWt2vpJNG5")
YANJI_CLIENT_SECRET: str = Field(default="XE8w413qNtJBOdWc2aCezV0yMIHpUuTZ")
YANJI_TENANT_ID: str = Field(default="516799409476866048")
YANJI_ESTATE_ID: str = Field(default="516799468310364162")
# SCRM 系统对接 API Key用于内部服务间调用
SCRM_API_KEY: str = Field(default="scrm-kpl-api-key-2026-ruixiaomei")
# AI 服务配置(知识点分析 V2 使用)
# 首选服务商4sapi.com国内优化
AI_PRIMARY_API_KEY: str = Field(default="sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw") # 测试阶段 Key
AI_PRIMARY_BASE_URL: str = Field(default="https://4sapi.com/v1")
# 备选服务商OpenRouter模型全稳定性好
AI_FALLBACK_API_KEY: str = Field(default="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") # 测试阶段 Key
AI_FALLBACK_BASE_URL: str = Field(default="https://openrouter.ai/api/v1")
# 默认模型
AI_DEFAULT_MODEL: str = Field(default="gemini-3-flash-preview")
# 请求超时(秒)
AI_TIMEOUT: float = Field(default=120.0)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": True,
"extra": "allow", # 允许额外的环境变量
}
@lru_cache()
def get_settings() -> Settings:
"""获取系统配置(缓存)"""
return Settings()
settings = get_settings()
def check_security_settings() -> list[str]:
"""
检查安全配置
返回安全警告列表,生产环境应确保列表为空
"""
warnings = []
# 检查 DEBUG 模式
if settings.DEBUG:
warnings.append(
"⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false"
)
# 检查 SECRET_KEY
if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION":
warnings.append(
"⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。"
"生成命令python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
elif len(settings.SECRET_KEY) < 32:
warnings.append(
"⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱"
)
# 检查数据库密码
if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]:
warnings.append(
"⚠️ 数据库密码不安全,请使用强密码"
)
return warnings
def print_security_warnings():
"""打印安全警告(应用启动时调用)"""
import logging
logger = logging.getLogger(__name__)
warnings = check_security_settings()
if warnings:
logger.warning("=" * 60)
logger.warning("安全配置警告:")
for warning in warnings:
logger.warning(warning)
logger.warning("=" * 60)
else:
logger.info("✅ 安全配置检查通过")
# ============================================
# 动态配置获取(支持从数据库读取)
# ============================================
class DynamicConfig:
"""
动态配置管理器
用于在运行时从数据库获取配置,支持热更新。
向后兼容:如果数据库不可用,回退到环境变量配置。
"""
_tenant_loader = None
_initialized = False
@classmethod
async def init(cls, redis_url: Optional[str] = None):
"""
初始化动态配置管理器
Args:
redis_url: Redis URL可选用于缓存
"""
if cls._initialized:
return
try:
from app.core.tenant_config import TenantConfigManager
if redis_url:
await TenantConfigManager.init_redis(redis_url)
cls._initialized = True
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"动态配置初始化失败: {e}")
@classmethod
async def get(cls, key: str, default: Any = None, tenant_code: Optional[str] = None) -> Any:
"""
获取配置值
Args:
key: 配置键(如 AI_PRIMARY_API_KEY
default: 默认值
tenant_code: 租户编码(可选,默认使用环境变量中的 TENANT_CODE
Returns:
配置值
"""
# 确定租户编码
if tenant_code is None:
tenant_code = settings.TENANT_CODE
# 配置键到分组的映射
config_mapping = {
# 数据库
"MYSQL_HOST": ("database", "MYSQL_HOST"),
"MYSQL_PORT": ("database", "MYSQL_PORT"),
"MYSQL_USER": ("database", "MYSQL_USER"),
"MYSQL_PASSWORD": ("database", "MYSQL_PASSWORD"),
"MYSQL_DATABASE": ("database", "MYSQL_DATABASE"),
# Redis
"REDIS_HOST": ("redis", "REDIS_HOST"),
"REDIS_PORT": ("redis", "REDIS_PORT"),
"REDIS_DB": ("redis", "REDIS_DB"),
# 安全
"SECRET_KEY": ("security", "SECRET_KEY"),
"CORS_ORIGINS": ("security", "CORS_ORIGINS"),
# Coze
"COZE_PRACTICE_BOT_ID": ("coze", "COZE_PRACTICE_BOT_ID"),
"COZE_BROADCAST_WORKFLOW_ID": ("coze", "COZE_BROADCAST_WORKFLOW_ID"),
"COZE_BROADCAST_SPACE_ID": ("coze", "COZE_BROADCAST_SPACE_ID"),
"COZE_OAUTH_CLIENT_ID": ("coze", "COZE_OAUTH_CLIENT_ID"),
"COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "COZE_OAUTH_PUBLIC_KEY_ID"),
# AI
"AI_PRIMARY_API_KEY": ("ai", "AI_PRIMARY_API_KEY"),
"AI_PRIMARY_BASE_URL": ("ai", "AI_PRIMARY_BASE_URL"),
"AI_FALLBACK_API_KEY": ("ai", "AI_FALLBACK_API_KEY"),
"AI_FALLBACK_BASE_URL": ("ai", "AI_FALLBACK_BASE_URL"),
"AI_DEFAULT_MODEL": ("ai", "AI_DEFAULT_MODEL"),
"AI_TIMEOUT": ("ai", "AI_TIMEOUT"),
# 言迹
"YANJI_CLIENT_ID": ("yanji", "YANJI_CLIENT_ID"),
"YANJI_CLIENT_SECRET": ("yanji", "YANJI_CLIENT_SECRET"),
"YANJI_TENANT_ID": ("yanji", "YANJI_TENANT_ID"),
"YANJI_ESTATE_ID": ("yanji", "YANJI_ESTATE_ID"),
}
# 尝试从数据库获取
if cls._initialized and key in config_mapping:
try:
from app.core.tenant_config import TenantConfigManager
config_group, config_key = config_mapping[key]
loader = TenantConfigManager.get_loader(tenant_code)
value = await loader.get_config(config_group, config_key)
if value is not None:
return value
except Exception:
pass
# 回退到环境变量 / Settings
env_value = getattr(settings, key, None)
if env_value is not None:
return env_value
return default
@classmethod
async def is_feature_enabled(cls, feature_code: str, tenant_code: Optional[str] = None) -> bool:
"""
检查功能是否启用
Args:
feature_code: 功能编码
tenant_code: 租户编码
Returns:
是否启用
"""
if tenant_code is None:
tenant_code = settings.TENANT_CODE
if cls._initialized:
try:
from app.core.tenant_config import TenantConfigManager
loader = TenantConfigManager.get_loader(tenant_code)
return await loader.is_feature_enabled(feature_code)
except Exception:
pass
return True # 默认启用
@classmethod
async def refresh_cache(cls, tenant_code: Optional[str] = None):
"""
刷新配置缓存
Args:
tenant_code: 租户编码(为空则刷新所有)
"""
if not cls._initialized:
return
try:
from app.core.tenant_config import TenantConfigManager
if tenant_code:
await TenantConfigManager.refresh_tenant_cache(tenant_code)
else:
await TenantConfigManager.refresh_all_cache()
except Exception:
pass