feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
"""
SaaS 超级管理后台 API
提供租户管理、配置管理、提示词管理等功能
"""
from fastapi import APIRouter
from .auth import router as auth_router
from .tenants import router as tenants_router
from .configs import router as configs_router
from .prompts import router as prompts_router
from .features import router as features_router
# 创建管理后台主路由
router = APIRouter(prefix="/admin", tags=["管理后台"])
# 注册子路由
router.include_router(auth_router)
router.include_router(tenants_router)
router.include_router(configs_router)
router.include_router(prompts_router)
router.include_router(features_router)

View File

@@ -0,0 +1,277 @@
"""
管理员认证 API
"""
import os
from datetime import datetime, timedelta
from typing import Optional
import jwt
import pymysql
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from passlib.context import CryptContext
from .schemas import (
AdminLoginRequest,
AdminLoginResponse,
AdminUserInfo,
AdminChangePasswordRequest,
ResponseModel,
)
router = APIRouter(prefix="/auth", tags=["管理员认证"])
# 密码加密
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT 配置
SECRET_KEY = os.getenv("ADMIN_JWT_SECRET", "admin-secret-key-kaopeilian-2026")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
# 安全认证
security = HTTPBearer()
# 管理库连接配置
ADMIN_DB_CONFIG = {
"host": os.getenv("ADMIN_DB_HOST", "prod-mysql"),
"port": int(os.getenv("ADMIN_DB_PORT", "3306")),
"user": os.getenv("ADMIN_DB_USER", "root"),
"password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"),
"db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"),
"charset": "utf8mb4",
}
def get_db_connection():
"""获取数据库连接"""
return pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""获取密码哈希"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> dict:
"""解码访问令牌"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token已过期",
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的Token",
)
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> AdminUserInfo:
"""获取当前登录的管理员"""
token = credentials.credentials
payload = decode_access_token(token)
admin_id = payload.get("sub")
if not admin_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的Token",
)
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, email, full_name, role, is_active, last_login_at
FROM admin_users WHERE id = %s
""",
(admin_id,)
)
admin = cursor.fetchone()
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="管理员不存在",
)
if not admin["is_active"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账户已被禁用",
)
return AdminUserInfo(
id=admin["id"],
username=admin["username"],
email=admin["email"],
full_name=admin["full_name"],
role=admin["role"],
last_login_at=admin["last_login_at"],
)
finally:
conn.close()
async def require_superadmin(
admin: AdminUserInfo = Depends(get_current_admin)
) -> AdminUserInfo:
"""要求超级管理员权限"""
if admin.role != "superadmin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要超级管理员权限",
)
return admin
@router.post("/login", response_model=AdminLoginResponse, summary="管理员登录")
async def admin_login(request: Request, login_data: AdminLoginRequest):
"""
管理员登录
- **username**: 用户名
- **password**: 密码
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 查询管理员
cursor.execute(
"""
SELECT id, username, email, full_name, role, password_hash, is_active, last_login_at
FROM admin_users WHERE username = %s
""",
(login_data.username,)
)
admin = cursor.fetchone()
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
if not admin["is_active"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账户已被禁用",
)
# 验证密码
if not verify_password(login_data.password, admin["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# 更新最后登录时间和IP
client_ip = request.client.host if request.client else None
cursor.execute(
"""
UPDATE admin_users
SET last_login_at = NOW(), last_login_ip = %s
WHERE id = %s
""",
(client_ip, admin["id"])
)
conn.commit()
# 创建 Token
access_token = create_access_token(
data={"sub": str(admin["id"]), "username": admin["username"], "role": admin["role"]}
)
return AdminLoginResponse(
access_token=access_token,
token_type="bearer",
expires_in=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
admin_user=AdminUserInfo(
id=admin["id"],
username=admin["username"],
email=admin["email"],
full_name=admin["full_name"],
role=admin["role"],
last_login_at=datetime.now(),
),
)
finally:
conn.close()
@router.get("/me", response_model=AdminUserInfo, summary="获取当前管理员信息")
async def get_me(admin: AdminUserInfo = Depends(get_current_admin)):
"""获取当前登录管理员的信息"""
return admin
@router.post("/change-password", response_model=ResponseModel, summary="修改密码")
async def change_password(
data: AdminChangePasswordRequest,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
修改当前管理员密码
- **old_password**: 旧密码
- **new_password**: 新密码
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证旧密码
cursor.execute(
"SELECT password_hash FROM admin_users WHERE id = %s",
(admin.id,)
)
row = cursor.fetchone()
if not verify_password(data.old_password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="旧密码错误",
)
# 更新密码
new_hash = get_password_hash(data.new_password)
cursor.execute(
"UPDATE admin_users SET password_hash = %s WHERE id = %s",
(new_hash, admin.id)
)
conn.commit()
return ResponseModel(message="密码修改成功")
finally:
conn.close()
@router.post("/logout", response_model=ResponseModel, summary="退出登录")
async def admin_logout(admin: AdminUserInfo = Depends(get_current_admin)):
"""退出登录(客户端需清除 Token"""
return ResponseModel(message="退出成功")

View File

@@ -0,0 +1,480 @@
"""
配置管理 API
"""
import os
import json
from typing import Optional, List, Dict
import pymysql
from fastapi import APIRouter, Depends, HTTPException, status, Query
from .auth import get_current_admin, get_db_connection, AdminUserInfo
from .schemas import (
ConfigTemplateResponse,
TenantConfigResponse,
TenantConfigCreate,
TenantConfigUpdate,
TenantConfigGroupResponse,
ConfigBatchUpdate,
ResponseModel,
)
router = APIRouter(prefix="/configs", tags=["配置管理"])
# 配置分组显示名称
CONFIG_GROUP_NAMES = {
"database": "数据库配置",
"redis": "Redis配置",
"security": "安全配置",
"coze": "Coze配置",
"ai": "AI服务配置",
"yanji": "言迹工牌配置",
"storage": "文件存储配置",
"basic": "基础配置",
}
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
operation_type: str, resource_type: str, resource_id: int,
resource_name: str, old_value: dict = None, new_value: dict = None):
"""记录操作日志"""
cursor.execute(
"""
INSERT INTO operation_logs
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name, old_value, new_value)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name,
json.dumps(old_value, ensure_ascii=False) if old_value else None,
json.dumps(new_value, ensure_ascii=False) if new_value else None)
)
@router.get("/templates", response_model=List[ConfigTemplateResponse], summary="获取配置模板")
async def get_config_templates(
config_group: Optional[str] = Query(None, description="配置分组筛选"),
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
获取配置模板列表
配置模板定义了所有可配置项的元数据
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
if config_group:
cursor.execute(
"""
SELECT * FROM config_templates
WHERE config_group = %s
ORDER BY sort_order, id
""",
(config_group,)
)
else:
cursor.execute(
"SELECT * FROM config_templates ORDER BY config_group, sort_order, id"
)
rows = cursor.fetchall()
result = []
for row in rows:
# 解析 options 字段
options = None
if row.get("options"):
try:
options = json.loads(row["options"])
except:
pass
result.append(ConfigTemplateResponse(
id=row["id"],
config_group=row["config_group"],
config_key=row["config_key"],
display_name=row["display_name"],
description=row["description"],
value_type=row["value_type"],
default_value=row["default_value"],
is_required=row["is_required"],
is_secret=row["is_secret"],
options=options,
sort_order=row["sort_order"],
))
return result
finally:
conn.close()
@router.get("/groups", response_model=List[Dict], summary="获取配置分组列表")
async def get_config_groups(admin: AdminUserInfo = Depends(get_current_admin)):
"""获取配置分组列表"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT config_group, COUNT(*) as count
FROM config_templates
GROUP BY config_group
ORDER BY config_group
"""
)
rows = cursor.fetchall()
return [
{
"group_name": row["config_group"],
"group_display_name": CONFIG_GROUP_NAMES.get(row["config_group"], row["config_group"]),
"config_count": row["count"],
}
for row in rows
]
finally:
conn.close()
@router.get("/tenants/{tenant_id}", response_model=List[TenantConfigGroupResponse], summary="获取租户配置")
async def get_tenant_configs(
tenant_id: int,
config_group: Optional[str] = Query(None, description="配置分组筛选"),
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
获取租户的所有配置
返回按分组整理的配置列表,包含模板信息
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 查询配置模板和租户配置
group_filter = "AND ct.config_group = %s" if config_group else ""
params = [tenant_id, config_group] if config_group else [tenant_id]
cursor.execute(
f"""
SELECT
ct.config_group,
ct.config_key,
ct.display_name,
ct.description,
ct.value_type,
ct.default_value,
ct.is_required,
ct.is_secret,
ct.sort_order,
tc.id as config_id,
tc.config_value,
tc.is_encrypted,
tc.created_at,
tc.updated_at
FROM config_templates ct
LEFT JOIN tenant_configs tc
ON tc.config_group = ct.config_group
AND tc.config_key = ct.config_key
AND tc.tenant_id = %s
WHERE 1=1 {group_filter}
ORDER BY ct.config_group, ct.sort_order, ct.id
""",
params
)
rows = cursor.fetchall()
# 按分组整理
groups: Dict[str, List] = {}
for row in rows:
group = row["config_group"]
if group not in groups:
groups[group] = []
# 如果是敏感信息且有值,隐藏部分内容
config_value = row["config_value"]
if row["is_secret"] and config_value:
if len(config_value) > 8:
config_value = config_value[:4] + "****" + config_value[-4:]
else:
config_value = "****"
groups[group].append(TenantConfigResponse(
id=row["config_id"] or 0,
config_group=row["config_group"],
config_key=row["config_key"],
config_value=config_value if not row["is_secret"] else row["config_value"],
value_type=row["value_type"],
is_encrypted=row["is_encrypted"] or False,
description=row["description"],
created_at=row["created_at"] or None,
updated_at=row["updated_at"] or None,
display_name=row["display_name"],
is_required=row["is_required"],
is_secret=row["is_secret"],
))
return [
TenantConfigGroupResponse(
group_name=group,
group_display_name=CONFIG_GROUP_NAMES.get(group, group),
configs=configs,
)
for group, configs in groups.items()
]
finally:
conn.close()
@router.put("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="更新单个配置")
async def update_tenant_config(
tenant_id: int,
config_group: str,
config_key: str,
data: TenantConfigUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""更新租户的单个配置项"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 验证配置模板存在
cursor.execute(
"""
SELECT value_type, is_secret FROM config_templates
WHERE config_group = %s AND config_key = %s
""",
(config_group, config_key)
)
template = cursor.fetchone()
if not template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的配置项",
)
# 检查是否已有配置
cursor.execute(
"""
SELECT id, config_value FROM tenant_configs
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
""",
(tenant_id, config_group, config_key)
)
existing = cursor.fetchone()
if existing:
# 更新
old_value = existing["config_value"]
cursor.execute(
"""
UPDATE tenant_configs
SET config_value = %s, is_encrypted = %s
WHERE id = %s
""",
(data.config_value, template["is_secret"], existing["id"])
)
else:
# 插入
old_value = None
cursor.execute(
"""
INSERT INTO tenant_configs
(tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(tenant_id, config_group, config_key, data.config_value,
template["value_type"], template["is_secret"])
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"update", "config", tenant_id, f"{config_group}.{config_key}",
old_value={"value": old_value} if old_value else None,
new_value={"value": data.config_value}
)
conn.commit()
return ResponseModel(message="配置已更新")
finally:
conn.close()
@router.put("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新配置")
async def batch_update_tenant_configs(
tenant_id: int,
data: ConfigBatchUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""批量更新租户配置"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
updated_count = 0
for config in data.configs:
# 获取模板信息
cursor.execute(
"""
SELECT value_type, is_secret FROM config_templates
WHERE config_group = %s AND config_key = %s
""",
(config.config_group, config.config_key)
)
template = cursor.fetchone()
if not template:
continue
# 检查是否已有配置
cursor.execute(
"""
SELECT id FROM tenant_configs
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
""",
(tenant_id, config.config_group, config.config_key)
)
existing = cursor.fetchone()
if existing:
cursor.execute(
"""
UPDATE tenant_configs
SET config_value = %s, is_encrypted = %s
WHERE id = %s
""",
(config.config_value, template["is_secret"], existing["id"])
)
else:
cursor.execute(
"""
INSERT INTO tenant_configs
(tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(tenant_id, config.config_group, config.config_key, config.config_value,
template["value_type"], template["is_secret"])
)
updated_count += 1
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"batch_update", "config", tenant_id, f"批量更新 {updated_count} 项配置"
)
conn.commit()
return ResponseModel(message=f"已更新 {updated_count} 项配置")
finally:
conn.close()
@router.delete("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="删除配置")
async def delete_tenant_config(
tenant_id: int,
config_group: str,
config_key: str,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""删除租户的配置项(恢复为默认值)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 删除配置
cursor.execute(
"""
DELETE FROM tenant_configs
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
""",
(tenant_id, config_group, config_key)
)
if cursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="配置不存在",
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"delete", "config", tenant_id, f"{config_group}.{config_key}"
)
conn.commit()
return ResponseModel(message="配置已删除,将使用默认值")
finally:
conn.close()
@router.post("/tenants/{tenant_id}/refresh-cache", response_model=ResponseModel, summary="刷新配置缓存")
async def refresh_tenant_config_cache(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""刷新租户的配置缓存"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 获取租户编码
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 刷新缓存
try:
from app.core.config import DynamicConfig
import asyncio
asyncio.create_task(DynamicConfig.refresh_cache(tenant["code"]))
except Exception as e:
pass # 缓存刷新失败不影响主流程
return ResponseModel(message="缓存刷新请求已发送")
finally:
conn.close()

View File

@@ -0,0 +1,424 @@
"""
功能开关管理 API
"""
import os
import json
from typing import Optional, List, Dict
import pymysql
from fastapi import APIRouter, Depends, HTTPException, status, Query
from .auth import get_current_admin, get_db_connection, AdminUserInfo
from .schemas import (
FeatureSwitchCreate,
FeatureSwitchUpdate,
FeatureSwitchResponse,
FeatureSwitchGroupResponse,
ResponseModel,
)
router = APIRouter(prefix="/features", tags=["功能开关"])
# 功能分组显示名称
FEATURE_GROUP_NAMES = {
"exam": "考试模块",
"practice": "陪练模块",
"broadcast": "播课模块",
"course": "课程模块",
"yanji": "智能工牌模块",
}
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
operation_type: str, resource_type: str, resource_id: int,
resource_name: str, old_value: dict = None, new_value: dict = None):
"""记录操作日志"""
cursor.execute(
"""
INSERT INTO operation_logs
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name, old_value, new_value)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name,
json.dumps(old_value, ensure_ascii=False) if old_value else None,
json.dumps(new_value, ensure_ascii=False) if new_value else None)
)
@router.get("/defaults", response_model=List[FeatureSwitchGroupResponse], summary="获取默认功能开关")
async def get_default_features(
admin: AdminUserInfo = Depends(get_current_admin),
):
"""获取全局默认的功能开关配置"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT * FROM feature_switches
WHERE tenant_id IS NULL
ORDER BY feature_group, id
"""
)
rows = cursor.fetchall()
# 按分组整理
groups: Dict[str, List] = {}
for row in rows:
group = row["feature_group"] or "other"
if group not in groups:
groups[group] = []
config = None
if row.get("config"):
try:
config = json.loads(row["config"])
except:
pass
groups[group].append(FeatureSwitchResponse(
id=row["id"],
tenant_id=row["tenant_id"],
feature_code=row["feature_code"],
feature_name=row["feature_name"],
feature_group=row["feature_group"],
is_enabled=row["is_enabled"],
config=config,
description=row["description"],
created_at=row["created_at"],
updated_at=row["updated_at"],
))
return [
FeatureSwitchGroupResponse(
group_name=group,
group_display_name=FEATURE_GROUP_NAMES.get(group, group),
features=features,
)
for group, features in groups.items()
]
finally:
conn.close()
@router.get("/tenants/{tenant_id}", response_model=List[FeatureSwitchGroupResponse], summary="获取租户功能开关")
async def get_tenant_features(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
获取租户的功能开关配置
返回租户自定义配置和默认配置的合并结果
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 获取默认配置
cursor.execute(
"""
SELECT * FROM feature_switches
WHERE tenant_id IS NULL
ORDER BY feature_group, id
"""
)
default_rows = cursor.fetchall()
# 获取租户配置
cursor.execute(
"""
SELECT * FROM feature_switches
WHERE tenant_id = %s
""",
(tenant_id,)
)
tenant_rows = cursor.fetchall()
# 合并配置
tenant_features = {row["feature_code"]: row for row in tenant_rows}
groups: Dict[str, List] = {}
for row in default_rows:
group = row["feature_group"] or "other"
if group not in groups:
groups[group] = []
# 使用租户配置覆盖默认配置
effective_row = tenant_features.get(row["feature_code"], row)
config = None
if effective_row.get("config"):
try:
config = json.loads(effective_row["config"])
except:
pass
groups[group].append(FeatureSwitchResponse(
id=effective_row["id"],
tenant_id=effective_row["tenant_id"],
feature_code=effective_row["feature_code"],
feature_name=effective_row["feature_name"],
feature_group=effective_row["feature_group"],
is_enabled=effective_row["is_enabled"],
config=config,
description=effective_row["description"],
created_at=effective_row["created_at"],
updated_at=effective_row["updated_at"],
))
return [
FeatureSwitchGroupResponse(
group_name=group,
group_display_name=FEATURE_GROUP_NAMES.get(group, group),
features=features,
)
for group, features in groups.items()
]
finally:
conn.close()
@router.put("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="更新租户功能开关")
async def update_tenant_feature(
tenant_id: int,
feature_code: str,
data: FeatureSwitchUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""更新租户的功能开关"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 获取默认配置
cursor.execute(
"""
SELECT * FROM feature_switches
WHERE tenant_id IS NULL AND feature_code = %s
""",
(feature_code,)
)
default_feature = cursor.fetchone()
if not default_feature:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的功能编码",
)
# 检查租户是否已有配置
cursor.execute(
"""
SELECT id, is_enabled FROM feature_switches
WHERE tenant_id = %s AND feature_code = %s
""",
(tenant_id, feature_code)
)
existing = cursor.fetchone()
if existing:
# 更新
old_enabled = existing["is_enabled"]
update_fields = []
update_values = []
if data.is_enabled is not None:
update_fields.append("is_enabled = %s")
update_values.append(data.is_enabled)
if data.config is not None:
update_fields.append("config = %s")
update_values.append(json.dumps(data.config))
if update_fields:
update_values.append(existing["id"])
cursor.execute(
f"UPDATE feature_switches SET {', '.join(update_fields)} WHERE id = %s",
update_values
)
else:
# 创建租户配置
old_enabled = default_feature["is_enabled"]
cursor.execute(
"""
INSERT INTO feature_switches
(tenant_id, feature_code, feature_name, feature_group, is_enabled, config, description)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(tenant_id, feature_code, default_feature["feature_name"],
default_feature["feature_group"],
data.is_enabled if data.is_enabled is not None else default_feature["is_enabled"],
json.dumps(data.config) if data.config else default_feature["config"],
default_feature["description"])
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"update", "feature", tenant_id, feature_code,
old_value={"is_enabled": old_enabled},
new_value={"is_enabled": data.is_enabled, "config": data.config}
)
conn.commit()
status_text = "启用" if data.is_enabled else "禁用"
return ResponseModel(message=f"功能 {default_feature['feature_name']}{status_text}")
finally:
conn.close()
@router.delete("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="重置租户功能开关")
async def reset_tenant_feature(
tenant_id: int,
feature_code: str,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""重置租户的功能开关为默认值"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 删除租户配置
cursor.execute(
"""
DELETE FROM feature_switches
WHERE tenant_id = %s AND feature_code = %s
""",
(tenant_id, feature_code)
)
if cursor.rowcount == 0:
return ResponseModel(message="功能配置已是默认值")
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"reset", "feature", tenant_id, feature_code
)
conn.commit()
return ResponseModel(message="功能配置已重置为默认值")
finally:
conn.close()
@router.post("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新功能开关")
async def batch_update_tenant_features(
tenant_id: int,
features: List[Dict],
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
批量更新租户的功能开关
请求体格式:
[
{"feature_code": "exam_module", "is_enabled": true},
{"feature_code": "practice_voice", "is_enabled": false}
]
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
updated_count = 0
for feature in features:
feature_code = feature.get("feature_code")
is_enabled = feature.get("is_enabled")
if not feature_code or is_enabled is None:
continue
# 获取默认配置
cursor.execute(
"""
SELECT * FROM feature_switches
WHERE tenant_id IS NULL AND feature_code = %s
""",
(feature_code,)
)
default_feature = cursor.fetchone()
if not default_feature:
continue
# 检查租户是否已有配置
cursor.execute(
"""
SELECT id FROM feature_switches
WHERE tenant_id = %s AND feature_code = %s
""",
(tenant_id, feature_code)
)
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE feature_switches SET is_enabled = %s WHERE id = %s",
(is_enabled, existing["id"])
)
else:
cursor.execute(
"""
INSERT INTO feature_switches
(tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(tenant_id, feature_code, default_feature["feature_name"],
default_feature["feature_group"], is_enabled, default_feature["description"])
)
updated_count += 1
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"batch_update", "feature", tenant_id, f"批量更新 {updated_count} 项功能开关"
)
conn.commit()
return ResponseModel(message=f"已更新 {updated_count} 项功能开关")
finally:
conn.close()

View File

@@ -0,0 +1,637 @@
"""
AI 提示词管理 API
"""
import os
import json
from typing import Optional, List
import pymysql
from fastapi import APIRouter, Depends, HTTPException, status, Query
from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo
from .schemas import (
AIPromptCreate,
AIPromptUpdate,
AIPromptResponse,
AIPromptVersionResponse,
TenantPromptResponse,
TenantPromptUpdate,
ResponseModel,
)
router = APIRouter(prefix="/prompts", tags=["提示词管理"])
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
operation_type: str, resource_type: str, resource_id: int,
resource_name: str, old_value: dict = None, new_value: dict = None):
"""记录操作日志"""
cursor.execute(
"""
INSERT INTO operation_logs
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name, old_value, new_value)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name,
json.dumps(old_value, ensure_ascii=False) if old_value else None,
json.dumps(new_value, ensure_ascii=False) if new_value else None)
)
@router.get("", response_model=List[AIPromptResponse], summary="获取提示词列表")
async def list_prompts(
module: Optional[str] = Query(None, description="模块筛选"),
is_active: Optional[bool] = Query(None, description="是否启用"),
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
获取所有 AI 提示词模板
- **module**: 模块筛选course, exam, practice, ability
- **is_active**: 是否启用
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
conditions = []
params = []
if module:
conditions.append("module = %s")
params.append(module)
if is_active is not None:
conditions.append("is_active = %s")
params.append(is_active)
where_clause = " AND ".join(conditions) if conditions else "1=1"
cursor.execute(
f"""
SELECT * FROM ai_prompts
WHERE {where_clause}
ORDER BY module, id
""",
params
)
rows = cursor.fetchall()
result = []
for row in rows:
# 解析 JSON 字段
variables = None
if row.get("variables"):
try:
variables = json.loads(row["variables"])
except:
pass
output_schema = None
if row.get("output_schema"):
try:
output_schema = json.loads(row["output_schema"])
except:
pass
result.append(AIPromptResponse(
id=row["id"],
code=row["code"],
name=row["name"],
description=row["description"],
module=row["module"],
system_prompt=row["system_prompt"],
user_prompt_template=row["user_prompt_template"],
variables=variables,
output_schema=output_schema,
model_recommendation=row["model_recommendation"],
max_tokens=row["max_tokens"],
temperature=float(row["temperature"]) if row["temperature"] else 0.7,
is_system=row["is_system"],
is_active=row["is_active"],
version=row["version"],
created_at=row["created_at"],
updated_at=row["updated_at"],
))
return result
finally:
conn.close()
@router.get("/{prompt_id}", response_model=AIPromptResponse, summary="获取提示词详情")
async def get_prompt(
prompt_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""获取提示词详情"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="提示词不存在",
)
# 解析 JSON 字段
variables = None
if row.get("variables"):
try:
variables = json.loads(row["variables"])
except:
pass
output_schema = None
if row.get("output_schema"):
try:
output_schema = json.loads(row["output_schema"])
except:
pass
return AIPromptResponse(
id=row["id"],
code=row["code"],
name=row["name"],
description=row["description"],
module=row["module"],
system_prompt=row["system_prompt"],
user_prompt_template=row["user_prompt_template"],
variables=variables,
output_schema=output_schema,
model_recommendation=row["model_recommendation"],
max_tokens=row["max_tokens"],
temperature=float(row["temperature"]) if row["temperature"] else 0.7,
is_system=row["is_system"],
is_active=row["is_active"],
version=row["version"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
finally:
conn.close()
@router.post("", response_model=AIPromptResponse, summary="创建提示词")
async def create_prompt(
data: AIPromptCreate,
admin: AdminUserInfo = Depends(require_superadmin),
):
"""
创建新的提示词模板
需要超级管理员权限
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 检查编码是否已存在
cursor.execute("SELECT id FROM ai_prompts WHERE code = %s", (data.code,))
if cursor.fetchone():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="提示词编码已存在",
)
# 创建提示词
cursor.execute(
"""
INSERT INTO ai_prompts
(code, name, description, module, system_prompt, user_prompt_template,
variables, output_schema, model_recommendation, max_tokens, temperature,
is_system, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s)
""",
(data.code, data.name, data.description, data.module,
data.system_prompt, data.user_prompt_template,
json.dumps(data.variables) if data.variables else None,
json.dumps(data.output_schema) if data.output_schema else None,
data.model_recommendation, data.max_tokens, data.temperature,
admin.id)
)
prompt_id = cursor.lastrowid
# 记录操作日志
log_operation(
cursor, admin, None, None,
"create", "prompt", prompt_id, data.name,
new_value=data.model_dump()
)
conn.commit()
return await get_prompt(prompt_id, admin)
finally:
conn.close()
@router.put("/{prompt_id}", response_model=AIPromptResponse, summary="更新提示词")
async def update_prompt(
prompt_id: int,
data: AIPromptUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
更新提示词模板
更新会自动保存版本历史
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 获取原提示词
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
old_prompt = cursor.fetchone()
if not old_prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="提示词不存在",
)
# 保存版本历史(如果系统提示词或用户提示词有变化)
if data.system_prompt or data.user_prompt_template:
new_version = old_prompt["version"] + 1
cursor.execute(
"""
INSERT INTO ai_prompt_versions
(prompt_id, version, system_prompt, user_prompt_template, variables,
output_schema, change_summary, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(prompt_id, old_prompt["version"],
old_prompt["system_prompt"], old_prompt["user_prompt_template"],
old_prompt["variables"], old_prompt["output_schema"],
f"版本 {old_prompt['version']} 备份",
admin.id)
)
else:
new_version = old_prompt["version"]
# 构建更新语句
update_fields = []
update_values = []
if data.name is not None:
update_fields.append("name = %s")
update_values.append(data.name)
if data.description is not None:
update_fields.append("description = %s")
update_values.append(data.description)
if data.system_prompt is not None:
update_fields.append("system_prompt = %s")
update_values.append(data.system_prompt)
if data.user_prompt_template is not None:
update_fields.append("user_prompt_template = %s")
update_values.append(data.user_prompt_template)
if data.variables is not None:
update_fields.append("variables = %s")
update_values.append(json.dumps(data.variables))
if data.output_schema is not None:
update_fields.append("output_schema = %s")
update_values.append(json.dumps(data.output_schema))
if data.model_recommendation is not None:
update_fields.append("model_recommendation = %s")
update_values.append(data.model_recommendation)
if data.max_tokens is not None:
update_fields.append("max_tokens = %s")
update_values.append(data.max_tokens)
if data.temperature is not None:
update_fields.append("temperature = %s")
update_values.append(data.temperature)
if data.is_active is not None:
update_fields.append("is_active = %s")
update_values.append(data.is_active)
if not update_fields:
return await get_prompt(prompt_id, admin)
# 更新版本号
if data.system_prompt or data.user_prompt_template:
update_fields.append("version = %s")
update_values.append(new_version)
update_fields.append("updated_by = %s")
update_values.append(admin.id)
update_values.append(prompt_id)
cursor.execute(
f"UPDATE ai_prompts SET {', '.join(update_fields)} WHERE id = %s",
update_values
)
# 记录操作日志
log_operation(
cursor, admin, None, None,
"update", "prompt", prompt_id, old_prompt["name"],
old_value={"version": old_prompt["version"]},
new_value=data.model_dump(exclude_unset=True)
)
conn.commit()
return await get_prompt(prompt_id, admin)
finally:
conn.close()
@router.get("/{prompt_id}/versions", response_model=List[AIPromptVersionResponse], summary="获取提示词版本历史")
async def get_prompt_versions(
prompt_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""获取提示词的版本历史"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT * FROM ai_prompt_versions
WHERE prompt_id = %s
ORDER BY version DESC
""",
(prompt_id,)
)
rows = cursor.fetchall()
result = []
for row in rows:
variables = None
if row.get("variables"):
try:
variables = json.loads(row["variables"])
except:
pass
result.append(AIPromptVersionResponse(
id=row["id"],
prompt_id=row["prompt_id"],
version=row["version"],
system_prompt=row["system_prompt"],
user_prompt_template=row["user_prompt_template"],
variables=variables,
change_summary=row["change_summary"],
created_at=row["created_at"],
))
return result
finally:
conn.close()
@router.post("/{prompt_id}/rollback/{version}", response_model=AIPromptResponse, summary="回滚提示词版本")
async def rollback_prompt_version(
prompt_id: int,
version: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""回滚到指定版本的提示词"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 获取指定版本
cursor.execute(
"""
SELECT * FROM ai_prompt_versions
WHERE prompt_id = %s AND version = %s
""",
(prompt_id, version)
)
version_row = cursor.fetchone()
if not version_row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="版本不存在",
)
# 获取当前提示词
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
current = cursor.fetchone()
if not current:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="提示词不存在",
)
# 保存当前版本到历史
new_version = current["version"] + 1
cursor.execute(
"""
INSERT INTO ai_prompt_versions
(prompt_id, version, system_prompt, user_prompt_template, variables,
output_schema, change_summary, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(prompt_id, current["version"],
current["system_prompt"], current["user_prompt_template"],
current["variables"], current["output_schema"],
f"回滚前备份(版本 {current['version']}",
admin.id)
)
# 回滚
cursor.execute(
"""
UPDATE ai_prompts
SET system_prompt = %s, user_prompt_template = %s, variables = %s,
output_schema = %s, version = %s, updated_by = %s
WHERE id = %s
""",
(version_row["system_prompt"], version_row["user_prompt_template"],
version_row["variables"], version_row["output_schema"],
new_version, admin.id, prompt_id)
)
# 记录操作日志
log_operation(
cursor, admin, None, None,
"rollback", "prompt", prompt_id, current["name"],
old_value={"version": current["version"]},
new_value={"version": new_version, "rollback_from": version}
)
conn.commit()
return await get_prompt(prompt_id, admin)
finally:
conn.close()
@router.get("/tenants/{tenant_id}", response_model=List[TenantPromptResponse], summary="获取租户自定义提示词")
async def get_tenant_prompts(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""获取租户的自定义提示词列表"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT tp.*, ap.code as prompt_code, ap.name as prompt_name
FROM tenant_prompts tp
JOIN ai_prompts ap ON tp.prompt_id = ap.id
WHERE tp.tenant_id = %s
ORDER BY ap.module, ap.id
""",
(tenant_id,)
)
rows = cursor.fetchall()
return [
TenantPromptResponse(
id=row["id"],
tenant_id=row["tenant_id"],
prompt_id=row["prompt_id"],
prompt_code=row["prompt_code"],
prompt_name=row["prompt_name"],
system_prompt=row["system_prompt"],
user_prompt_template=row["user_prompt_template"],
is_active=row["is_active"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in rows
]
finally:
conn.close()
@router.put("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="更新租户自定义提示词")
async def update_tenant_prompt(
tenant_id: int,
prompt_id: int,
data: TenantPromptUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""创建或更新租户的自定义提示词"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 验证租户存在
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 验证提示词存在
cursor.execute("SELECT name FROM ai_prompts WHERE id = %s", (prompt_id,))
prompt = cursor.fetchone()
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="提示词不存在",
)
# 检查是否已有自定义
cursor.execute(
"""
SELECT id FROM tenant_prompts
WHERE tenant_id = %s AND prompt_id = %s
""",
(tenant_id, prompt_id)
)
existing = cursor.fetchone()
if existing:
# 更新
update_fields = []
update_values = []
if data.system_prompt is not None:
update_fields.append("system_prompt = %s")
update_values.append(data.system_prompt)
if data.user_prompt_template is not None:
update_fields.append("user_prompt_template = %s")
update_values.append(data.user_prompt_template)
if data.is_active is not None:
update_fields.append("is_active = %s")
update_values.append(data.is_active)
if update_fields:
update_fields.append("updated_by = %s")
update_values.append(admin.id)
update_values.append(existing["id"])
cursor.execute(
f"UPDATE tenant_prompts SET {', '.join(update_fields)} WHERE id = %s",
update_values
)
else:
# 创建
cursor.execute(
"""
INSERT INTO tenant_prompts
(tenant_id, prompt_id, system_prompt, user_prompt_template, is_active, created_by)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(tenant_id, prompt_id, data.system_prompt, data.user_prompt_template,
data.is_active if data.is_active is not None else True, admin.id)
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"update", "tenant_prompt", prompt_id, prompt["name"],
new_value=data.model_dump(exclude_unset=True)
)
conn.commit()
return ResponseModel(message="自定义提示词已保存")
finally:
conn.close()
@router.delete("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="删除租户自定义提示词")
async def delete_tenant_prompt(
tenant_id: int,
prompt_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""删除租户的自定义提示词(恢复使用默认)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
DELETE FROM tenant_prompts
WHERE tenant_id = %s AND prompt_id = %s
""",
(tenant_id, prompt_id)
)
if cursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="自定义提示词不存在",
)
conn.commit()
return ResponseModel(message="自定义提示词已删除,将使用默认模板")
finally:
conn.close()

View File

@@ -0,0 +1,352 @@
"""
管理后台数据模型
"""
from datetime import datetime
from typing import Optional, List, Any, Dict
from pydantic import BaseModel, Field
# ============================================
# 通用模型
# ============================================
class ResponseModel(BaseModel):
"""通用响应模型"""
code: int = 0
message: str = "success"
data: Optional[Any] = None
class PaginationParams(BaseModel):
"""分页参数"""
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
class PaginatedResponse(BaseModel):
"""分页响应"""
items: List[Any]
total: int
page: int
page_size: int
total_pages: int
# ============================================
# 认证相关
# ============================================
class AdminLoginRequest(BaseModel):
"""管理员登录请求"""
username: str = Field(..., min_length=1, max_length=50)
password: str = Field(..., min_length=6)
class AdminLoginResponse(BaseModel):
"""管理员登录响应"""
access_token: str
token_type: str = "bearer"
expires_in: int
admin_user: "AdminUserInfo"
class AdminUserInfo(BaseModel):
"""管理员信息"""
id: int
username: str
email: Optional[str]
full_name: Optional[str]
role: str
last_login_at: Optional[datetime]
class AdminChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6)
# ============================================
# 租户相关
# ============================================
class TenantBase(BaseModel):
"""租户基础信息"""
code: str = Field(..., min_length=2, max_length=20, pattern=r'^[a-z0-9_]+$')
name: str = Field(..., min_length=1, max_length=100)
display_name: Optional[str] = Field(None, max_length=200)
domain: str = Field(..., min_length=1, max_length=200)
logo_url: Optional[str] = None
favicon_url: Optional[str] = None
contact_name: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
industry: str = Field(default="medical_beauty")
remarks: Optional[str] = None
class TenantCreate(TenantBase):
"""创建租户请求"""
pass
class TenantUpdate(BaseModel):
"""更新租户请求"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
display_name: Optional[str] = Field(None, max_length=200)
domain: Optional[str] = Field(None, min_length=1, max_length=200)
logo_url: Optional[str] = None
favicon_url: Optional[str] = None
contact_name: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
industry: Optional[str] = None
status: Optional[str] = None
expire_at: Optional[datetime] = None
remarks: Optional[str] = None
class TenantResponse(TenantBase):
"""租户响应"""
id: int
status: str
expire_at: Optional[datetime]
created_at: datetime
updated_at: datetime
config_count: int = 0 # 配置项数量
class Config:
from_attributes = True
class TenantListResponse(BaseModel):
"""租户列表响应"""
items: List[TenantResponse]
total: int
page: int
page_size: int
# ============================================
# 配置相关
# ============================================
class ConfigTemplateResponse(BaseModel):
"""配置模板响应"""
id: int
config_group: str
config_key: str
display_name: str
description: Optional[str]
value_type: str
default_value: Optional[str]
is_required: bool
is_secret: bool
options: Optional[List[str]]
sort_order: int
class TenantConfigBase(BaseModel):
"""租户配置基础"""
config_group: str
config_key: str
config_value: Optional[str] = None
class TenantConfigCreate(TenantConfigBase):
"""创建租户配置请求"""
pass
class TenantConfigUpdate(BaseModel):
"""更新租户配置请求"""
config_value: Optional[str] = None
class TenantConfigResponse(TenantConfigBase):
"""租户配置响应"""
id: int
value_type: str
is_encrypted: bool
description: Optional[str]
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# 从模板获取的额外信息
display_name: Optional[str] = None
is_required: bool = False
is_secret: bool = False
class Config:
from_attributes = True
class TenantConfigGroupResponse(BaseModel):
"""租户配置分组响应"""
group_name: str
group_display_name: str
configs: List[TenantConfigResponse]
class ConfigBatchUpdate(BaseModel):
"""批量更新配置请求"""
configs: List[TenantConfigCreate]
# ============================================
# 提示词相关
# ============================================
class AIPromptBase(BaseModel):
"""AI提示词基础"""
code: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
module: str
system_prompt: str
user_prompt_template: Optional[str] = None
variables: Optional[List[str]] = None
output_schema: Optional[Dict] = None
model_recommendation: Optional[str] = None
max_tokens: int = 4096
temperature: float = 0.7
class AIPromptCreate(AIPromptBase):
"""创建提示词请求"""
pass
class AIPromptUpdate(BaseModel):
"""更新提示词请求"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
system_prompt: Optional[str] = None
user_prompt_template: Optional[str] = None
variables: Optional[List[str]] = None
output_schema: Optional[Dict] = None
model_recommendation: Optional[str] = None
max_tokens: Optional[int] = None
temperature: Optional[float] = None
is_active: Optional[bool] = None
class AIPromptResponse(AIPromptBase):
"""提示词响应"""
id: int
is_system: bool
is_active: bool
version: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AIPromptVersionResponse(BaseModel):
"""提示词版本响应"""
id: int
prompt_id: int
version: int
system_prompt: str
user_prompt_template: Optional[str]
variables: Optional[List[str]]
change_summary: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class TenantPromptResponse(BaseModel):
"""租户自定义提示词响应"""
id: int
tenant_id: int
prompt_id: int
prompt_code: str
prompt_name: str
system_prompt: Optional[str]
user_prompt_template: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TenantPromptUpdate(BaseModel):
"""更新租户自定义提示词"""
system_prompt: Optional[str] = None
user_prompt_template: Optional[str] = None
is_active: Optional[bool] = None
# ============================================
# 功能开关相关
# ============================================
class FeatureSwitchBase(BaseModel):
"""功能开关基础"""
feature_code: str
feature_name: str
feature_group: Optional[str] = None
is_enabled: bool = True
config: Optional[Dict] = None
description: Optional[str] = None
class FeatureSwitchCreate(FeatureSwitchBase):
"""创建功能开关请求"""
pass
class FeatureSwitchUpdate(BaseModel):
"""更新功能开关请求"""
is_enabled: Optional[bool] = None
config: Optional[Dict] = None
class FeatureSwitchResponse(FeatureSwitchBase):
"""功能开关响应"""
id: int
tenant_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class FeatureSwitchGroupResponse(BaseModel):
"""功能开关分组响应"""
group_name: str
group_display_name: str
features: List[FeatureSwitchResponse]
# ============================================
# 操作日志相关
# ============================================
class OperationLogResponse(BaseModel):
"""操作日志响应"""
id: int
admin_username: Optional[str]
tenant_code: Optional[str]
operation_type: str
resource_type: str
resource_name: Optional[str]
old_value: Optional[Dict]
new_value: Optional[Dict]
ip_address: Optional[str]
created_at: datetime
class Config:
from_attributes = True
# 更新前向引用
AdminLoginResponse.model_rebuild()

View File

@@ -0,0 +1,379 @@
"""
租户管理 API
"""
import os
import json
from datetime import datetime
from typing import Optional, List
import pymysql
from fastapi import APIRouter, Depends, HTTPException, status, Query
from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo
from .schemas import (
TenantCreate,
TenantUpdate,
TenantResponse,
TenantListResponse,
ResponseModel,
)
router = APIRouter(prefix="/tenants", tags=["租户管理"])
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
operation_type: str, resource_type: str, resource_id: int,
resource_name: str, old_value: dict = None, new_value: dict = None):
"""记录操作日志"""
cursor.execute(
"""
INSERT INTO operation_logs
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name, old_value, new_value)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
resource_type, resource_id, resource_name,
json.dumps(old_value, ensure_ascii=False) if old_value else None,
json.dumps(new_value, ensure_ascii=False) if new_value else None)
)
@router.get("", response_model=TenantListResponse, summary="获取租户列表")
async def list_tenants(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None, description="状态筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
admin: AdminUserInfo = Depends(get_current_admin),
):
"""
获取租户列表
- **page**: 页码
- **page_size**: 每页数量
- **status**: 状态筛选active, inactive, suspended
- **keyword**: 关键词搜索(匹配名称、编码、域名)
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 构建查询条件
conditions = []
params = []
if status:
conditions.append("t.status = %s")
params.append(status)
if keyword:
conditions.append("(t.name LIKE %s OR t.code LIKE %s OR t.domain LIKE %s)")
params.extend([f"%{keyword}%"] * 3)
where_clause = " AND ".join(conditions) if conditions else "1=1"
# 查询总数
cursor.execute(
f"SELECT COUNT(*) as total FROM tenants t WHERE {where_clause}",
params
)
total = cursor.fetchone()["total"]
# 查询列表
offset = (page - 1) * page_size
cursor.execute(
f"""
SELECT t.*,
(SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count
FROM tenants t
WHERE {where_clause}
ORDER BY t.id DESC
LIMIT %s OFFSET %s
""",
params + [page_size, offset]
)
rows = cursor.fetchall()
items = [TenantResponse(**row) for row in rows]
return TenantListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
)
finally:
conn.close()
@router.get("/{tenant_id}", response_model=TenantResponse, summary="获取租户详情")
async def get_tenant(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""获取租户详情"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT t.*,
(SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count
FROM tenants t
WHERE t.id = %s
""",
(tenant_id,)
)
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
return TenantResponse(**row)
finally:
conn.close()
@router.post("", response_model=TenantResponse, summary="创建租户")
async def create_tenant(
data: TenantCreate,
admin: AdminUserInfo = Depends(require_superadmin),
):
"""
创建新租户
需要超级管理员权限
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 检查编码是否已存在
cursor.execute("SELECT id FROM tenants WHERE code = %s", (data.code,))
if cursor.fetchone():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="租户编码已存在",
)
# 检查域名是否已存在
cursor.execute("SELECT id FROM tenants WHERE domain = %s", (data.domain,))
if cursor.fetchone():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="域名已被使用",
)
# 创建租户
cursor.execute(
"""
INSERT INTO tenants
(code, name, display_name, domain, logo_url, favicon_url,
contact_name, contact_phone, contact_email, industry, remarks, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(data.code, data.name, data.display_name, data.domain,
data.logo_url, data.favicon_url, data.contact_name,
data.contact_phone, data.contact_email, data.industry,
data.remarks, admin.id)
)
tenant_id = cursor.lastrowid
# 记录操作日志
log_operation(
cursor, admin, tenant_id, data.code,
"create", "tenant", tenant_id, data.name,
new_value=data.model_dump()
)
conn.commit()
# 返回创建的租户
return await get_tenant(tenant_id, admin)
finally:
conn.close()
@router.put("/{tenant_id}", response_model=TenantResponse, summary="更新租户")
async def update_tenant(
tenant_id: int,
data: TenantUpdate,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""更新租户信息"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 获取原租户信息
cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,))
old_tenant = cursor.fetchone()
if not old_tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 如果更新域名,检查是否已被使用
if data.domain and data.domain != old_tenant["domain"]:
cursor.execute(
"SELECT id FROM tenants WHERE domain = %s AND id != %s",
(data.domain, tenant_id)
)
if cursor.fetchone():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="域名已被使用",
)
# 构建更新语句
update_fields = []
update_values = []
for field, value in data.model_dump(exclude_unset=True).items():
if value is not None:
update_fields.append(f"{field} = %s")
update_values.append(value)
if not update_fields:
return await get_tenant(tenant_id, admin)
update_fields.append("updated_by = %s")
update_values.append(admin.id)
update_values.append(tenant_id)
cursor.execute(
f"UPDATE tenants SET {', '.join(update_fields)} WHERE id = %s",
update_values
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, old_tenant["code"],
"update", "tenant", tenant_id, old_tenant["name"],
old_value=dict(old_tenant),
new_value=data.model_dump(exclude_unset=True)
)
conn.commit()
return await get_tenant(tenant_id, admin)
finally:
conn.close()
@router.delete("/{tenant_id}", response_model=ResponseModel, summary="删除租户")
async def delete_tenant(
tenant_id: int,
admin: AdminUserInfo = Depends(require_superadmin),
):
"""
删除租户
需要超级管理员权限
警告:此操作将删除租户及其所有配置
"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# 获取租户信息
cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 记录操作日志
log_operation(
cursor, admin, tenant_id, tenant["code"],
"delete", "tenant", tenant_id, tenant["name"],
old_value=dict(tenant)
)
# 删除租户(级联删除配置)
cursor.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
conn.commit()
return ResponseModel(message=f"租户 {tenant['name']} 已删除")
finally:
conn.close()
@router.post("/{tenant_id}/enable", response_model=ResponseModel, summary="启用租户")
async def enable_tenant(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""启用租户"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"UPDATE tenants SET status = 'active', updated_by = %s WHERE id = %s",
(admin.id, tenant_id)
)
if cursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 获取租户信息并记录日志
cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
log_operation(
cursor, admin, tenant_id, tenant["code"],
"enable", "tenant", tenant_id, tenant["name"]
)
conn.commit()
return ResponseModel(message="租户已启用")
finally:
conn.close()
@router.post("/{tenant_id}/disable", response_model=ResponseModel, summary="禁用租户")
async def disable_tenant(
tenant_id: int,
admin: AdminUserInfo = Depends(get_current_admin),
):
"""禁用租户"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"UPDATE tenants SET status = 'inactive', updated_by = %s WHERE id = %s",
(admin.id, tenant_id)
)
if cursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
# 获取租户信息并记录日志
cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,))
tenant = cursor.fetchone()
log_operation(
cursor, admin, tenant_id, tenant["code"],
"disable", "tenant", tenant_id, tenant["name"]
)
conn.commit()
return ResponseModel(message="租户已禁用")
finally:
conn.close()