Files
wecom_it_smart_desk/backend/app/api/admin/welcome.py
T

388 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 欢迎与引导配置 API
# =============================================================================
# 说明:管理员工端H5的欢迎页、引导视频、互动教学、主题模板配置
# GET /api/admin/welcome-config - 获取全部配置
# PUT /api/admin/welcome-config - 更新配置
# POST /api/admin/welcome-video - 上传视频
# DELETE /api/admin/welcome-video - 删除视频
# =============================================================================
import json
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.agents import get_current_agent
from app.database import get_db
from app.models.agent import Agent
from app.models.system_config import SystemConfig
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/admin/welcome-config", tags=["欢迎与引导配置"])
# --------------------------------------------------------------------------
# 请求/响应模型
# --------------------------------------------------------------------------
# 欢迎页配置
class WelcomePageConfig(BaseModel):
enabled: bool = False
title: str = "欢迎使用IT服务台"
content: str = "在这里您可以提交IT问题申请,获取快速支持"
background_color: str = "#07C160"
# 引导视频配置
class WelcomeVideoConfig(BaseModel):
url: str
filename: str
uploaded_at: str
# 教程步骤
class TutorialStep(BaseModel):
id: str
title: str
description: str
# 互动教学配置
class TutorialConfig(BaseModel):
enabled: bool = False
steps: list[TutorialStep] = []
# 主题配置
class ThemeConfig(BaseModel):
theme: str = "default" # default/blue/orange/red
# 完整配置
class WelcomeConfigData(BaseModel):
welcome_page: WelcomePageConfig = WelcomePageConfig()
welcome_video: Optional[WelcomeVideoConfig] = None
tutorial: TutorialConfig = TutorialConfig()
theme: ThemeConfig = ThemeConfig()
# 更新配置请求
class UpdateWelcomeConfigRequest(BaseModel):
welcome_page: Optional[WelcomePageConfig] = None
welcome_video: Optional[WelcomeVideoConfig] = None
tutorial: Optional[TutorialConfig] = None
theme: Optional[ThemeConfig] = None
# --------------------------------------------------------------------------
# 视频存储配置
# --------------------------------------------------------------------------
VIDEO_UPLOAD_DIR = Path(os.getenv("VIDEO_UPLOAD_DIR", "./uploads/videos"))
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
MAX_VIDEO_SIZE = int(os.getenv("MAX_VIDEO_SIZE", str(50 * 1024 * 1024))) # 50MB
# --------------------------------------------------------------------------
# 权限校验依赖
# --------------------------------------------------------------------------
async def require_admin(
agent: Agent = Depends(get_current_agent),
) -> Agent:
"""管理后台权限校验:仅 role='admin' 可访问。"""
if agent.role != "admin":
raise AppException(1004, "无管理权限")
return agent
# --------------------------------------------------------------------------
# 辅助函数
# --------------------------------------------------------------------------
async def _get_welcome_config(db: AsyncSession) -> WelcomeConfigData:
"""从数据库获取欢迎配置。"""
stmt = select(SystemConfig).where(
SystemConfig.config_key == "welcome_config"
)
result = await db.execute(stmt)
config_row = result.scalars().first()
if config_row:
try:
data = json.loads(config_row.config_value)
return WelcomeConfigData(**data)
except (json.JSONDecodeError, TypeError):
pass
# 返回默认配置
return WelcomeConfigData()
async def _save_welcome_config(
db: AsyncSession,
config_data: WelcomeConfigData,
) -> None:
"""保存欢迎配置到数据库。"""
stmt = select(SystemConfig).where(
SystemConfig.config_key == "welcome_config"
)
result = await db.execute(stmt)
config_row = result.scalars().first()
config_value = config_data.model_dump_json()
if config_row:
config_row.config_value = config_value
config_row.updated_at = datetime.now()
else:
new_config = SystemConfig(
id=str(uuid.uuid4()),
config_key="welcome_config",
config_value=config_value,
description="员工端H5欢迎与引导配置",
)
db.add(new_config)
await db.flush()
# --------------------------------------------------------------------------
# API 端点
# --------------------------------------------------------------------------
# ---------- GET /api/v1/admin/welcome-config ----------
@router.get("")
async def get_welcome_config(
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> dict:
"""获取欢迎与引导配置。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含全部配置
"""
config = await _get_welcome_config(db)
return success_response(data=config.model_dump())
# ---------- PUT /api/v1/admin/welcome-config ----------
@router.put("")
async def update_welcome_config(
body: UpdateWelcomeConfigRequest,
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> dict:
"""更新欢迎与引导配置。
Args:
body: 更新请求体
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的配置
"""
# 获取当前配置
current_config = await _get_welcome_config(db)
# 合并更新
if body.welcome_page is not None:
current_config.welcome_page = body.welcome_page
if body.welcome_video is not None:
current_config.welcome_video = body.welcome_video
if body.tutorial is not None:
current_config.tutorial = body.tutorial
if body.theme is not None:
current_config.theme = body.theme
# 保存配置
await _save_welcome_config(db, current_config)
await db.commit()
logger.info(f"管理员更新欢迎配置: admin={admin.user_id}")
return success_response(data=current_config.model_dump())
# ---------- POST /api/v1/admin/welcome-video ----------
@router.post("/video")
async def upload_welcome_video(
file: UploadFile = File(..., description="引导视频文件"),
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> dict:
"""上传引导视频。
Args:
file: 视频文件
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含视频信息
"""
# 校验文件类型
allowed_extensions = {"mp4"}
ext = file.filename.split(".")[-1].lower() if file.filename else ""
if ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"不支持的视频格式: .{ext},仅支持 .mp4",
)
# 校验文件大小
content = await file.read()
file_size = len(content)
if file_size > MAX_VIDEO_SIZE:
raise HTTPException(
status_code=400,
detail=f"视频大小 {file_size / 1024 / 1024:.1f}MB 超过限制(50MB",
)
# 生成存储路径(使用与普通文件上传相同的目录结构)
now = datetime.now()
file_id = uuid.uuid4().hex[:12]
filename = f"{file_id}.{ext}"
# 按日期分目录:uploads/videos/YYYY/MM/DD/
video_dir = VIDEO_UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
video_dir.mkdir(parents=True, exist_ok=True)
storage_path = video_dir / filename
# 保存文件
try:
with open(storage_path, "wb") as f:
f.write(content)
except OSError as e:
logger.error(f"视频保存失败: {e}")
raise HTTPException(status_code=500, detail="视频保存失败,请重试")
# 构建访问 URL(与普通文件上传格式一致)
video_url = f"/api/media/videos/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
# 更新数据库配置
config = await _get_welcome_config(db)
config.welcome_video = WelcomeVideoConfig(
url=video_url,
filename=file.filename or filename,
uploaded_at=now.isoformat(),
)
await _save_welcome_config(db, config)
await db.commit()
logger.info(f"管理员上传引导视频: {file.filename}, admin={admin.user_id}")
return success_response(data={
"url": video_url,
"filename": file.filename or filename,
"uploaded_at": now.isoformat(),
})
# ---------- DELETE /api/admin/welcome-video ----------
@router.delete("/video")
async def delete_welcome_video(
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> dict:
"""删除引导视频。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 获取当前配置
config = await _get_welcome_config(db)
if not config.welcome_video:
raise AppException(4001, "暂无引导视频")
# 删除文件
video_url = config.welcome_video.url
if video_url and video_url.startswith("/api/media/videos/"):
# 解析URL: /api/media/videos/YYYY/MM/DD/filename
parts = video_url.split("/")
if len(parts) >= 8:
year = parts[-4]
month = parts[-3]
day = parts[-2]
filename = parts[-1]
file_path = VIDEO_UPLOAD_DIR / year / month / day / filename
try:
if file_path.exists():
file_path.unlink()
except OSError as e:
logger.warning(f"删除视频文件失败: {e}")
# 清除配置
config.welcome_video = None
await _save_welcome_config(db, config)
await db.commit()
logger.info(f"管理员删除引导视频: admin={admin.user_id}")
return success_response(data=None, message="视频已删除")
# ---------- GET /api/media/videos/{year}/{month}/{day}/{filename} ----------
@router.get("/media/videos/{year}/{month}/{day}/{filename}")
async def serve_welcome_video(
year: str,
month: str,
day: str,
filename: str,
):
"""提供引导视频的静态访问。
Args:
year: 年份
month: 月份
day: 日期
filename: 文件名
Returns:
FileResponse: 视频文件响应
"""
file_path = VIDEO_UPLOAD_DIR / year / month / day / filename
# 安全检查:防止路径遍历攻击
try:
resolved = file_path.resolve()
upload_root = VIDEO_UPLOAD_DIR.resolve()
if not str(resolved).startswith(str(upload_root)):
raise HTTPException(status_code=403, detail="禁止访问")
except (ValueError, OSError):
raise HTTPException(status_code=403, detail="禁止访问")
if not file_path.exists():
raise HTTPException(status_code=404, detail="视频文件不存在")
# 根据扩展名设置 Content-Type
content_type = "video/mp4"
return FileResponse(file_path, media_type=content_type)
# --------------------------------------------------------------------------
# 注册路由到主路由
# --------------------------------------------------------------------------
def register_welcome_routes(main_router: APIRouter) -> None:
"""将欢迎配置路由注册到主路由。"""
main_router.include_router(router)