feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
24
backend/app/api/v1/admin_portal/__init__.py
Normal file
24
backend/app/api/v1/admin_portal/__init__.py
Normal 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)
|
||||
|
||||
277
backend/app/api/v1/admin_portal/auth.py
Normal file
277
backend/app/api/v1/admin_portal/auth.py
Normal 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="退出成功")
|
||||
|
||||
480
backend/app/api/v1/admin_portal/configs.py
Normal file
480
backend/app/api/v1/admin_portal/configs.py
Normal 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()
|
||||
|
||||
424
backend/app/api/v1/admin_portal/features.py
Normal file
424
backend/app/api/v1/admin_portal/features.py
Normal 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()
|
||||
|
||||
637
backend/app/api/v1/admin_portal/prompts.py
Normal file
637
backend/app/api/v1/admin_portal/prompts.py
Normal 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()
|
||||
|
||||
352
backend/app/api/v1/admin_portal/schemas.py
Normal file
352
backend/app/api/v1/admin_portal/schemas.py
Normal 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()
|
||||
|
||||
379
backend/app/api/v1/admin_portal/tenants.py
Normal file
379
backend/app/api/v1/admin_portal/tenants.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user