Files
012-kaopeilian/backend/app/api/v1/admin.py
yuliang_guo 64f5d567fa
Some checks failed
continuous-integration/drone/push Build is failing
feat: 实现 KPL 系统功能改进计划
1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
2026-01-30 14:22:35 +08:00

543 lines
18 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.
"""
管理员相关API路由
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.deps import get_current_active_user as get_current_user, get_db
from app.models.user import User
from app.models.course import Course, CourseStatus
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.schemas.base import ResponseModel
router = APIRouter(prefix="/admin")
@router.get("/dashboard/stats")
async def get_dashboard_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResponseModel:
"""
获取管理员仪表盘统计数据
需要管理员权限
"""
# 权限检查
if current_user.role != "admin":
return ResponseModel(
code=403,
message="权限不足,需要管理员权限"
)
# 用户统计
total_users = await db.scalar(select(func.count(User.id)))
# 计算最近30天的新增用户
thirty_days_ago = datetime.now() - timedelta(days=30)
new_users_count = await db.scalar(
select(func.count(User.id))
.where(User.created_at >= thirty_days_ago)
)
# 计算增长率假设上个月也是30天
sixty_days_ago = datetime.now() - timedelta(days=60)
last_month_users = await db.scalar(
select(func.count(User.id))
.where(User.created_at >= sixty_days_ago)
.where(User.created_at < thirty_days_ago)
)
growth_rate = 0.0
if last_month_users > 0:
growth_rate = ((new_users_count - last_month_users) / last_month_users) * 100
# 课程统计
total_courses = await db.scalar(
select(func.count(Course.id))
.where(Course.status == CourseStatus.PUBLISHED)
)
# 根据用户课程进度表计算完成的课程学习记录数
completed_courses = await db.scalar(
select(func.count(UserCourseProgress.id))
.where(UserCourseProgress.status == ProgressStatus.COMPLETED.value)
) or 0
# 考试统计(如果有考试表的话)
total_exams = 0
avg_score = 0.0
pass_rate = "0%"
# 学习时长统计 - 从用户课程进度表获取
total_study_seconds = await db.scalar(
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
) or 0
total_learning_hours = round(total_study_seconds / 3600)
# 平均学习时长(每个活跃用户)
active_learners = await db.scalar(
select(func.count(func.distinct(UserCourseProgress.user_id)))
.where(UserCourseProgress.status != ProgressStatus.NOT_STARTED.value)
) or 0
avg_learning_hours = round(total_study_seconds / 3600 / max(active_learners, 1), 1)
# 活跃率 = 有学习记录的用户 / 总用户
active_rate = f"{round(active_learners / max(total_users, 1) * 100)}%"
# 构建响应数据
stats = {
"users": {
"total": total_users,
"growth": new_users_count,
"growthRate": f"{growth_rate:.1f}%"
},
"courses": {
"total": total_courses,
"completed": completed_courses,
"completionRate": f"{(completed_courses / total_courses * 100) if total_courses > 0 else 0:.1f}%"
},
"exams": {
"total": total_exams,
"avgScore": avg_score,
"passRate": pass_rate
},
"learning": {
"totalHours": total_learning_hours,
"avgHours": avg_learning_hours,
"activeRate": active_rate
}
}
return ResponseModel(
code=200,
message="获取仪表盘统计数据成功",
data=stats
)
@router.get("/dashboard/user-growth")
async def get_user_growth_data(
days: int = Query(30, description="统计天数", ge=7, le=90),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResponseModel:
"""
获取用户增长数据
Args:
days: 统计天数默认30天
需要管理员权限
"""
# 权限检查
if current_user.role != "admin":
return ResponseModel(
code=403,
message="权限不足,需要管理员权限"
)
# 准备日期列表
dates = []
new_users = []
active_users = []
end_date = datetime.now().date()
for i in range(days):
current_date = end_date - timedelta(days=days-1-i)
dates.append(current_date.strftime("%Y-%m-%d"))
# 统计当天新增用户
next_date = current_date + timedelta(days=1)
new_count = await db.scalar(
select(func.count(User.id))
.where(func.date(User.created_at) == current_date)
)
new_users.append(new_count or 0)
# 统计当天活跃用户(有登录记录)
active_count = await db.scalar(
select(func.count(User.id))
.where(func.date(User.last_login_at) == current_date)
)
active_users.append(active_count or 0)
return ResponseModel(
code=200,
message="获取用户增长数据成功",
data={
"dates": dates,
"newUsers": new_users,
"activeUsers": active_users
}
)
@router.get("/dashboard/course-completion")
async def get_course_completion_data(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResponseModel:
"""
获取课程完成率数据
需要管理员权限
"""
# 权限检查
if current_user.role != "admin":
return ResponseModel(
code=403,
message="权限不足,需要管理员权限"
)
# 获取所有已发布的课程
courses_result = await db.execute(
select(Course.name, Course.id)
.where(Course.status == CourseStatus.PUBLISHED)
.order_by(Course.sort_order, Course.id)
.limit(10) # 限制显示前10个课程
)
courses = courses_result.all()
course_names = []
completion_rates = []
for course_name, course_id in courses:
course_names.append(course_name)
# 根据用户课程进度表计算完成率
# 统计该课程的完成用户数和总学习用户数
stats_result = await db.execute(
select(
func.count(UserCourseProgress.id).label('total'),
func.sum(
func.case(
(UserCourseProgress.status == ProgressStatus.COMPLETED.value, 1),
else_=0
)
).label('completed')
).where(UserCourseProgress.course_id == course_id)
)
stats = stats_result.one()
total_learners = stats.total or 0
completed_learners = stats.completed or 0
# 计算完成率
if total_learners > 0:
completion_rate = round(completed_learners / total_learners * 100)
else:
completion_rate = 0
completion_rates.append(completion_rate)
return ResponseModel(
code=200,
message="获取课程完成率数据成功",
data={
"courses": course_names,
"completionRates": completion_rates
}
)
# ===== 岗位管理(最小可用 stub 版本)=====
def _ensure_admin(user: User) -> Optional[ResponseModel]:
if user.role != "admin":
return ResponseModel(code=403, message="权限不足,需要管理员权限")
return None
# 注意positions相关路由已移至positions.py
# _sample_positions函数和所有positions路由已删除避免与positions.py冲突
# ===== 用户批量操作 =====
from pydantic import BaseModel
from app.models.position_member import PositionMember
class BatchUserOperation(BaseModel):
"""批量用户操作请求模型"""
ids: List[int]
action: str # delete, activate, deactivate, change_role, assign_position, assign_team
value: Optional[Any] = None # 角色值、岗位ID、团队ID等
@router.post("/users/batch", response_model=ResponseModel)
async def batch_user_operation(
operation: BatchUserOperation,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResponseModel:
"""
批量用户操作
支持的操作类型:
- delete: 批量删除用户(软删除)
- activate: 批量启用用户
- deactivate: 批量禁用用户
- change_role: 批量修改角色(需要 value 参数)
- assign_position: 批量分配岗位(需要 value 参数为岗位ID
- assign_team: 批量分配团队(需要 value 参数为团队ID
权限:需要管理员权限
"""
# 权限检查
if current_user.role != "admin":
return ResponseModel(
code=403,
message="权限不足,需要管理员权限"
)
if not operation.ids:
return ResponseModel(
code=400,
message="请选择要操作的用户"
)
# 不能操作自己
if current_user.id in operation.ids:
return ResponseModel(
code=400,
message="不能对自己执行批量操作"
)
# 获取要操作的用户
result = await db.execute(
select(User).where(User.id.in_(operation.ids), User.is_deleted == False)
)
users = result.scalars().all()
if not users:
return ResponseModel(
code=404,
message="未找到要操作的用户"
)
success_count = 0
failed_count = 0
errors = []
try:
if operation.action == "delete":
# 批量软删除
for user in users:
try:
user.is_deleted = True
user.deleted_at = datetime.now()
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"删除用户 {user.username} 失败: {str(e)}")
await db.commit()
elif operation.action == "activate":
# 批量启用
for user in users:
try:
user.is_active = True
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"启用用户 {user.username} 失败: {str(e)}")
await db.commit()
elif operation.action == "deactivate":
# 批量禁用
for user in users:
try:
user.is_active = False
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"禁用用户 {user.username} 失败: {str(e)}")
await db.commit()
elif operation.action == "change_role":
# 批量修改角色
if not operation.value:
return ResponseModel(
code=400,
message="请指定要修改的角色"
)
valid_roles = ["trainee", "manager", "admin"]
if operation.value not in valid_roles:
return ResponseModel(
code=400,
message=f"无效的角色,可选值: {', '.join(valid_roles)}"
)
for user in users:
try:
user.role = operation.value
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"修改用户 {user.username} 角色失败: {str(e)}")
await db.commit()
elif operation.action == "assign_position":
# 批量分配岗位
if not operation.value:
return ResponseModel(
code=400,
message="请指定要分配的岗位ID"
)
position_id = int(operation.value)
# 获取岗位信息用于通知
from app.models.position import Position
position_result = await db.execute(
select(Position).where(Position.id == position_id)
)
position = position_result.scalar_one_or_none()
position_name = position.name if position else "未知岗位"
# 记录新分配成功的用户ID用于发送通知
newly_assigned_user_ids = []
for user in users:
try:
# 检查是否已有该岗位
existing = await db.execute(
select(PositionMember).where(
PositionMember.user_id == user.id,
PositionMember.position_id == position_id,
PositionMember.is_deleted == False
)
)
if existing.scalar_one_or_none():
# 已有该岗位,跳过
success_count += 1
continue
# 添加岗位关联PositionMember模型没有created_by字段
member = PositionMember(
position_id=position_id,
user_id=user.id,
joined_at=datetime.now()
)
db.add(member)
newly_assigned_user_ids.append(user.id)
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"为用户 {user.username} 分配岗位失败: {str(e)}")
await db.commit()
# 发送岗位分配通知给新分配的用户
if newly_assigned_user_ids:
try:
from app.services.notification_service import notification_service
from app.schemas.notification import NotificationBatchCreate, NotificationType
notification_batch = NotificationBatchCreate(
user_ids=newly_assigned_user_ids,
title="岗位分配通知",
content=f"您已被分配到「{position_name}」岗位,请查看相关培训课程。",
type=NotificationType.POSITION_ASSIGN,
related_id=position_id,
related_type="position",
sender_id=current_user.id
)
await notification_service.batch_create_notifications(
db=db,
batch_in=notification_batch
)
except Exception as e:
# 通知发送失败不影响岗位分配结果
import logging
logging.getLogger(__name__).error(f"发送岗位分配通知失败: {str(e)}")
elif operation.action == "assign_team":
# 批量分配团队
if not operation.value:
return ResponseModel(
code=400,
message="请指定要分配的团队ID"
)
from app.models.user import user_teams
team_id = int(operation.value)
for user in users:
try:
# 检查是否已在该团队
existing = await db.execute(
select(user_teams).where(
user_teams.c.user_id == user.id,
user_teams.c.team_id == team_id
)
)
if existing.first():
# 已在该团队,跳过
success_count += 1
continue
# 添加团队关联
await db.execute(
user_teams.insert().values(
user_id=user.id,
team_id=team_id,
role="member",
joined_at=datetime.now()
)
)
success_count += 1
except Exception as e:
failed_count += 1
errors.append(f"为用户 {user.username} 分配团队失败: {str(e)}")
await db.commit()
else:
return ResponseModel(
code=400,
message=f"不支持的操作类型: {operation.action}"
)
# 返回结果
action_names = {
"delete": "删除",
"activate": "启用",
"deactivate": "禁用",
"change_role": "修改角色",
"assign_position": "分配岗位",
"assign_team": "分配团队"
}
action_name = action_names.get(operation.action, operation.action)
return ResponseModel(
code=200,
message=f"批量{action_name}完成:成功 {success_count} 个,失败 {failed_count}",
data={
"success_count": success_count,
"failed_count": failed_count,
"errors": errors
}
)
except Exception as e:
await db.rollback()
return ResponseModel(
code=500,
message=f"批量操作失败: {str(e)}"
)