Some checks failed
continuous-integration/drone/push Build is failing
1. 课程学习进度追踪
- 新增 UserCourseProgress 和 UserMaterialProgress 模型
- 新增 /api/v1/progress/* 进度追踪 API
- 更新 admin.py 使用真实课程完成率数据
2. 路由权限检查完善
- 新增前端 permissionChecker.ts 权限检查工具
- 更新 router/guard.ts 实现团队和课程权限验证
- 新增后端 permission_service.py
3. AI 陪练音频转文本
- 新增 speech_recognition.py 语音识别服务
- 新增 /api/v1/speech/* API
- 更新 ai-practice-coze.vue 支持语音输入
4. 双人对练报告生成
- 更新 practice_room_service.py 添加报告生成功能
- 新增 /rooms/{room_code}/report API
- 更新 duo-practice-report.vue 调用真实 API
5. 学习提醒推送
- 新增 notification_service.py 通知服务
- 新增 scheduler_service.py 定时任务服务
- 支持钉钉、企微、站内消息推送
6. 智能学习推荐
- 新增 recommendation_service.py 推荐服务
- 新增 /api/v1/recommendations/* API
- 支持错题、能力、进度、热门多维度推荐
7. 安全问题修复
- DEBUG 默认值改为 False
- 添加 SECRET_KEY 安全警告
- 新增 check_security_settings() 检查函数
8. 证书 PDF 生成
- 更新 certificate_service.py 添加 PDF 生成
- 添加 weasyprint、Pillow、qrcode 依赖
- 更新下载 API 支持 PDF 和 PNG 格式
382 lines
14 KiB
Python
382 lines
14 KiB
Python
"""
|
||
系统配置
|
||
|
||
支持两种配置来源:
|
||
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)
|
||
|
||
# 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
|