feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
497
backend/app/api/v1/03-Agent-Course/api_contract.yaml
Normal file
497
backend/app/api/v1/03-Agent-Course/api_contract.yaml
Normal file
@@ -0,0 +1,497 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: 课程管理模块API契约
|
||||
description: 定义课程管理模块对外提供的所有API接口
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8000/api/v1
|
||||
description: 本地开发服务器
|
||||
|
||||
paths:
|
||||
/courses:
|
||||
get:
|
||||
summary: 获取课程列表
|
||||
description: 支持分页和多条件筛选
|
||||
operationId: getCourses
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: size
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [draft, published, archived]
|
||||
- name: category
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [technology, management, business, general]
|
||||
- name: is_featured
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: keyword
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: 成功获取课程列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoursePageResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
|
||||
post:
|
||||
summary: 创建课程
|
||||
description: 创建新课程(需要管理员权限)
|
||||
operationId: createCourse
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CourseCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: 成功创建课程
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CourseResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"409":
|
||||
$ref: "#/components/responses/ConflictError"
|
||||
|
||||
/courses/{courseId}:
|
||||
get:
|
||||
summary: 获取课程详情
|
||||
operationId: getCourse
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: courseId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: 成功获取课程详情
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CourseResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
|
||||
put:
|
||||
summary: 更新课程
|
||||
description: 更新课程信息(需要管理员权限)
|
||||
operationId: updateCourse
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: courseId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CourseUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: 成功更新课程
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CourseResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
|
||||
delete:
|
||||
summary: 删除课程
|
||||
description: 软删除课程(需要管理员权限)
|
||||
operationId: deleteCourse
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: courseId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: 成功删除课程
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
|
||||
/courses/{courseId}/knowledge-points:
|
||||
get:
|
||||
summary: 获取课程知识点列表
|
||||
operationId: getCourseKnowledgePoints
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: courseId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: parent_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
responses:
|
||||
"200":
|
||||
description: 成功获取知识点列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/KnowledgePointListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
ResponseBase:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
default: 200
|
||||
message:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
CourseBase:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
description:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
enum: [technology, management, business, general]
|
||||
default: general
|
||||
cover_image:
|
||||
type: string
|
||||
maxLength: 500
|
||||
duration_hours:
|
||||
type: number
|
||||
format: float
|
||||
minimum: 0
|
||||
difficulty_level:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
default: 0
|
||||
is_featured:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
CourseCreate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CourseBase"
|
||||
- type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, published, archived]
|
||||
default: draft
|
||||
|
||||
CourseUpdate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CourseBase"
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, published, archived]
|
||||
|
||||
Course:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CourseBase"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, published, archived]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
published_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
publisher_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
created_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
updated_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
CourseResponse:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/Course"
|
||||
|
||||
CoursePageResponse:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
- total
|
||||
- page
|
||||
- size
|
||||
- pages
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Course"
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
size:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
|
||||
KnowledgePoint:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- course_id
|
||||
- name
|
||||
- level
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
course_id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
maxLength: 200
|
||||
description:
|
||||
type: string
|
||||
parent_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
level:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
sort_order:
|
||||
type: integer
|
||||
weight:
|
||||
type: number
|
||||
format: float
|
||||
is_required:
|
||||
type: boolean
|
||||
estimated_hours:
|
||||
type: number
|
||||
format: float
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
KnowledgePointListResponse:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/KnowledgePoint"
|
||||
|
||||
DeleteResponse:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: boolean
|
||||
|
||||
ErrorDetail:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
error_code:
|
||||
type: string
|
||||
field:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
|
||||
responses:
|
||||
BadRequestError:
|
||||
description: 请求参数错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
example: 400
|
||||
detail:
|
||||
$ref: "#/components/schemas/ErrorDetail"
|
||||
|
||||
UnauthorizedError:
|
||||
description: 未认证
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
example: 401
|
||||
detail:
|
||||
$ref: "#/components/schemas/ErrorDetail"
|
||||
|
||||
ForbiddenError:
|
||||
description: 权限不足
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
example: 403
|
||||
detail:
|
||||
$ref: "#/components/schemas/ErrorDetail"
|
||||
|
||||
NotFoundError:
|
||||
description: 资源不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
example: 404
|
||||
detail:
|
||||
$ref: "#/components/schemas/ErrorDetail"
|
||||
|
||||
ConflictError:
|
||||
description: 资源冲突
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ResponseBase"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
example: 409
|
||||
detail:
|
||||
$ref: "#/components/schemas/ErrorDetail"
|
||||
105
backend/app/api/v1/__init__.py
Normal file
105
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
API v1 版本模块
|
||||
整合所有 v1 版本的路由
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# 先只导入必要的路由
|
||||
from .coze_gateway import router as coze_router
|
||||
|
||||
# 创建 v1 版本的主路由
|
||||
api_router = APIRouter()
|
||||
|
||||
# 包含各个子路由
|
||||
api_router.include_router(coze_router, tags=["coze"])
|
||||
|
||||
# TODO: 逐步添加其他路由
|
||||
from .auth import router as auth_router
|
||||
from .courses import router as courses_router
|
||||
from .users import router as users_router
|
||||
from .training import router as training_router
|
||||
from .admin import router as admin_router
|
||||
from .positions import router as positions_router
|
||||
from .upload import router as upload_router
|
||||
from .teams import router as teams_router
|
||||
from .knowledge_analysis import router as knowledge_analysis_router
|
||||
from .system import router as system_router
|
||||
from .sql_executor import router as sql_executor_router
|
||||
|
||||
from .exam import router as exam_router
|
||||
from .practice import router as practice_router
|
||||
from .course_chat import router as course_chat_router
|
||||
from .broadcast import router as broadcast_router
|
||||
from .preview import router as preview_router
|
||||
from .yanji import router as yanji_router
|
||||
from .ability import router as ability_router
|
||||
from .statistics import router as statistics_router
|
||||
from .team_dashboard import router as team_dashboard_router
|
||||
from .team_management import router as team_management_router
|
||||
# Manager 模块路由
|
||||
from .manager import student_scores_router, student_practice_router
|
||||
from .system_logs import router as system_logs_router
|
||||
from .tasks import router as tasks_router
|
||||
from .endpoints.employee_sync import router as employee_sync_router
|
||||
from .notifications import router as notifications_router
|
||||
from .scrm import router as scrm_router
|
||||
# 管理后台路由
|
||||
from .admin_portal import router as admin_portal_router
|
||||
|
||||
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||
# courses_router 已在内部定义了 prefix="/courses",此处不再额外添加前缀
|
||||
api_router.include_router(courses_router, tags=["courses"])
|
||||
api_router.include_router(users_router, prefix="/users", tags=["users"])
|
||||
# training_router 已在内部定义了 prefix="/training",此处不再额外添加前缀
|
||||
api_router.include_router(training_router, tags=["training"])
|
||||
# admin_router 已在内部定义了 prefix="/admin",此处不再额外添加前缀
|
||||
api_router.include_router(admin_router, tags=["admin"])
|
||||
api_router.include_router(positions_router, tags=["positions"])
|
||||
# upload_router 已在内部定义了 prefix="/upload",此处不再额外添加前缀
|
||||
api_router.include_router(upload_router, tags=["upload"])
|
||||
api_router.include_router(teams_router, tags=["teams"])
|
||||
# knowledge_analysis_router 不需要额外前缀,路径已在路由中定义
|
||||
api_router.include_router(knowledge_analysis_router, tags=["knowledge-analysis"])
|
||||
# system_router 已在内部定义了 prefix="/system",此处不再额外添加前缀
|
||||
api_router.include_router(system_router, tags=["system"])
|
||||
# sql_executor_router SQL 执行器
|
||||
api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executor"])
|
||||
# exam_router 已在内部定义了 prefix="/exams",此处不再额外添加前缀
|
||||
api_router.include_router(exam_router, tags=["exams"])
|
||||
# practice_router 陪练功能路由
|
||||
api_router.include_router(practice_router, prefix="/practice", tags=["practice"])
|
||||
# course_chat_router 与课程对话路由
|
||||
api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"])
|
||||
# broadcast_router 播课功能路由(不添加prefix,路径在router内部定义)
|
||||
api_router.include_router(broadcast_router, tags=["broadcast"])
|
||||
# preview_router 文件预览路由
|
||||
api_router.include_router(preview_router, prefix="/preview", tags=["preview"])
|
||||
# yanji_router 言迹智能工牌路由
|
||||
api_router.include_router(yanji_router, prefix="/yanji", tags=["yanji"])
|
||||
# ability_router 能力评估路由
|
||||
api_router.include_router(ability_router, prefix="/ability", tags=["ability"])
|
||||
# statistics_router 统计分析路由(不添加prefix,路径在router内部定义)
|
||||
api_router.include_router(statistics_router, tags=["statistics"])
|
||||
# team_dashboard_router 团队看板路由(不添加prefix,路径在router内部定义为/team/dashboard)
|
||||
api_router.include_router(team_dashboard_router, tags=["team-dashboard"])
|
||||
# team_management_router 团队成员管理路由(不添加prefix,路径在router内部定义为/team/management)
|
||||
api_router.include_router(team_management_router, tags=["team-management"])
|
||||
# student_scores_router 学员考试成绩管理路由(不添加prefix,路径在router内部定义为/manager/student-scores)
|
||||
api_router.include_router(student_scores_router, tags=["manager-student-scores"])
|
||||
# student_practice_router 学员陪练记录管理路由(不添加prefix,路径在router内部定义为/manager/student-practice)
|
||||
api_router.include_router(student_practice_router, tags=["manager-student-practice"])
|
||||
# system_logs_router 系统日志路由(不添加prefix,路径在router内部定义为/admin/logs)
|
||||
api_router.include_router(system_logs_router, tags=["system-logs"])
|
||||
# tasks_router 任务管理路由(不添加prefix,路径在router内部定义为/manager/tasks)
|
||||
api_router.include_router(tasks_router, tags=["tasks"])
|
||||
# employee_sync_router 员工同步路由
|
||||
api_router.include_router(employee_sync_router, prefix="/employee-sync", tags=["employee-sync"])
|
||||
# notifications_router 站内消息通知路由(不添加prefix,路径在router内部定义为/notifications)
|
||||
api_router.include_router(notifications_router, tags=["notifications"])
|
||||
# scrm_router SCRM系统对接路由(prefix在router内部定义为/scrm)
|
||||
api_router.include_router(scrm_router, tags=["scrm"])
|
||||
# admin_portal_router SaaS超级管理后台路由(prefix在router内部定义为/admin)
|
||||
api_router.include_router(admin_portal_router, tags=["admin-portal"])
|
||||
|
||||
__all__ = ["api_router"]
|
||||
187
backend/app/api/v1/ability.py
Normal file
187
backend/app/api/v1/ability.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
能力评估API接口
|
||||
用于智能工牌数据分析、能力评估报告生成等
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List
|
||||
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.ability import AbilityAssessmentResponse, AbilityAssessmentHistory
|
||||
from app.services.yanji_service import YanjiService
|
||||
from app.services.ability_assessment_service import get_ability_assessment_service
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/analyze-yanji", response_model=ResponseModel)
|
||||
async def analyze_yanji_badge_data(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
分析智能工牌数据生成能力评估和课程推荐
|
||||
|
||||
使用 Python 原生 AI 服务实现。
|
||||
|
||||
功能说明:
|
||||
1. 从言迹智能工牌获取员工的最近10条录音记录
|
||||
2. 分析对话数据,进行能力评估(6个维度)
|
||||
3. 基于能力短板生成课程推荐(3-5门)
|
||||
4. 保存评估记录到数据库
|
||||
|
||||
要求:
|
||||
- 用户必须已绑定手机号(用于匹配言迹数据)
|
||||
|
||||
返回:
|
||||
- assessment_id: 评估记录ID
|
||||
- total_score: 综合评分(0-100)
|
||||
- dimensions: 能力维度列表(6个维度)
|
||||
- recommended_courses: 推荐课程列表(3-5门)
|
||||
- conversation_count: 分析的对话数量
|
||||
"""
|
||||
# 检查用户是否绑定手机号
|
||||
if not current_user.phone:
|
||||
logger.warning(f"用户未绑定手机号: user_id={current_user.id}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="用户未绑定手机号,无法匹配言迹数据"
|
||||
)
|
||||
|
||||
# 获取服务实例
|
||||
yanji_service = YanjiService()
|
||||
assessment_service = get_ability_assessment_service()
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"开始分析智能工牌数据: user_id={current_user.id}, "
|
||||
f"phone={current_user.phone}"
|
||||
)
|
||||
|
||||
# 调用能力评估服务(使用 Python 原生实现)
|
||||
result = await assessment_service.analyze_yanji_conversations(
|
||||
user_id=current_user.id,
|
||||
phone=current_user.phone,
|
||||
db=db,
|
||||
yanji_service=yanji_service,
|
||||
engine="v2" # 固定使用 V2
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"智能工牌数据分析完成: user_id={current_user.id}, "
|
||||
f"assessment_id={result['assessment_id']}, "
|
||||
f"total_score={result['total_score']}"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="智能工牌数据分析完成",
|
||||
data=result
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# 业务逻辑错误(如未找到录音记录)
|
||||
logger.warning(f"智能工牌数据分析失败: {e}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
# 系统错误
|
||||
logger.error(f"分析智能工牌数据失败: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"分析失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=ResponseModel)
|
||||
async def get_assessment_history(
|
||||
limit: int = Query(default=10, ge=1, le=50, description="返回记录数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取用户的能力评估历史记录
|
||||
|
||||
参数:
|
||||
- limit: 返回记录数量(默认10,最大50)
|
||||
|
||||
返回:
|
||||
- 评估历史记录列表
|
||||
"""
|
||||
assessment_service = get_ability_assessment_service()
|
||||
|
||||
try:
|
||||
history = await assessment_service.get_user_assessment_history(
|
||||
user_id=current_user.id,
|
||||
db=db,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message=f"获取评估历史成功,共{len(history)}条",
|
||||
data={"history": history, "total": len(history)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取评估历史失败: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取评估历史失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{assessment_id}", response_model=ResponseModel)
|
||||
async def get_assessment_detail(
|
||||
assessment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取单个评估记录的详细信息
|
||||
|
||||
参数:
|
||||
- assessment_id: 评估记录ID
|
||||
|
||||
返回:
|
||||
- 评估详细信息
|
||||
"""
|
||||
assessment_service = get_ability_assessment_service()
|
||||
|
||||
try:
|
||||
detail = await assessment_service.get_assessment_detail(
|
||||
assessment_id=assessment_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
# 权限检查:只能查看自己的评估记录
|
||||
if detail['user_id'] != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="无权访问该评估记录"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取评估详情成功",
|
||||
data=detail
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取评估详情失败: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取评估详情失败: {str(e)}"
|
||||
)
|
||||
|
||||
509
backend/app/api/v1/admin.py
Normal file
509
backend/app/api/v1/admin.py
Normal file
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
管理员相关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.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)
|
||||
)
|
||||
|
||||
# TODO: 完成的课程数需要根据用户课程进度表计算
|
||||
completed_courses = 0 # 暂时设为0
|
||||
|
||||
# 考试统计(如果有考试表的话)
|
||||
total_exams = 0
|
||||
avg_score = 0.0
|
||||
pass_rate = "0%"
|
||||
|
||||
# 学习时长统计(如果有学习记录表的话)
|
||||
total_learning_hours = 0
|
||||
avg_learning_hours = 0.0
|
||||
active_rate = "0%"
|
||||
|
||||
# 构建响应数据
|
||||
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)
|
||||
|
||||
# TODO: 根据用户课程进度表计算完成率
|
||||
# 这里暂时生成模拟数据
|
||||
import random
|
||||
completion_rate = random.randint(60, 95)
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
158
backend/app/api/v1/admin_positions_backup.py
Normal file
158
backend/app/api/v1/admin_positions_backup.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# 此文件备份了admin.py中的positions相关路由代码
|
||||
# 这些路由已移至positions.py,为避免冲突,从admin.py中移除
|
||||
|
||||
@router.get("/positions")
|
||||
async def list_positions(
|
||||
keyword: Optional[str] = Query(None, description="关键词"),
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取岗位列表(stub 数据)
|
||||
|
||||
返回结构兼容前端:data.list/total/page/pageSize
|
||||
"""
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
try:
|
||||
items = _sample_positions()
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
items = [
|
||||
p for p in items if kw in (p.get("name", "") + p.get("description", "")).lower()
|
||||
]
|
||||
|
||||
total = len(items)
|
||||
start = (page - 1) * pageSize
|
||||
end = start + pageSize
|
||||
page_items = items[start:end]
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取岗位列表成功",
|
||||
data={
|
||||
"list": page_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
# 记录错误堆栈由全局异常中间件处理;此处返回统一结构
|
||||
return ResponseModel(code=500, message=f"服务器错误:{exc}")
|
||||
|
||||
|
||||
@router.get("/positions/tree")
|
||||
async def get_position_tree(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取岗位树(stub 数据)
|
||||
"""
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
try:
|
||||
items = _sample_positions()
|
||||
id_to_node: Dict[int, Dict[str, Any]] = {}
|
||||
for p in items:
|
||||
node = {**p, "children": []}
|
||||
id_to_node[p["id"]] = node
|
||||
|
||||
roots: List[Dict[str, Any]] = []
|
||||
for p in items:
|
||||
parent_id = p.get("parentId")
|
||||
if parent_id and parent_id in id_to_node:
|
||||
id_to_node[parent_id]["children"].append(id_to_node[p["id"]])
|
||||
else:
|
||||
roots.append(id_to_node[p["id"]])
|
||||
|
||||
return ResponseModel(code=200, message="获取岗位树成功", data=roots)
|
||||
except Exception as exc:
|
||||
return ResponseModel(code=500, message=f"服务器错误:{exc}")
|
||||
|
||||
|
||||
@router.get("/positions/{position_id}")
|
||||
async def get_position_detail(
|
||||
position_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
items = _sample_positions()
|
||||
for p in items:
|
||||
if p["id"] == position_id:
|
||||
return ResponseModel(code=200, message="获取岗位详情成功", data=p)
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
|
||||
@router.get("/positions/{position_id}/check-delete")
|
||||
async def check_position_delete(
|
||||
position_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
# stub:允许删除非根岗位
|
||||
deletable = position_id != 1
|
||||
reason = "根岗位不允许删除" if not deletable else ""
|
||||
return ResponseModel(code=200, message="检查成功", data={"deletable": deletable, "reason": reason})
|
||||
|
||||
|
||||
@router.post("/positions")
|
||||
async def create_position(
|
||||
payload: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
# stub:直接回显并附带一个伪ID
|
||||
payload = dict(payload)
|
||||
payload.setdefault("id", 999)
|
||||
payload.setdefault("createTime", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
return ResponseModel(code=200, message="创建岗位成功", data=payload)
|
||||
|
||||
|
||||
@router.put("/positions/{position_id}")
|
||||
async def update_position(
|
||||
position_id: int,
|
||||
payload: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
# stub:直接回显
|
||||
updated = {"id": position_id, **payload}
|
||||
return ResponseModel(code=200, message="更新岗位成功", data=updated)
|
||||
|
||||
|
||||
@router.delete("/positions/{position_id}")
|
||||
async def delete_position(
|
||||
position_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
not_admin = _ensure_admin(current_user)
|
||||
if not_admin:
|
||||
return not_admin
|
||||
|
||||
# stub:直接返回成功
|
||||
return ResponseModel(code=200, message="删除岗位成功", data={"id": position_id})
|
||||
156
backend/app/api/v1/auth.py
Normal file
156
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
认证 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.user import User as UserSchema
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.system_log_service import system_log_service
|
||||
from app.schemas.system_log import SystemLogCreate
|
||||
from app.core.exceptions import UnauthorizedError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=ResponseModel)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
用户登录
|
||||
|
||||
支持使用用户名、邮箱或手机号登录
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
try:
|
||||
user, token = await auth_service.login(
|
||||
username=login_data.username,
|
||||
password=login_data.password,
|
||||
)
|
||||
|
||||
# 记录登录成功日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="security",
|
||||
message=f"用户 {user.username} 登录成功",
|
||||
user_id=user.id,
|
||||
user=user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
message="登录成功",
|
||||
data={
|
||||
"user": UserSchema.model_validate(user).model_dump(),
|
||||
"token": token.model_dump(),
|
||||
},
|
||||
)
|
||||
except UnauthorizedError as e:
|
||||
# 记录登录失败日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="WARNING",
|
||||
type="security",
|
||||
message=f"用户 {login_data.username} 登录失败:密码错误",
|
||||
user=login_data.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
# 不返回 401,统一返回 HTTP 200 + 业务失败码,便于前端友好提示
|
||||
logger.warning("login_failed_wrong_credentials", username=login_data.username)
|
||||
return ResponseModel(
|
||||
code=400,
|
||||
message=str(e) or "用户名或密码错误",
|
||||
data=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("login_failed_unexpected", error=str(e))
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message="登录失败,请稍后重试",
|
||||
data=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=ResponseModel)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
刷新访问令牌
|
||||
|
||||
使用刷新令牌获取新的访问令牌
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
token = await auth_service.refresh_token(refresh_data.refresh_token)
|
||||
|
||||
return ResponseModel(message="令牌刷新成功", data=token.model_dump())
|
||||
|
||||
|
||||
@router.post("/logout", response_model=ResponseModel)
|
||||
async def logout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
用户登出
|
||||
|
||||
注意:客户端需要删除本地存储的令牌
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
await auth_service.logout(current_user.id)
|
||||
|
||||
# 记录登出日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="security",
|
||||
message=f"用户 {current_user.username} 登出",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/auth/logout",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(message="登出成功")
|
||||
|
||||
|
||||
@router.get("/verify", response_model=ResponseModel)
|
||||
async def verify_token(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
验证令牌
|
||||
|
||||
用于检查当前令牌是否有效
|
||||
"""
|
||||
return ResponseModel(
|
||||
message="令牌有效",
|
||||
data={
|
||||
"user": UserSchema.model_validate(current_user).model_dump(),
|
||||
},
|
||||
)
|
||||
145
backend/app/api/v1/broadcast.py
Normal file
145
backend/app/api/v1/broadcast.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
播课功能 API 接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user, require_admin_or_manager
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.models.course import Course
|
||||
from app.models.user import User
|
||||
from app.services.coze_broadcast_service import broadcast_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schema 定义
|
||||
class GenerateBroadcastResponse(BaseModel):
|
||||
"""生成播课响应"""
|
||||
message: str = Field(..., description="提示信息")
|
||||
|
||||
|
||||
class BroadcastInfo(BaseModel):
|
||||
"""播课信息"""
|
||||
has_broadcast: bool = Field(..., description="是否有播课")
|
||||
mp3_url: Optional[str] = Field(None, description="播课音频URL")
|
||||
generated_at: Optional[datetime] = Field(None, description="生成时间")
|
||||
|
||||
|
||||
@router.post("/courses/{course_id}/generate-broadcast", response_model=ResponseModel[GenerateBroadcastResponse])
|
||||
async def generate_broadcast(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""
|
||||
触发播课音频生成(立即返回,Coze工作流会直接写数据库)
|
||||
|
||||
权限:manager、admin
|
||||
|
||||
Args:
|
||||
course_id: 课程ID
|
||||
db: 数据库会话
|
||||
current_user: 当前用户
|
||||
|
||||
Returns:
|
||||
启动提示信息
|
||||
|
||||
Raises:
|
||||
HTTPException 404: 课程不存在
|
||||
"""
|
||||
logger.info(
|
||||
f"请求生成播课",
|
||||
extra={"course_id": course_id, "user_id": current_user.id}
|
||||
)
|
||||
|
||||
# 查询课程
|
||||
result = await db.execute(
|
||||
select(Course)
|
||||
.where(Course.id == course_id)
|
||||
.where(Course.is_deleted == False)
|
||||
)
|
||||
course = result.scalar_one_or_none()
|
||||
|
||||
if not course:
|
||||
logger.warning(f"课程不存在", extra={"course_id": course_id})
|
||||
raise HTTPException(status_code=404, detail="课程不存在")
|
||||
|
||||
# 调用 Coze 工作流(不等待结果,工作流会直接写数据库)
|
||||
try:
|
||||
await broadcast_service.trigger_workflow(course_id)
|
||||
|
||||
logger.info(
|
||||
f"播课生成工作流已触发",
|
||||
extra={"course_id": course_id, "user_id": current_user.id}
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="播课生成已启动",
|
||||
data=GenerateBroadcastResponse(
|
||||
message="播课生成工作流已启动,生成完成后将自动更新"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"触发播课生成失败",
|
||||
extra={"course_id": course_id, "error": str(e)}
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"触发播课生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/courses/{course_id}/broadcast", response_model=ResponseModel[BroadcastInfo])
|
||||
async def get_broadcast_info(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取播课信息
|
||||
|
||||
权限:所有登录用户
|
||||
|
||||
Args:
|
||||
course_id: 课程ID
|
||||
db: 数据库会话
|
||||
current_user: 当前用户
|
||||
|
||||
Returns:
|
||||
播课信息
|
||||
|
||||
Raises:
|
||||
HTTPException 404: 课程不存在
|
||||
"""
|
||||
# 查询课程
|
||||
result = await db.execute(
|
||||
select(Course)
|
||||
.where(Course.id == course_id)
|
||||
.where(Course.is_deleted == False)
|
||||
)
|
||||
course = result.scalar_one_or_none()
|
||||
|
||||
if not course:
|
||||
raise HTTPException(status_code=404, detail="课程不存在")
|
||||
|
||||
# 构建播课信息
|
||||
has_broadcast = bool(course.broadcast_audio_url)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=BroadcastInfo(
|
||||
has_broadcast=has_broadcast,
|
||||
mp3_url=course.broadcast_audio_url if has_broadcast else None,
|
||||
generated_at=course.broadcast_generated_at if has_broadcast else None
|
||||
)
|
||||
)
|
||||
190
backend/app/api/v1/course_chat.py
Normal file
190
backend/app/api/v1/course_chat.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
与课程对话 API
|
||||
|
||||
使用 Python 原生 AI 服务实现
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.ai.course_chat_service import course_chat_service_v2
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseChatRequest(BaseModel):
|
||||
"""课程对话请求"""
|
||||
course_id: int = Field(..., description="课程ID")
|
||||
query: str = Field(..., description="用户问题")
|
||||
conversation_id: Optional[str] = Field(None, description="会话ID(续接对话时传入)")
|
||||
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
"""通用响应模型"""
|
||||
code: int = 200
|
||||
message: str = "success"
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
async def _chat_with_course(
|
||||
request: CourseChatRequest,
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""
|
||||
Python 原生实现的流式对话
|
||||
"""
|
||||
logger.info(
|
||||
f"用户 {current_user.username} 与课程 {request.course_id} 对话: "
|
||||
f"{request.query[:50]}..."
|
||||
)
|
||||
|
||||
async def generate_stream():
|
||||
"""生成 SSE 流"""
|
||||
try:
|
||||
async for event_type, data in course_chat_service_v2.chat_stream(
|
||||
db=db,
|
||||
course_id=request.course_id,
|
||||
query=request.query,
|
||||
user_id=current_user.id,
|
||||
conversation_id=request.conversation_id
|
||||
):
|
||||
if event_type == "conversation_started":
|
||||
yield f"data: {json.dumps({'event': 'conversation_started', 'conversation_id': data})}\n\n"
|
||||
logger.info(f"会话已创建: {data}")
|
||||
|
||||
elif event_type == "chunk":
|
||||
yield f"data: {json.dumps({'event': 'message_chunk', 'chunk': data})}\n\n"
|
||||
|
||||
elif event_type == "done":
|
||||
yield f"data: {json.dumps({'event': 'message_end', 'message': data})}\n\n"
|
||||
logger.info(f"对话完成,总长度: {len(data)}")
|
||||
|
||||
elif event_type == "error":
|
||||
yield f"data: {json.dumps({'event': 'error', 'message': data})}\n\n"
|
||||
logger.error(f"对话错误: {data}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"流式对话异常: {e}", exc_info=True)
|
||||
yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat_with_course(
|
||||
request: CourseChatRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
与课程对话(流式响应)
|
||||
|
||||
使用 Python 原生 AI 服务实现,支持多轮对话。
|
||||
"""
|
||||
return await _chat_with_course(request, current_user, db)
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def get_conversations(
|
||||
course_id: Optional[int] = None,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取会话列表
|
||||
|
||||
返回当前用户的历史会话列表
|
||||
"""
|
||||
try:
|
||||
conversations = await course_chat_service_v2.get_conversations(
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取会话列表成功",
|
||||
data={
|
||||
"conversations": conversations,
|
||||
"total": len(conversations)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话列表失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"获取会话列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def get_messages(
|
||||
conversation_id: str,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取历史消息
|
||||
|
||||
返回指定会话的历史消息
|
||||
"""
|
||||
try:
|
||||
messages = await course_chat_service_v2.get_messages(
|
||||
conversation_id=conversation_id,
|
||||
user_id=current_user.id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取历史消息成功",
|
||||
data={
|
||||
"messages": messages,
|
||||
"total": len(messages)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取历史消息失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"获取历史消息失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/engines")
|
||||
async def list_chat_engines():
|
||||
"""
|
||||
获取可用的对话引擎列表
|
||||
"""
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取对话引擎列表成功",
|
||||
data={
|
||||
"engines": [
|
||||
{
|
||||
"id": "native",
|
||||
"name": "Python 原生实现",
|
||||
"description": "使用本地 AI 服务(4sapi.com + OpenRouter),支持流式输出和多轮对话",
|
||||
"default": True
|
||||
}
|
||||
],
|
||||
"default_engine": "native"
|
||||
}
|
||||
)
|
||||
786
backend/app/api/v1/courses.py
Normal file
786
backend/app/api/v1/courses.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""
|
||||
课程管理API路由
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User
|
||||
from app.core.exceptions import NotFoundError, BadRequestError
|
||||
from app.core.logger import get_logger
|
||||
from app.services.system_log_service import system_log_service
|
||||
from app.schemas.system_log import SystemLogCreate
|
||||
from app.models.course import CourseStatus, CourseCategory
|
||||
from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse
|
||||
from app.schemas.course import (
|
||||
CourseCreate,
|
||||
CourseUpdate,
|
||||
CourseInDB,
|
||||
CourseList,
|
||||
CourseMaterialCreate,
|
||||
CourseMaterialInDB,
|
||||
KnowledgePointCreate,
|
||||
KnowledgePointUpdate,
|
||||
KnowledgePointInDB,
|
||||
GrowthPathCreate,
|
||||
GrowthPathInDB,
|
||||
CourseExamSettingsCreate,
|
||||
CourseExamSettingsUpdate,
|
||||
CourseExamSettingsInDB,
|
||||
CoursePositionAssignment,
|
||||
CoursePositionAssignmentInDB,
|
||||
)
|
||||
from app.services.course_service import (
|
||||
course_service,
|
||||
knowledge_point_service,
|
||||
growth_path_service,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/courses", tags=["courses"])
|
||||
|
||||
|
||||
@router.get("", response_model=ResponseModel[PaginatedResponse[CourseInDB]])
|
||||
async def get_courses(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
status: Optional[CourseStatus] = Query(None, description="课程状态"),
|
||||
category: Optional[CourseCategory] = Query(None, description="课程分类"),
|
||||
is_featured: Optional[bool] = Query(None, description="是否推荐"),
|
||||
keyword: Optional[str] = Query(None, description="搜索关键词"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程列表(支持分页和筛选)
|
||||
|
||||
- **page**: 页码
|
||||
- **size**: 每页数量
|
||||
- **status**: 课程状态筛选
|
||||
- **category**: 课程分类筛选
|
||||
- **is_featured**: 是否推荐筛选
|
||||
- **keyword**: 关键词搜索(搜索名称和描述)
|
||||
"""
|
||||
page_params = PaginationParams(page=page, page_size=size)
|
||||
filters = CourseList(
|
||||
status=status, category=category, is_featured=is_featured, keyword=keyword
|
||||
)
|
||||
|
||||
result = await course_service.get_course_list(
|
||||
db, page_params=page_params, filters=filters, user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=result, message="获取课程列表成功")
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=ResponseModel[CourseInDB], status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_course(
|
||||
course_in: CourseCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建课程(需要管理员权限)
|
||||
|
||||
- **name**: 课程名称
|
||||
- **description**: 课程描述
|
||||
- **category**: 课程分类
|
||||
- **status**: 课程状态(默认为草稿)
|
||||
- **cover_image**: 封面图片URL
|
||||
- **duration_hours**: 课程时长(小时)
|
||||
- **difficulty_level**: 难度等级(1-5)
|
||||
- **tags**: 标签列表
|
||||
- **is_featured**: 是否推荐
|
||||
"""
|
||||
course = await course_service.create_course(
|
||||
db, course_in=course_in, created_by=current_user.id
|
||||
)
|
||||
|
||||
# 记录课程创建日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"创建课程: {course.name}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/courses",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(data=course, message="创建课程成功")
|
||||
|
||||
|
||||
@router.get("/{course_id}", response_model=ResponseModel[CourseInDB])
|
||||
async def get_course(
|
||||
course_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程详情
|
||||
|
||||
- **course_id**: 课程ID
|
||||
"""
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
logger.info(f"查看课程详情 - course_id: {course_id}, user_id: {current_user.id}")
|
||||
|
||||
return ResponseModel(data=course, message="获取课程详情成功")
|
||||
|
||||
|
||||
@router.put("/{course_id}", response_model=ResponseModel[CourseInDB])
|
||||
async def update_course(
|
||||
course_id: int,
|
||||
course_in: CourseUpdate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新课程(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **course_in**: 更新的课程数据(所有字段都是可选的)
|
||||
"""
|
||||
course = await course_service.update_course(
|
||||
db, course_id=course_id, course_in=course_in, updated_by=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=course, message="更新课程成功")
|
||||
|
||||
|
||||
@router.delete("/{course_id}", response_model=ResponseModel[bool])
|
||||
async def delete_course(
|
||||
course_id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除课程(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
|
||||
说明:任意状态均可软删除(is_deleted=1),请谨慎操作
|
||||
"""
|
||||
# 先获取课程信息
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
course_name = course.name if course else f"ID:{course_id}"
|
||||
|
||||
success = await course_service.delete_course(
|
||||
db, course_id=course_id, deleted_by=current_user.id
|
||||
)
|
||||
|
||||
# 记录课程删除日志
|
||||
if success:
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"删除课程: {course_name}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path=f"/api/v1/courses/{course_id}",
|
||||
method="DELETE",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(data=success, message="删除课程成功" if success else "删除课程失败")
|
||||
|
||||
|
||||
# 课程资料相关API
|
||||
@router.post(
|
||||
"/{course_id}/materials",
|
||||
response_model=ResponseModel[CourseMaterialInDB],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_course_material(
|
||||
course_id: int,
|
||||
material_in: CourseMaterialCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
添加课程资料(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **name**: 资料名称
|
||||
- **description**: 资料描述
|
||||
- **file_url**: 文件URL
|
||||
- **file_type**: 文件类型(pdf, doc, docx, ppt, pptx, xls, xlsx, mp4, mp3, zip)
|
||||
- **file_size**: 文件大小(字节)
|
||||
|
||||
添加资料后会自动触发知识点分析
|
||||
"""
|
||||
material = await course_service.add_course_material(
|
||||
db, course_id=course_id, material_in=material_in, created_by=current_user.id
|
||||
)
|
||||
|
||||
# 获取课程信息用于知识点分析
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if course:
|
||||
# 异步触发知识点分析
|
||||
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||
background_tasks.add_task(
|
||||
_trigger_knowledge_analysis,
|
||||
db,
|
||||
course_id,
|
||||
material.id,
|
||||
material.file_url,
|
||||
course.name,
|
||||
current_user.id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"资料添加成功,已触发知识点分析 - course_id: {course_id}, material_id: {material.id}, user_id: {current_user.id}"
|
||||
)
|
||||
|
||||
return ResponseModel(data=material, message="添加课程资料成功")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{course_id}/materials",
|
||||
response_model=ResponseModel[List[CourseMaterialInDB]],
|
||||
)
|
||||
async def list_course_materials(
|
||||
course_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程资料列表
|
||||
|
||||
- **course_id**: 课程ID
|
||||
"""
|
||||
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||
return ResponseModel(data=materials, message="获取课程资料列表成功")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{course_id}/materials/{material_id}",
|
||||
response_model=ResponseModel[bool],
|
||||
)
|
||||
async def delete_course_material(
|
||||
course_id: int,
|
||||
material_id: int,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除课程资料(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **material_id**: 资料ID
|
||||
"""
|
||||
success = await course_service.delete_course_material(
|
||||
db, course_id=course_id, material_id=material_id, deleted_by=current_user.id
|
||||
)
|
||||
return ResponseModel(data=success, message="删除课程资料成功" if success else "删除课程资料失败")
|
||||
|
||||
|
||||
# 知识点相关API
|
||||
@router.get(
|
||||
"/{course_id}/knowledge-points",
|
||||
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||
)
|
||||
async def get_course_knowledge_points(
|
||||
course_id: int,
|
||||
material_id: Optional[int] = Query(None, description="资料ID"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程的知识点列表
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **material_id**: 资料ID(可选,用于筛选特定资料的知识点)
|
||||
"""
|
||||
# 先检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
knowledge_points = await knowledge_point_service.get_knowledge_points_by_course(
|
||||
db, course_id=course_id, material_id=material_id
|
||||
)
|
||||
|
||||
return ResponseModel(data=knowledge_points, message="获取知识点列表成功")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{course_id}/knowledge-points",
|
||||
response_model=ResponseModel[KnowledgePointInDB],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_knowledge_point(
|
||||
course_id: int,
|
||||
point_in: KnowledgePointCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建知识点(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **name**: 知识点名称
|
||||
- **description**: 知识点描述
|
||||
- **parent_id**: 父知识点ID
|
||||
- **weight**: 权重(0-10)
|
||||
- **is_required**: 是否必修
|
||||
- **estimated_hours**: 预计学习时间(小时)
|
||||
"""
|
||||
knowledge_point = await knowledge_point_service.create_knowledge_point(
|
||||
db, course_id=course_id, point_in=point_in, created_by=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=knowledge_point, message="创建知识点成功")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/knowledge-points/{point_id}", response_model=ResponseModel[KnowledgePointInDB]
|
||||
)
|
||||
async def update_knowledge_point(
|
||||
point_id: int,
|
||||
point_in: KnowledgePointUpdate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新知识点(需要管理员权限)
|
||||
|
||||
- **point_id**: 知识点ID
|
||||
- **point_in**: 更新的知识点数据(所有字段都是可选的)
|
||||
"""
|
||||
knowledge_point = await knowledge_point_service.update_knowledge_point(
|
||||
db, point_id=point_id, point_in=point_in, updated_by=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=knowledge_point, message="更新知识点成功")
|
||||
|
||||
|
||||
@router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool])
|
||||
async def delete_knowledge_point(
|
||||
point_id: int,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除知识点(需要管理员权限)
|
||||
|
||||
- **point_id**: 知识点ID
|
||||
"""
|
||||
success = await knowledge_point_service.delete(
|
||||
db, id=point_id, soft=True, deleted_by=current_user.id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.warning("删除知识点", knowledge_point_id=point_id, deleted_by=current_user.id)
|
||||
|
||||
return ResponseModel(data=success, message="删除知识点成功" if success else "删除知识点失败")
|
||||
|
||||
|
||||
# 资料知识点关联API
|
||||
@router.get(
|
||||
"/materials/{material_id}/knowledge-points",
|
||||
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||
)
|
||||
async def get_material_knowledge_points(
|
||||
material_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取资料关联的知识点列表
|
||||
"""
|
||||
knowledge_points = await course_service.get_material_knowledge_points(
|
||||
db, material_id=material_id
|
||||
)
|
||||
return ResponseModel(data=knowledge_points, message="获取知识点列表成功")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/materials/{material_id}/knowledge-points",
|
||||
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_material_knowledge_points(
|
||||
material_id: int,
|
||||
knowledge_point_ids: List[int],
|
||||
current_user: User = Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
为资料添加知识点关联(需要管理员或经理权限)
|
||||
"""
|
||||
knowledge_points = await course_service.add_material_knowledge_points(
|
||||
db, material_id=material_id, knowledge_point_ids=knowledge_point_ids
|
||||
)
|
||||
return ResponseModel(data=knowledge_points, message="添加知识点成功")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/materials/{material_id}/knowledge-points/{knowledge_point_id}",
|
||||
response_model=ResponseModel[bool],
|
||||
)
|
||||
async def remove_material_knowledge_point(
|
||||
material_id: int,
|
||||
knowledge_point_id: int,
|
||||
current_user: User = Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
移除资料的知识点关联(需要管理员或经理权限)
|
||||
"""
|
||||
success = await course_service.remove_material_knowledge_point(
|
||||
db, material_id=material_id, knowledge_point_id=knowledge_point_id
|
||||
)
|
||||
return ResponseModel(data=success, message="移除知识点成功" if success else "移除失败")
|
||||
|
||||
|
||||
# 成长路径相关API
|
||||
@router.post(
|
||||
"/growth-paths",
|
||||
response_model=ResponseModel[GrowthPathInDB],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_growth_path(
|
||||
path_in: GrowthPathCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建成长路径(需要管理员权限)
|
||||
|
||||
- **name**: 路径名称
|
||||
- **description**: 路径描述
|
||||
- **target_role**: 目标角色
|
||||
- **courses**: 课程列表(包含course_id、order、is_required)
|
||||
- **estimated_duration_days**: 预计完成天数
|
||||
- **is_active**: 是否启用
|
||||
"""
|
||||
growth_path = await growth_path_service.create_growth_path(
|
||||
db, path_in=path_in, created_by=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=growth_path, message="创建成长路径成功")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/growth-paths", response_model=ResponseModel[PaginatedResponse[GrowthPathInDB]]
|
||||
)
|
||||
async def get_growth_paths(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取成长路径列表
|
||||
|
||||
- **page**: 页码
|
||||
- **size**: 每页数量
|
||||
- **is_active**: 是否启用筛选
|
||||
"""
|
||||
page_params = PaginationParams(page=page, page_size=size)
|
||||
|
||||
filters = []
|
||||
if is_active is not None:
|
||||
from app.models.course import GrowthPath
|
||||
|
||||
filters.append(GrowthPath.is_active == is_active)
|
||||
|
||||
result = await growth_path_service.get_page(
|
||||
db, page_params=page_params, filters=filters
|
||||
)
|
||||
|
||||
return ResponseModel(data=result, message="获取成长路径列表成功")
|
||||
|
||||
|
||||
# 课程考试设置相关API
|
||||
@router.get(
|
||||
"/{course_id}/exam-settings",
|
||||
response_model=ResponseModel[Optional[CourseExamSettingsInDB]],
|
||||
)
|
||||
async def get_course_exam_settings(
|
||||
course_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程的考试设置
|
||||
|
||||
- **course_id**: 课程ID
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 获取考试设置
|
||||
from app.services.course_exam_service import course_exam_service
|
||||
settings = await course_exam_service.get_by_course_id(db, course_id)
|
||||
|
||||
# 添加调试日志
|
||||
if settings:
|
||||
logger.info(
|
||||
f"📊 获取考试设置成功 - course_id: {course_id}, "
|
||||
f"单选: {settings.single_choice_count}, 多选: {settings.multiple_choice_count}, "
|
||||
f"判断: {settings.true_false_count}, 填空: {settings.fill_blank_count}, "
|
||||
f"问答: {settings.essay_count}, 难度: {settings.difficulty_level}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ 课程 {course_id} 没有配置考试设置,将使用默认值")
|
||||
|
||||
return ResponseModel(data=settings, message="获取考试设置成功")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{course_id}/exam-settings",
|
||||
response_model=ResponseModel[CourseExamSettingsInDB],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_course_exam_settings(
|
||||
course_id: int,
|
||||
settings_in: CourseExamSettingsCreate,
|
||||
current_user: User = Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建或更新课程的考试设置(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **settings_in**: 考试设置数据
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 创建或更新考试设置
|
||||
from app.services.course_exam_service import course_exam_service
|
||||
settings = await course_exam_service.create_or_update(
|
||||
db, course_id=course_id, settings_in=settings_in, user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=settings, message="保存考试设置成功")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{course_id}/exam-settings",
|
||||
response_model=ResponseModel[CourseExamSettingsInDB],
|
||||
)
|
||||
async def update_course_exam_settings(
|
||||
course_id: int,
|
||||
settings_in: CourseExamSettingsUpdate,
|
||||
current_user: User = Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新课程的考试设置(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **settings_in**: 更新的考试设置数据
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 更新考试设置
|
||||
from app.services.course_exam_service import course_exam_service
|
||||
settings = await course_exam_service.update(
|
||||
db, course_id=course_id, settings_in=settings_in, user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=settings, message="更新考试设置成功")
|
||||
|
||||
|
||||
# 课程岗位分配相关API
|
||||
@router.get(
|
||||
"/{course_id}/positions",
|
||||
response_model=ResponseModel[List[CoursePositionAssignmentInDB]],
|
||||
)
|
||||
async def get_course_positions(
|
||||
course_id: int,
|
||||
course_type: Optional[str] = Query(None, pattern="^(required|optional)$", description="课程类型筛选"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取课程的岗位分配列表
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **course_type**: 课程类型筛选(required必修/optional选修)
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 获取岗位分配列表
|
||||
from app.services.course_position_service import course_position_service
|
||||
assignments = await course_position_service.get_course_positions(
|
||||
db, course_id=course_id, course_type=course_type
|
||||
)
|
||||
|
||||
return ResponseModel(data=assignments, message="获取岗位分配列表成功")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{course_id}/positions",
|
||||
response_model=ResponseModel[List[CoursePositionAssignmentInDB]],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def assign_course_positions(
|
||||
course_id: int,
|
||||
assignments: List[CoursePositionAssignment],
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量分配课程到岗位(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **assignments**: 岗位分配列表
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 批量分配岗位
|
||||
from app.services.course_position_service import course_position_service
|
||||
result = await course_position_service.batch_assign_positions(
|
||||
db, course_id=course_id, assignments=assignments, user_id=current_user.id
|
||||
)
|
||||
|
||||
# 发送课程分配通知给相关岗位的学员
|
||||
try:
|
||||
from app.models.position_member import PositionMember
|
||||
from app.services.notification_service import notification_service
|
||||
from app.schemas.notification import NotificationBatchCreate, NotificationType
|
||||
|
||||
# 获取所有分配岗位的学员ID
|
||||
position_ids = [a.position_id for a in assignments]
|
||||
if position_ids:
|
||||
member_result = await db.execute(
|
||||
select(PositionMember.user_id).where(
|
||||
PositionMember.position_id.in_(position_ids),
|
||||
PositionMember.is_deleted == False
|
||||
).distinct()
|
||||
)
|
||||
user_ids = [row[0] for row in member_result.fetchall()]
|
||||
|
||||
if user_ids:
|
||||
notification_batch = NotificationBatchCreate(
|
||||
user_ids=user_ids,
|
||||
title="新课程通知",
|
||||
content=f"您所在岗位有新课程「{course.name}」已分配,请及时学习。",
|
||||
type=NotificationType.COURSE_ASSIGN,
|
||||
related_id=course_id,
|
||||
related_type="course",
|
||||
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)}")
|
||||
|
||||
return ResponseModel(data=result, message="岗位分配成功")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{course_id}/positions/{position_id}",
|
||||
response_model=ResponseModel[bool],
|
||||
)
|
||||
async def remove_course_position(
|
||||
course_id: int,
|
||||
position_id: int,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
移除课程的岗位分配(需要管理员权限)
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **position_id**: 岗位ID
|
||||
"""
|
||||
# 检查课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||
|
||||
# 移除岗位分配
|
||||
from app.services.course_position_service import course_position_service
|
||||
success = await course_position_service.remove_position_assignment(
|
||||
db, course_id=course_id, position_id=position_id, user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(data=success, message="移除岗位分配成功" if success else "移除岗位分配失败")
|
||||
|
||||
|
||||
async def _trigger_knowledge_analysis(
|
||||
db: AsyncSession,
|
||||
course_id: int,
|
||||
material_id: int,
|
||||
file_url: str,
|
||||
course_title: str,
|
||||
user_id: int
|
||||
):
|
||||
"""
|
||||
后台触发知识点分析任务
|
||||
|
||||
注意:此函数在后台任务中执行,异常不会影响资料添加的成功响应
|
||||
"""
|
||||
try:
|
||||
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||
|
||||
logger.info(
|
||||
f"后台知识点分析开始 - course_id: {course_id}, material_id: {material_id}, file_url: {file_url}, user_id: {user_id}"
|
||||
)
|
||||
|
||||
result = await knowledge_analysis_service_v2.analyze_course_material(
|
||||
db=db,
|
||||
course_id=course_id,
|
||||
material_id=material_id,
|
||||
file_url=file_url,
|
||||
course_title=course_title,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"后台知识点分析完成 - course_id: {course_id}, material_id: {material_id}, knowledge_points_count: {result.get('knowledge_points_count', 0)}, user_id: {user_id}"
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# 文件不存在时记录警告,但不记录完整堆栈
|
||||
logger.warning(
|
||||
f"后台知识点分析失败(文件不存在) - course_id: {course_id}, material_id: {material_id}, "
|
||||
f"file_url: {file_url}, error: {str(e)}, user_id: {user_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
# 其他异常记录详细信息
|
||||
logger.error(
|
||||
f"后台知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
275
backend/app/api/v1/coze_gateway.py
Normal file
275
backend/app/api/v1/coze_gateway.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Coze 网关 API 路由
|
||||
提供课程对话和陪练功能的统一接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.services.ai.coze import (
|
||||
get_coze_service,
|
||||
CreateSessionRequest,
|
||||
SendMessageRequest,
|
||||
EndSessionRequest,
|
||||
SessionType,
|
||||
CozeException,
|
||||
StreamEventType,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["coze-gateway"])
|
||||
|
||||
|
||||
# TODO: 依赖注入获取当前用户
|
||||
async def get_current_user():
|
||||
"""获取当前登录用户(临时实现)"""
|
||||
# 实际应该从 Auth 模块获取
|
||||
return {"user_id": "test-user-123", "username": "test_user"}
|
||||
|
||||
|
||||
@router.post("/course-chat/sessions")
|
||||
async def create_course_chat_session(course_id: str, user=Depends(get_current_user)):
|
||||
"""
|
||||
创建课程对话会话
|
||||
|
||||
- **course_id**: 课程ID
|
||||
"""
|
||||
try:
|
||||
service = get_coze_service()
|
||||
request = CreateSessionRequest(
|
||||
session_type=SessionType.COURSE_CHAT,
|
||||
user_id=user["user_id"],
|
||||
course_id=course_id,
|
||||
metadata={"username": user["username"], "course_id": course_id},
|
||||
)
|
||||
|
||||
response = await service.create_session(request)
|
||||
|
||||
return {"code": 200, "message": "success", "data": response.dict()}
|
||||
|
||||
except CozeException as e:
|
||||
logger.error(f"创建课程对话会话失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=e.status_code or 500,
|
||||
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"未知错误: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/training/sessions")
|
||||
async def create_training_session(
|
||||
training_topic: str = None, user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
创建陪练会话
|
||||
|
||||
- **training_topic**: 陪练主题(可选)
|
||||
"""
|
||||
try:
|
||||
service = get_coze_service()
|
||||
request = CreateSessionRequest(
|
||||
session_type=SessionType.TRAINING,
|
||||
user_id=user["user_id"],
|
||||
training_topic=training_topic,
|
||||
metadata={"username": user["username"], "training_topic": training_topic},
|
||||
)
|
||||
|
||||
response = await service.create_session(request)
|
||||
|
||||
return {"code": 200, "message": "success", "data": response.dict()}
|
||||
|
||||
except CozeException as e:
|
||||
logger.error(f"创建陪练会话失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=e.status_code or 500,
|
||||
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"未知错误: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/training/sessions/{session_id}/end")
|
||||
async def end_training_session(
|
||||
session_id: str, request: EndSessionRequest, user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
结束陪练会话
|
||||
|
||||
- **session_id**: 会话ID
|
||||
"""
|
||||
try:
|
||||
service = get_coze_service()
|
||||
response = await service.end_session(session_id, request)
|
||||
|
||||
return {"code": 200, "message": "success", "data": response.dict()}
|
||||
|
||||
except CozeException as e:
|
||||
logger.error(f"结束会话失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=e.status_code or 500,
|
||||
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"未知错误: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat/messages")
|
||||
async def send_message(request: SendMessageRequest, user=Depends(get_current_user)):
|
||||
"""
|
||||
发送消息(支持流式响应)
|
||||
|
||||
- **session_id**: 会话ID
|
||||
- **content**: 消息内容
|
||||
- **stream**: 是否流式响应(默认True)
|
||||
"""
|
||||
try:
|
||||
service = get_coze_service()
|
||||
|
||||
if request.stream:
|
||||
# 流式响应
|
||||
async def event_generator():
|
||||
async for event in service.send_message(request):
|
||||
# 转换为 SSE 格式
|
||||
if event.event == StreamEventType.MESSAGE_DELTA:
|
||||
yield {
|
||||
"event": "message",
|
||||
"data": {
|
||||
"type": "delta",
|
||||
"content": event.content,
|
||||
"content_type": event.content_type.value,
|
||||
"message_id": event.message_id,
|
||||
},
|
||||
}
|
||||
elif event.event == StreamEventType.MESSAGE_COMPLETED:
|
||||
yield {
|
||||
"event": "message",
|
||||
"data": {
|
||||
"type": "completed",
|
||||
"content": event.content,
|
||||
"content_type": event.content_type.value,
|
||||
"message_id": event.message_id,
|
||||
"usage": event.data.get("usage", {}),
|
||||
},
|
||||
}
|
||||
elif event.event == StreamEventType.ERROR:
|
||||
yield {"event": "error", "data": {"error": event.error}}
|
||||
elif event.event == StreamEventType.DONE:
|
||||
yield {
|
||||
"event": "done",
|
||||
"data": {"session_id": event.data.get("session_id")},
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
else:
|
||||
# 非流式响应(收集完整响应)
|
||||
full_content = ""
|
||||
content_type = None
|
||||
message_id = None
|
||||
|
||||
async for event in service.send_message(request):
|
||||
if event.event == StreamEventType.MESSAGE_COMPLETED:
|
||||
full_content = event.content
|
||||
content_type = event.content_type
|
||||
message_id = event.message_id
|
||||
break
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"message_id": message_id,
|
||||
"content": full_content,
|
||||
"content_type": content_type.value if content_type else "text",
|
||||
"role": "assistant",
|
||||
},
|
||||
}
|
||||
|
||||
except CozeException as e:
|
||||
logger.error(f"发送消息失败: {e}")
|
||||
if request.stream:
|
||||
# 流式响应的错误处理
|
||||
async def error_generator():
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": {
|
||||
"code": e.code,
|
||||
"message": e.message,
|
||||
"details": e.details,
|
||||
},
|
||||
}
|
||||
|
||||
return EventSourceResponse(error_generator())
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=e.status_code or 500,
|
||||
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"未知错误: {e}", exc_info=True)
|
||||
if request.stream:
|
||||
|
||||
async def error_generator():
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": {"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
}
|
||||
|
||||
return EventSourceResponse(error_generator())
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages")
|
||||
async def get_session_messages(
|
||||
session_id: str, limit: int = 50, offset: int = 0, user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取会话消息历史
|
||||
|
||||
- **session_id**: 会话ID
|
||||
- **limit**: 返回消息数量限制
|
||||
- **offset**: 偏移量
|
||||
"""
|
||||
try:
|
||||
service = get_coze_service()
|
||||
messages = await service.get_session_messages(session_id, limit, offset)
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"messages": [msg.dict() for msg in messages],
|
||||
"total": len(messages),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取消息历史失败: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||
)
|
||||
236
backend/app/api/v1/endpoints/employee_sync.py
Normal file
236
backend/app/api/v1/endpoints/employee_sync.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
员工同步API接口
|
||||
提供从钉钉员工表同步员工数据的功能
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.services.employee_sync_service import EmployeeSyncService
|
||||
from app.models.user import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/sync", summary="执行员工同步")
|
||||
async def sync_employees(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从钉钉员工表同步在职员工数据到考培练系统
|
||||
|
||||
权限要求: 仅管理员可执行
|
||||
|
||||
同步内容:
|
||||
- 创建用户账号(用户名=手机号,初始密码=123456)
|
||||
- 创建部门团队
|
||||
- 创建岗位并关联用户
|
||||
- 设置领导为团队负责人
|
||||
|
||||
Returns:
|
||||
同步结果统计
|
||||
"""
|
||||
# 权限检查:仅管理员可执行
|
||||
if current_user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以执行员工同步"
|
||||
)
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 开始执行员工同步")
|
||||
|
||||
try:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.sync_employees()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "员工同步完成",
|
||||
"data": stats
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"员工同步失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"员工同步失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preview", summary="预览待同步员工数据")
|
||||
async def preview_sync_data(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
预览待同步的员工数据(不执行实际同步)
|
||||
|
||||
权限要求: 仅管理员可查看
|
||||
|
||||
Returns:
|
||||
预览数据,包括员工列表、部门列表、岗位列表等
|
||||
"""
|
||||
# 权限检查:仅管理员可查看
|
||||
if current_user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以预览员工数据"
|
||||
)
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 预览员工同步数据")
|
||||
|
||||
try:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
preview_data = await sync_service.preview_sync_data()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "预览数据获取成功",
|
||||
"data": preview_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"预览数据获取失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"预览数据获取失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/incremental-sync", summary="增量同步员工")
|
||||
async def incremental_sync_employees(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
增量同步钉钉员工数据
|
||||
|
||||
功能说明:
|
||||
- 新增:钉钉有但系统没有的员工
|
||||
- 删除:系统有但钉钉没有的员工(物理删除)
|
||||
- 跳过:两边都存在的员工(不修改任何信息)
|
||||
|
||||
权限要求: 管理员(admin 或 manager)可执行
|
||||
|
||||
Returns:
|
||||
同步结果统计
|
||||
"""
|
||||
# 权限检查:管理员或经理可执行
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以执行员工同步"
|
||||
)
|
||||
|
||||
logger.info(f"用户 {current_user.username} ({current_user.role}) 开始执行增量员工同步")
|
||||
|
||||
try:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.incremental_sync_employees()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "增量同步完成",
|
||||
"data": {
|
||||
"added_count": stats['added_count'],
|
||||
"deleted_count": stats['deleted_count'],
|
||||
"skipped_count": stats['skipped_count'],
|
||||
"added_users": stats['added_users'],
|
||||
"deleted_users": stats['deleted_users'],
|
||||
"errors": stats['errors'],
|
||||
"duration": stats['duration']
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"增量同步失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"增量同步失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", summary="查询同步状态")
|
||||
async def get_sync_status(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询当前系统的用户、团队、岗位统计信息
|
||||
|
||||
Returns:
|
||||
统计信息
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
from app.models.user import User, Team
|
||||
from app.models.position import Position
|
||||
|
||||
try:
|
||||
# 统计用户数量
|
||||
user_count_stmt = select(func.count(User.id)).where(User.is_deleted == False)
|
||||
user_result = await db.execute(user_count_stmt)
|
||||
total_users = user_result.scalar()
|
||||
|
||||
# 统计各角色用户数量
|
||||
admin_count_stmt = select(func.count(User.id)).where(
|
||||
User.is_deleted == False,
|
||||
User.role == 'admin'
|
||||
)
|
||||
admin_result = await db.execute(admin_count_stmt)
|
||||
admin_count = admin_result.scalar()
|
||||
|
||||
manager_count_stmt = select(func.count(User.id)).where(
|
||||
User.is_deleted == False,
|
||||
User.role == 'manager'
|
||||
)
|
||||
manager_result = await db.execute(manager_count_stmt)
|
||||
manager_count = manager_result.scalar()
|
||||
|
||||
trainee_count_stmt = select(func.count(User.id)).where(
|
||||
User.is_deleted == False,
|
||||
User.role == 'trainee'
|
||||
)
|
||||
trainee_result = await db.execute(trainee_count_stmt)
|
||||
trainee_count = trainee_result.scalar()
|
||||
|
||||
# 统计团队数量
|
||||
team_count_stmt = select(func.count(Team.id)).where(Team.is_deleted == False)
|
||||
team_result = await db.execute(team_count_stmt)
|
||||
total_teams = team_result.scalar()
|
||||
|
||||
# 统计岗位数量
|
||||
position_count_stmt = select(func.count(Position.id)).where(Position.is_deleted == False)
|
||||
position_result = await db.execute(position_count_stmt)
|
||||
total_positions = position_result.scalar()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"admin": admin_count,
|
||||
"manager": manager_count,
|
||||
"trainee": trainee_count
|
||||
},
|
||||
"teams": total_teams,
|
||||
"positions": total_positions
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询统计信息失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询统计信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
761
backend/app/api/v1/exam.py
Normal file
761
backend/app/api/v1/exam.py
Normal file
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
考试相关API路由
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.core.config import get_settings
|
||||
from app.core.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.exam import Exam
|
||||
from app.models.exam_mistake import ExamMistake
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.position_course import PositionCourse
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.exam import (
|
||||
StartExamRequest,
|
||||
StartExamResponse,
|
||||
SubmitExamRequest,
|
||||
SubmitExamResponse,
|
||||
ExamDetailResponse,
|
||||
ExamRecordResponse,
|
||||
GenerateExamRequest,
|
||||
GenerateExamResponse,
|
||||
JudgeAnswerRequest,
|
||||
JudgeAnswerResponse,
|
||||
RecordMistakeRequest,
|
||||
RecordMistakeResponse,
|
||||
GetMistakesResponse,
|
||||
MistakeRecordItem,
|
||||
# 新增的Schema
|
||||
ExamReportResponse,
|
||||
MistakeListResponse,
|
||||
MistakesStatisticsResponse,
|
||||
UpdateRoundScoreRequest,
|
||||
)
|
||||
from app.services.exam_report_service import ExamReportService, MistakeService
|
||||
from app.services.course_statistics_service import course_statistics_service
|
||||
from app.services.system_log_service import system_log_service
|
||||
from app.schemas.system_log import SystemLogCreate
|
||||
|
||||
# V2 原生服务:Python 实现
|
||||
from app.services.ai import exam_generator_service, ExamGeneratorConfig
|
||||
from app.services.ai.answer_judge_service import answer_judge_service
|
||||
from app.core.exceptions import ExternalServiceError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
router = APIRouter(prefix="/exams", tags=["考试"])
|
||||
|
||||
|
||||
@router.post("/start", response_model=ResponseModel[StartExamResponse])
|
||||
async def start_exam(
|
||||
request: StartExamRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""开始考试"""
|
||||
exam = await ExamService.start_exam(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=request.course_id,
|
||||
question_count=request.count,
|
||||
)
|
||||
|
||||
# 异步更新课程学员数统计
|
||||
try:
|
||||
await course_statistics_service.update_course_student_count(db, request.course_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新课程学员数失败: {str(e)}")
|
||||
# 不影响主流程,只记录警告
|
||||
|
||||
# 记录考试开始日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"用户 {current_user.username} 开始考试(课程ID: {request.course_id})",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=http_request.client.host if http_request.client else None,
|
||||
path="/api/v1/exams/start",
|
||||
method="POST",
|
||||
user_agent=http_request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始")
|
||||
|
||||
|
||||
@router.post("/submit", response_model=ResponseModel[SubmitExamResponse])
|
||||
async def submit_exam(
|
||||
request: SubmitExamRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""提交考试答案"""
|
||||
result = await ExamService.submit_exam(
|
||||
db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers
|
||||
)
|
||||
|
||||
# 获取考试记录以获取course_id
|
||||
exam_stmt = select(Exam).where(Exam.id == request.exam_id)
|
||||
exam_result = await db.execute(exam_stmt)
|
||||
exam = exam_result.scalar_one_or_none()
|
||||
|
||||
# 异步更新课程学员数统计
|
||||
if exam and exam.course_id:
|
||||
try:
|
||||
await course_statistics_service.update_course_student_count(db, exam.course_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新课程学员数失败: {str(e)}")
|
||||
# 不影响主流程,只记录警告
|
||||
|
||||
# 记录考试提交日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"用户 {current_user.username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=http_request.client.host if http_request.client else None,
|
||||
path="/api/v1/exams/submit",
|
||||
method="POST",
|
||||
user_agent=http_request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功")
|
||||
|
||||
|
||||
@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse])
|
||||
async def get_mistakes(
|
||||
exam_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取错题记录
|
||||
|
||||
用于第二、三轮考试时获取上一轮的错题记录
|
||||
返回的数据可直接序列化为JSON字符串作为mistake_records参数传给考试生成接口
|
||||
"""
|
||||
logger.info(f"📋 GET /mistakes 收到请求")
|
||||
try:
|
||||
logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, user_id: {current_user.id}")
|
||||
|
||||
# 查询指定考试的错题记录
|
||||
result = await db.execute(
|
||||
select(ExamMistake).where(
|
||||
ExamMistake.exam_id == exam_id,
|
||||
ExamMistake.user_id == current_user.id,
|
||||
).order_by(ExamMistake.id)
|
||||
)
|
||||
mistakes = result.scalars().all()
|
||||
|
||||
logger.info(f"✅ 查询到错题记录数量: {len(mistakes)}")
|
||||
|
||||
# 转换为响应格式
|
||||
mistake_items = [
|
||||
MistakeRecordItem(
|
||||
id=m.id,
|
||||
question_id=m.question_id,
|
||||
knowledge_point_id=m.knowledge_point_id,
|
||||
question_content=m.question_content,
|
||||
correct_answer=m.correct_answer,
|
||||
user_answer=m.user_answer,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in mistakes
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"获取错题记录成功 - user_id: {current_user.id}, exam_id: {exam_id}, "
|
||||
f"count: {len(mistake_items)}"
|
||||
)
|
||||
|
||||
# 返回统一的ResponseModel格式,让Pydantic自动处理序列化
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取错题记录成功",
|
||||
data=GetMistakesResponse(
|
||||
mistakes=mistake_items
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取错题记录失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取错题记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse])
|
||||
async def get_exam_detail(
|
||||
exam_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取考试详情"""
|
||||
exam_data = await ExamService.get_exam_detail(
|
||||
db=db, user_id=current_user.id, exam_id=exam_id
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功")
|
||||
|
||||
|
||||
@router.get("/records", response_model=ResponseModel[dict])
|
||||
async def get_exam_records(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(10, ge=1, le=100),
|
||||
course_id: Optional[int] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取考试记录列表"""
|
||||
records = await ExamService.get_exam_records(
|
||||
db=db, user_id=current_user.id, page=page, size=size, course_id=course_id
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=records, message="获取成功")
|
||||
|
||||
|
||||
@router.get("/statistics/summary", response_model=ResponseModel[dict])
|
||||
async def get_exam_statistics(
|
||||
course_id: Optional[int] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取考试统计信息"""
|
||||
stats = await ExamService.get_exam_statistics(
|
||||
db=db, user_id=current_user.id, course_id=course_id
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=stats, message="获取成功")
|
||||
|
||||
|
||||
# ==================== 试题生成接口 ====================
|
||||
|
||||
@router.post("/generate", response_model=ResponseModel[GenerateExamResponse])
|
||||
async def generate_exam(
|
||||
request: GenerateExamRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
生成考试试题
|
||||
|
||||
使用 Python 原生 AI 服务实现。
|
||||
|
||||
考试轮次说明:
|
||||
- 第一轮考试:mistake_records 传空或不传
|
||||
- 第二、三轮错题重考:mistake_records 传入上一轮错题记录的JSON字符串
|
||||
"""
|
||||
try:
|
||||
# 从用户信息中自动获取岗位ID(如果未提供)
|
||||
position_id = request.position_id
|
||||
if not position_id:
|
||||
# 1. 首先查询用户已分配的岗位
|
||||
result = await db.execute(
|
||||
select(PositionMember).where(
|
||||
PositionMember.user_id == current_user.id,
|
||||
PositionMember.is_deleted == False
|
||||
).limit(1)
|
||||
)
|
||||
position_member = result.scalar_one_or_none()
|
||||
if position_member:
|
||||
position_id = position_member.position_id
|
||||
else:
|
||||
# 2. 如果用户没有岗位,从课程关联的岗位中获取第一个
|
||||
result = await db.execute(
|
||||
select(PositionCourse.position_id).where(
|
||||
PositionCourse.course_id == request.course_id,
|
||||
PositionCourse.is_deleted == False
|
||||
).limit(1)
|
||||
)
|
||||
course_position = result.scalar_one_or_none()
|
||||
if course_position:
|
||||
position_id = course_position
|
||||
logger.info(f"用户 {current_user.id} 没有分配岗位,使用课程关联的岗位ID: {position_id}")
|
||||
else:
|
||||
# 3. 如果课程也没有关联岗位,抛出错误
|
||||
logger.warning(f"用户 {current_user.id} 没有分配岗位,且课程 {request.course_id} 未关联任何岗位")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法生成试题:用户未分配岗位,且课程未关联任何岗位"
|
||||
)
|
||||
|
||||
# 记录详细的题型设置(用于调试)
|
||||
logger.info(
|
||||
f"考试题型设置 - 单选:{request.single_choice_count}, 多选:{request.multiple_choice_count}, "
|
||||
f"判断:{request.true_false_count}, 填空:{request.fill_blank_count}, 问答:{request.essay_count}, "
|
||||
f"难度:{request.difficulty_level}"
|
||||
)
|
||||
|
||||
# 调用 Python 原生试题生成服务
|
||||
logger.info(
|
||||
f"调用原生试题生成服务 - user_id: {current_user.id}, "
|
||||
f"course_id: {request.course_id}, position_id: {position_id}"
|
||||
)
|
||||
|
||||
# 构建配置
|
||||
config = ExamGeneratorConfig(
|
||||
course_id=request.course_id,
|
||||
position_id=position_id,
|
||||
single_choice_count=request.single_choice_count or 0,
|
||||
multiple_choice_count=request.multiple_choice_count or 0,
|
||||
true_false_count=request.true_false_count or 0,
|
||||
fill_blank_count=request.fill_blank_count or 0,
|
||||
essay_count=request.essay_count or 0,
|
||||
difficulty_level=request.difficulty_level or 3,
|
||||
mistake_records=request.mistake_records or "",
|
||||
)
|
||||
|
||||
# 调用原生服务
|
||||
gen_result = await exam_generator_service.generate_exam(db, config)
|
||||
|
||||
if not gen_result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="试题生成服务返回失败"
|
||||
)
|
||||
|
||||
# 将题目列表转为 JSON 字符串(兼容原有前端格式)
|
||||
result_data = json.dumps(gen_result.get("questions", []), ensure_ascii=False)
|
||||
|
||||
logger.info(
|
||||
f"试题生成完成 - questions: {gen_result.get('total_count')}, "
|
||||
f"provider: {gen_result.get('ai_provider')}, latency: {gen_result.get('ai_latency_ms')}ms"
|
||||
)
|
||||
|
||||
if result_data is None or result_data == "":
|
||||
logger.error(f"试题生成未返回有效结果数据")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="试题生成失败: 未返回结果数据"
|
||||
)
|
||||
|
||||
# 创建或复用考试记录
|
||||
question_count = sum([
|
||||
request.single_choice_count or 0,
|
||||
request.multiple_choice_count or 0,
|
||||
request.true_false_count or 0,
|
||||
request.fill_blank_count or 0,
|
||||
request.essay_count or 0
|
||||
])
|
||||
|
||||
# 第一轮:创建新的exam记录
|
||||
if request.current_round == 1:
|
||||
exam = Exam(
|
||||
user_id=current_user.id,
|
||||
course_id=request.course_id,
|
||||
exam_name=f"课程{request.course_id}考试",
|
||||
question_count=question_count,
|
||||
total_score=100.0,
|
||||
pass_score=60.0,
|
||||
duration_minutes=60,
|
||||
status="started",
|
||||
start_time=datetime.now(),
|
||||
questions=None,
|
||||
answers=None,
|
||||
)
|
||||
|
||||
db.add(exam)
|
||||
await db.commit()
|
||||
await db.refresh(exam)
|
||||
|
||||
logger.info(f"第{request.current_round}轮:创建考试记录成功 - exam_id: {exam.id}")
|
||||
else:
|
||||
# 第二、三轮:复用已有exam记录
|
||||
if not request.exam_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"第{request.current_round}轮考试必须提供exam_id"
|
||||
)
|
||||
|
||||
exam = await db.get(Exam, request.exam_id)
|
||||
if not exam:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="考试记录不存在"
|
||||
)
|
||||
|
||||
if exam.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此考试记录"
|
||||
)
|
||||
|
||||
logger.info(f"第{request.current_round}轮:复用考试记录 - exam_id: {exam.id}")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="试题生成成功",
|
||||
data=GenerateExamResponse(
|
||||
result=result_data,
|
||||
workflow_run_id=f"{gen_result.get('ai_provider')}_{gen_result.get('ai_latency_ms')}ms",
|
||||
task_id=f"native_{request.course_id}",
|
||||
exam_id=exam.id,
|
||||
)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成考试试题失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"试题生成失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/judge-answer", response_model=ResponseModel[JudgeAnswerResponse])
|
||||
async def judge_answer(
|
||||
request: JudgeAnswerRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
判断主观题答案
|
||||
|
||||
适用于填空题和问答题的答案判断。
|
||||
使用 Python 原生 AI 服务实现。
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"调用原生答案判断服务 - user_id: {current_user.id}, "
|
||||
f"question: {request.question[:50]}..."
|
||||
)
|
||||
|
||||
result = await answer_judge_service.judge(
|
||||
question=request.question,
|
||||
correct_answer=request.correct_answer,
|
||||
user_answer=request.user_answer,
|
||||
analysis=request.analysis,
|
||||
db=db # 传入 db_session 用于记录调用日志
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"答案判断完成 - is_correct: {result.is_correct}, "
|
||||
f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="答案判断完成",
|
||||
data=JudgeAnswerResponse(
|
||||
is_correct=result.is_correct,
|
||||
correct_answer=request.correct_answer,
|
||||
feedback=result.raw_response if not result.is_correct else None,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"答案判断失败: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"答案判断失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/record-mistake", response_model=ResponseModel[RecordMistakeResponse])
|
||||
async def record_mistake(
|
||||
request: RecordMistakeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
记录错题
|
||||
|
||||
当用户答错题目时,立即调用此接口记录到错题表
|
||||
"""
|
||||
try:
|
||||
# 创建错题记录
|
||||
# 注意:knowledge_point_id暂时设置为None,避免外键约束失败
|
||||
mistake = ExamMistake(
|
||||
user_id=current_user.id,
|
||||
exam_id=request.exam_id,
|
||||
question_id=request.question_id,
|
||||
knowledge_point_id=None, # 暂时设为None,避免外键约束
|
||||
question_content=request.question_content,
|
||||
correct_answer=request.correct_answer,
|
||||
user_answer=request.user_answer,
|
||||
question_type=request.question_type, # 新增:记录题型
|
||||
)
|
||||
|
||||
if request.knowledge_point_id:
|
||||
logger.info(f"原始knowledge_point_id={request.knowledge_point_id},已设置为NULL(待同步生产数据)")
|
||||
|
||||
db.add(mistake)
|
||||
await db.commit()
|
||||
await db.refresh(mistake)
|
||||
|
||||
logger.info(
|
||||
f"记录错题成功 - user_id: {current_user.id}, exam_id: {request.exam_id}, "
|
||||
f"mistake_id: {mistake.id}"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="错题记录成功",
|
||||
data=RecordMistakeResponse(
|
||||
id=mistake.id,
|
||||
created_at=mistake.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"记录错题失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"记录错题失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mistakes-debug")
|
||||
async def get_mistakes_debug(
|
||||
exam_id: int,
|
||||
):
|
||||
"""调试endpoint - 不需要认证"""
|
||||
logger.info(f"🔍 调试 - exam_id: {exam_id}, type: {type(exam_id)}")
|
||||
return {"exam_id": exam_id, "type": str(type(exam_id))}
|
||||
|
||||
|
||||
# ==================== 成绩报告和错题本相关接口 ====================
|
||||
|
||||
@router.get("/statistics/report", response_model=ResponseModel[ExamReportResponse])
|
||||
async def get_exam_report(
|
||||
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取成绩报告汇总
|
||||
|
||||
返回包含概览、趋势、科目分析、最近考试记录的完整报告
|
||||
"""
|
||||
try:
|
||||
report_data = await ExamReportService.get_exam_report(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=report_data, message="获取成绩报告成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取成绩报告失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取成绩报告失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mistakes/list", response_model=ResponseModel[MistakeListResponse])
|
||||
async def get_mistakes_list(
|
||||
exam_id: Optional[int] = Query(None, description="考试ID"),
|
||||
course_id: Optional[int] = Query(None, description="课程ID"),
|
||||
question_type: Optional[str] = Query(None, description="题型(single/multiple/judge/blank/essay)"),
|
||||
search: Optional[str] = Query(None, description="关键词搜索"),
|
||||
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取错题列表(支持多维度筛选)
|
||||
|
||||
- 不传exam_id时返回用户所有错题
|
||||
- 支持按course_id、question_type、关键词、时间范围筛选
|
||||
"""
|
||||
try:
|
||||
mistakes_data = await MistakeService.get_mistakes_list(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
exam_id=exam_id,
|
||||
course_id=course_id,
|
||||
question_type=question_type,
|
||||
search=search,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
page=page,
|
||||
size=size
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=mistakes_data, message="获取错题列表成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取错题列表失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取错题列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mistakes/statistics", response_model=ResponseModel[MistakesStatisticsResponse])
|
||||
async def get_mistakes_statistics(
|
||||
course_id: Optional[int] = Query(None, description="课程ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取错题统计数据
|
||||
|
||||
返回按课程、题型、时间维度的统计数据
|
||||
"""
|
||||
try:
|
||||
stats_data = await MistakeService.get_mistakes_statistics(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id
|
||||
)
|
||||
|
||||
return ResponseModel(code=200, data=stats_data, message="获取错题统计成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取错题统计失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取错题统计失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{exam_id}/round-score", response_model=ResponseModel[dict])
|
||||
async def update_round_score(
|
||||
exam_id: int,
|
||||
request: UpdateRoundScoreRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新某轮的得分
|
||||
|
||||
用于前端每轮考试结束后更新对应轮次的得分
|
||||
"""
|
||||
try:
|
||||
# 查询考试记录
|
||||
exam = await db.get(Exam, exam_id)
|
||||
if not exam:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="考试记录不存在"
|
||||
)
|
||||
|
||||
# 验证权限
|
||||
if exam.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权修改此考试记录"
|
||||
)
|
||||
|
||||
# 更新对应轮次的得分
|
||||
if request.round == 1:
|
||||
exam.round1_score = request.score
|
||||
elif request.round == 2:
|
||||
exam.round2_score = request.score
|
||||
elif request.round == 3:
|
||||
exam.round3_score = request.score
|
||||
# 第三轮默认就是 final
|
||||
request.is_final = True
|
||||
|
||||
# 如果是最终轮次(可能是第1/2轮就全对了),更新总分和状态
|
||||
if request.is_final:
|
||||
exam.score = request.score
|
||||
exam.status = "submitted"
|
||||
# 计算是否通过 (pass_score 为空默认 60)
|
||||
exam.is_passed = request.score >= (exam.pass_score or 60)
|
||||
# 更新结束时间
|
||||
from datetime import datetime
|
||||
exam.end_time = datetime.now()
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"更新轮次得分成功 - exam_id: {exam_id}, round: {request.round}, score: {request.score}")
|
||||
|
||||
return ResponseModel(code=200, data={"exam_id": exam_id}, message="更新得分成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"更新轮次得分失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新轮次得分失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/mistakes/{mistake_id}/mastered", response_model=ResponseModel)
|
||||
async def mark_mistake_mastered(
|
||||
mistake_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
标记错题为已掌握
|
||||
|
||||
Args:
|
||||
mistake_id: 错题记录ID
|
||||
db: 数据库会话
|
||||
current_user: 当前用户
|
||||
|
||||
Returns:
|
||||
ResponseModel: 标记结果
|
||||
"""
|
||||
try:
|
||||
# 查询错题记录
|
||||
stmt = select(ExamMistake).where(
|
||||
ExamMistake.id == mistake_id,
|
||||
ExamMistake.user_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
mistake = result.scalar_one_or_none()
|
||||
|
||||
if not mistake:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="错题记录不存在或无权访问"
|
||||
)
|
||||
|
||||
# 更新掌握状态
|
||||
from datetime import datetime as dt
|
||||
mistake.mastery_status = 'mastered'
|
||||
mistake.mastered_at = dt.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"标记错题已掌握成功 - mistake_id: {mistake_id}, user_id: {current_user.id}")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="已标记为掌握",
|
||||
data={"mistake_id": mistake_id, "mastery_status": "mastered"}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"标记错题已掌握失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"标记失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
201
backend/app/api/v1/knowledge_analysis.py
Normal file
201
backend/app/api/v1/knowledge_analysis.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
知识点分析 API
|
||||
|
||||
使用 Python 原生 AI 服务实现
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.models.user import User
|
||||
from app.services.course_service import course_service
|
||||
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/courses/{course_id}/materials/{material_id}/analyze", response_model=ResponseModel[Dict[str, Any]])
|
||||
async def analyze_material_knowledge_points(
|
||||
course_id: int,
|
||||
material_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
分析单个资料的知识点
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **material_id**: 资料ID
|
||||
|
||||
使用 Python 原生 AI 服务:
|
||||
- 本地 AI 服务调用(4sapi.com 首选,OpenRouter 备选)
|
||||
- 多层 JSON 解析兜底
|
||||
- 无外部平台依赖,更稳定
|
||||
"""
|
||||
try:
|
||||
# 验证课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"课程 {course_id} 不存在"
|
||||
)
|
||||
|
||||
# 获取资料信息
|
||||
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||
material = next((m for m in materials if m.id == material_id), None)
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"资料 {material_id} 不存在"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"准备启动知识点分析 - course_id: {course_id}, material_id: {material_id}, "
|
||||
f"file_url: {material.file_url}, user_id: {current_user.id}"
|
||||
)
|
||||
|
||||
# 调用 Python 原生知识点分析服务
|
||||
result = await knowledge_analysis_service_v2.analyze_course_material(
|
||||
db=db,
|
||||
course_id=course_id,
|
||||
material_id=material_id,
|
||||
file_url=material.file_url,
|
||||
course_title=course.name,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, "
|
||||
f"knowledge_points: {result.get('knowledge_points_count', 0)}, "
|
||||
f"provider: {result.get('ai_provider')}"
|
||||
)
|
||||
|
||||
# 构建响应
|
||||
response_data = {
|
||||
"message": "知识点分析完成",
|
||||
"course_id": course_id,
|
||||
"material_id": material_id,
|
||||
"status": result.get("status", "completed"),
|
||||
"knowledge_points_count": result.get("knowledge_points_count", 0),
|
||||
"ai_provider": result.get("ai_provider"),
|
||||
"ai_model": result.get("ai_model"),
|
||||
"ai_tokens": result.get("ai_tokens"),
|
||||
"ai_latency_ms": result.get("ai_latency_ms"),
|
||||
}
|
||||
|
||||
return ResponseModel(
|
||||
data=response_data,
|
||||
message="知识点分析完成"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"知识点分析失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/courses/{course_id}/reanalyze", response_model=ResponseModel[Dict[str, Any]])
|
||||
async def reanalyze_course_materials(
|
||||
course_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
重新分析课程的所有资料
|
||||
|
||||
- **course_id**: 课程ID
|
||||
|
||||
该接口会重新分析课程下的所有资料,提取知识点
|
||||
"""
|
||||
try:
|
||||
# 验证课程是否存在
|
||||
course = await course_service.get_by_id(db, course_id)
|
||||
if not course:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"课程 {course_id} 不存在"
|
||||
)
|
||||
|
||||
# 获取课程资料信息
|
||||
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||
|
||||
if not materials:
|
||||
return ResponseModel(
|
||||
data={
|
||||
"message": "该课程暂无资料需要分析",
|
||||
"course_id": course_id,
|
||||
"status": "stopped",
|
||||
"materials_count": 0
|
||||
},
|
||||
message="无资料需要分析"
|
||||
)
|
||||
|
||||
# 调用 Python 原生知识点分析服务
|
||||
result = await knowledge_analysis_service_v2.reanalyze_course_materials(
|
||||
db=db,
|
||||
course_id=course_id,
|
||||
course_title=course.name,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"message": "课程资料重新分析完成",
|
||||
"course_id": course_id,
|
||||
"status": "completed",
|
||||
"materials_count": result.get("materials_count", 0),
|
||||
"success_count": result.get("success_count", 0),
|
||||
"knowledge_points_count": result.get("knowledge_points_count", 0),
|
||||
"analysis_results": result.get("analysis_results", [])
|
||||
},
|
||||
message="重新分析完成"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"启动课程资料重新分析失败 - course_id: {course_id}, error: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="启动重新分析失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/engines", response_model=ResponseModel[Dict[str, Any]])
|
||||
async def list_analysis_engines():
|
||||
"""
|
||||
获取可用的分析引擎列表
|
||||
"""
|
||||
return ResponseModel(
|
||||
data={
|
||||
"engines": [
|
||||
{
|
||||
"id": "native",
|
||||
"name": "Python 原生实现",
|
||||
"description": "使用本地 AI 服务(4sapi.com + OpenRouter),稳定可靠",
|
||||
"default": True
|
||||
}
|
||||
],
|
||||
"default_engine": "native"
|
||||
},
|
||||
message="获取分析引擎列表成功"
|
||||
)
|
||||
8
backend/app/api/v1/manager/__init__.py
Normal file
8
backend/app/api/v1/manager/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
管理员相关API模块
|
||||
"""
|
||||
from .student_scores import router as student_scores_router
|
||||
from .student_practice import router as student_practice_router
|
||||
|
||||
__all__ = ["student_scores_router", "student_practice_router"]
|
||||
|
||||
345
backend/app/api/v1/manager/student_practice.py
Normal file
345
backend/app/api/v1/manager/student_practice.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
管理员查看学员陪练记录API
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.practice import PracticeReport, PracticeSession, PracticeDialogue
|
||||
from app.models.user import User
|
||||
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/manager/student-practice", tags=["manager-student-practice"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ResponseModel[PaginatedResponse])
|
||||
async def get_student_practice_records(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
student_name: Optional[str] = Query(None, description="学员姓名搜索"),
|
||||
position: Optional[str] = Query(None, description="岗位筛选"),
|
||||
scene_type: Optional[str] = Query(None, description="场景类型筛选"),
|
||||
result: Optional[str] = Query(None, description="结果筛选: excellent/good/average/needs_improvement"),
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取所有用户的陪练记录列表(管理员和manager可访问)
|
||||
|
||||
包含所有角色(trainee/admin/manager)的陪练记录,方便测试和全面管理
|
||||
|
||||
支持筛选:
|
||||
- student_name: 按用户姓名模糊搜索
|
||||
- position: 按岗位筛选
|
||||
- scene_type: 按场景类型筛选
|
||||
- result: 按结果筛选(优秀/良好/一般/需改进)
|
||||
- start_date/end_date: 按日期范围筛选
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 构建基础查询
|
||||
# 关联User、PracticeReport来获取完整信息
|
||||
query = (
|
||||
select(
|
||||
PracticeSession,
|
||||
User.full_name.label('student_name'),
|
||||
User.id.label('student_id'),
|
||||
PracticeReport.total_score
|
||||
)
|
||||
.join(User, PracticeSession.user_id == User.id)
|
||||
.outerjoin(
|
||||
PracticeReport,
|
||||
PracticeSession.session_id == PracticeReport.session_id
|
||||
)
|
||||
.where(
|
||||
# 管理员可以查看所有人的陪练记录(包括其他管理员的),方便测试和全面管理
|
||||
PracticeSession.status == 'completed', # 只查询已完成的陪练
|
||||
PracticeSession.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
# 学员姓名筛选
|
||||
if student_name:
|
||||
query = query.where(User.full_name.contains(student_name))
|
||||
|
||||
# 岗位筛选
|
||||
if position:
|
||||
# 通过position_members关联查询
|
||||
query = query.join(
|
||||
PositionMember,
|
||||
and_(
|
||||
PositionMember.user_id == User.id,
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
).join(
|
||||
Position,
|
||||
Position.id == PositionMember.position_id
|
||||
).where(
|
||||
Position.name == position
|
||||
)
|
||||
|
||||
# 场景类型筛选
|
||||
if scene_type:
|
||||
query = query.where(PracticeSession.scene_type == scene_type)
|
||||
|
||||
# 结果筛选(根据分数)
|
||||
if result:
|
||||
if result == 'excellent':
|
||||
query = query.where(PracticeReport.total_score >= 90)
|
||||
elif result == 'good':
|
||||
query = query.where(and_(
|
||||
PracticeReport.total_score >= 80,
|
||||
PracticeReport.total_score < 90
|
||||
))
|
||||
elif result == 'average':
|
||||
query = query.where(and_(
|
||||
PracticeReport.total_score >= 70,
|
||||
PracticeReport.total_score < 80
|
||||
))
|
||||
elif result == 'needs_improvement':
|
||||
query = query.where(PracticeReport.total_score < 70)
|
||||
|
||||
# 日期范围筛选
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
query = query.where(PracticeSession.start_time >= start_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.where(PracticeSession.start_time <= end_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 按开始时间倒序
|
||||
query = query.order_by(PracticeSession.start_time.desc())
|
||||
|
||||
# 计算总数
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * size
|
||||
results = await db.execute(query.offset(offset).limit(size))
|
||||
|
||||
# 构建响应数据
|
||||
items = []
|
||||
for session, student_name, student_id, total_score in results:
|
||||
# 查询该学员的所有岗位
|
||||
position_query = (
|
||||
select(Position.name)
|
||||
.join(PositionMember, Position.id == PositionMember.position_id)
|
||||
.where(
|
||||
PositionMember.user_id == student_id,
|
||||
PositionMember.is_deleted == False,
|
||||
Position.is_deleted == False
|
||||
)
|
||||
)
|
||||
position_result = await db.execute(position_query)
|
||||
positions = position_result.scalars().all()
|
||||
position_str = ', '.join(positions) if positions else None
|
||||
|
||||
# 根据分数计算结果等级
|
||||
result_level = "needs_improvement"
|
||||
if total_score:
|
||||
if total_score >= 90:
|
||||
result_level = "excellent"
|
||||
elif total_score >= 80:
|
||||
result_level = "good"
|
||||
elif total_score >= 70:
|
||||
result_level = "average"
|
||||
|
||||
items.append({
|
||||
"id": session.id,
|
||||
"student_id": student_id,
|
||||
"student_name": student_name,
|
||||
"position": position_str, # 所有岗位,逗号分隔
|
||||
"session_id": session.session_id,
|
||||
"scene_name": session.scene_name,
|
||||
"scene_type": session.scene_type,
|
||||
"duration_seconds": session.duration_seconds,
|
||||
"round_count": session.turns, # turns字段表示对话轮数
|
||||
"score": total_score,
|
||||
"result": result_level,
|
||||
"practice_time": session.start_time.strftime('%Y-%m-%d %H:%M:%S') if session.start_time else None
|
||||
})
|
||||
|
||||
# 计算分页信息
|
||||
pages = (total + size - 1) // size
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=PaginatedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=size,
|
||||
pages=pages
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取学员陪练记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取学员陪练记录失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=ResponseModel)
|
||||
async def get_student_practice_statistics(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取学员陪练统计数据
|
||||
|
||||
返回:
|
||||
- total_count: 总陪练次数
|
||||
- avg_score: 平均评分
|
||||
- total_duration_hours: 总陪练时长(小时)
|
||||
- excellent_rate: 优秀率
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 查询所有已完成陪练(包括所有角色)
|
||||
query = (
|
||||
select(PracticeSession, PracticeReport.total_score)
|
||||
.join(User, PracticeSession.user_id == User.id)
|
||||
.outerjoin(
|
||||
PracticeReport,
|
||||
PracticeSession.session_id == PracticeReport.session_id
|
||||
)
|
||||
.where(
|
||||
PracticeSession.status == 'completed',
|
||||
PracticeSession.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = result.all()
|
||||
|
||||
if not records:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"total_count": 0,
|
||||
"avg_score": 0,
|
||||
"total_duration_hours": 0,
|
||||
"excellent_rate": 0
|
||||
}
|
||||
)
|
||||
|
||||
total_count = len(records)
|
||||
|
||||
# 计算总时长(秒转小时)
|
||||
total_duration_seconds = sum(
|
||||
session.duration_seconds for session, _ in records if session.duration_seconds
|
||||
)
|
||||
total_duration_hours = round(total_duration_seconds / 3600, 1)
|
||||
|
||||
# 计算平均分
|
||||
scores = [score for _, score in records if score is not None]
|
||||
avg_score = round(sum(scores) / len(scores), 1) if scores else 0
|
||||
|
||||
# 计算优秀率(>=90分)
|
||||
excellent = sum(1 for _, score in records if score and score >= 90)
|
||||
excellent_rate = round((excellent / total_count) * 100, 1) if total_count > 0 else 0
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"total_count": total_count,
|
||||
"avg_score": avg_score,
|
||||
"total_duration_hours": total_duration_hours,
|
||||
"excellent_rate": excellent_rate
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取学员陪练统计失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取学员陪练统计失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/{session_id}/conversation", response_model=ResponseModel)
|
||||
async def get_session_conversation(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取指定会话的对话记录
|
||||
|
||||
返回对话列表,按sequence排序
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 1. 查询会话是否存在
|
||||
session_query = select(PracticeSession).where(
|
||||
PracticeSession.session_id == session_id,
|
||||
PracticeSession.is_deleted == False
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
return ResponseModel(code=404, message="会话不存在", data=None)
|
||||
|
||||
# 2. 查询对话记录
|
||||
dialogue_query = (
|
||||
select(PracticeDialogue)
|
||||
.where(PracticeDialogue.session_id == session_id)
|
||||
.order_by(PracticeDialogue.sequence)
|
||||
)
|
||||
dialogue_result = await db.execute(dialogue_query)
|
||||
dialogues = dialogue_result.scalars().all()
|
||||
|
||||
# 3. 构建响应数据
|
||||
conversation = []
|
||||
for dialogue in dialogues:
|
||||
conversation.append({
|
||||
"role": dialogue.speaker, # "user" 或 "ai"
|
||||
"content": dialogue.content,
|
||||
"timestamp": dialogue.timestamp.strftime('%Y-%m-%d %H:%M:%S') if dialogue.timestamp else None,
|
||||
"sequence": dialogue.sequence
|
||||
})
|
||||
|
||||
logger.info(f"获取会话对话记录: session_id={session_id}, 对话数={len(conversation)}")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"conversation": conversation,
|
||||
"total_count": len(conversation)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话对话记录失败: {e}, session_id={session_id}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取对话记录失败: {str(e)}", data=None)
|
||||
|
||||
447
backend/app/api/v1/manager/student_scores.py
Normal file
447
backend/app/api/v1/manager/student_scores.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
管理员查看学员考试成绩API
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.course import Course
|
||||
from app.models.exam import Exam
|
||||
from app.models.exam_mistake import ExamMistake
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.position import Position
|
||||
from app.models.user import User
|
||||
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/manager/student-scores", tags=["manager-student-scores"])
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求"""
|
||||
ids: List[int]
|
||||
|
||||
|
||||
@router.get("/{exam_id}/mistakes", response_model=ResponseModel[PaginatedResponse])
|
||||
async def get_exam_mistakes(
|
||||
exam_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取指定考试的错题记录(管理员和manager可访问)
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 查询错题记录
|
||||
query = (
|
||||
select(ExamMistake)
|
||||
.options(selectinload(ExamMistake.question))
|
||||
.where(ExamMistake.exam_id == exam_id)
|
||||
.order_by(ExamMistake.created_at.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
mistakes = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for mistake in mistakes:
|
||||
# 获取解析:优先从关联题目获取,如果是AI生成的题目可能没有关联题目
|
||||
analysis = ""
|
||||
if mistake.question and mistake.question.explanation:
|
||||
analysis = mistake.question.explanation
|
||||
|
||||
items.append({
|
||||
"id": mistake.id,
|
||||
"question_content": mistake.question_content,
|
||||
"correct_answer": mistake.correct_answer,
|
||||
"user_answer": mistake.user_answer,
|
||||
"question_type": mistake.question_type,
|
||||
"analysis": analysis,
|
||||
"created_at": mistake.created_at.strftime('%Y-%m-%d %H:%M:%S') if mistake.created_at else None
|
||||
})
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=PaginatedResponse(
|
||||
items=items,
|
||||
total=len(items),
|
||||
page=1,
|
||||
page_size=len(items),
|
||||
pages=1
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取错题记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取错题记录失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/", response_model=ResponseModel[PaginatedResponse])
|
||||
async def get_student_scores(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
student_name: Optional[str] = Query(None, description="学员姓名搜索"),
|
||||
position: Optional[str] = Query(None, description="岗位筛选"),
|
||||
course_id: Optional[int] = Query(None, description="课程ID筛选"),
|
||||
score_range: Optional[str] = Query(None, description="成绩范围: excellent/good/pass/fail"),
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取所有学员的考试成绩列表(管理员和manager可访问)
|
||||
|
||||
支持筛选:
|
||||
- student_name: 按学员姓名模糊搜索
|
||||
- position: 按岗位筛选
|
||||
- course_id: 按课程筛选
|
||||
- score_range: 按成绩范围筛选(excellent>=90, good>=80, pass>=60, fail<60)
|
||||
- start_date/end_date: 按日期范围筛选
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 构建基础查询
|
||||
# 关联User、Course、ExamMistake来获取完整信息
|
||||
query = (
|
||||
select(
|
||||
Exam,
|
||||
User.full_name.label('student_name'),
|
||||
User.id.label('student_id'),
|
||||
Course.name.label('course_name'),
|
||||
func.count(ExamMistake.id).label('wrong_count')
|
||||
)
|
||||
.join(User, Exam.user_id == User.id)
|
||||
.join(Course, Exam.course_id == Course.id)
|
||||
.outerjoin(ExamMistake, and_(
|
||||
ExamMistake.exam_id == Exam.id,
|
||||
ExamMistake.user_id == User.id
|
||||
))
|
||||
.where(
|
||||
Exam.status.in_(['completed', 'submitted']) # 只查询已完成的考试
|
||||
)
|
||||
.group_by(Exam.id, User.id, User.full_name, Course.id, Course.name)
|
||||
)
|
||||
|
||||
# 学员姓名筛选
|
||||
if student_name:
|
||||
query = query.where(User.full_name.contains(student_name))
|
||||
|
||||
# 岗位筛选
|
||||
if position:
|
||||
# 通过position_members关联查询
|
||||
query = query.join(
|
||||
PositionMember,
|
||||
and_(
|
||||
PositionMember.user_id == User.id,
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
).join(
|
||||
Position,
|
||||
Position.id == PositionMember.position_id
|
||||
).where(
|
||||
Position.name == position
|
||||
)
|
||||
|
||||
# 课程筛选
|
||||
if course_id:
|
||||
query = query.where(Exam.course_id == course_id)
|
||||
|
||||
# 成绩范围筛选
|
||||
if score_range:
|
||||
score_field = Exam.round1_score # 使用第一轮成绩
|
||||
if score_range == 'excellent':
|
||||
query = query.where(score_field >= 90)
|
||||
elif score_range == 'good':
|
||||
query = query.where(and_(score_field >= 80, score_field < 90))
|
||||
elif score_range == 'pass':
|
||||
query = query.where(and_(score_field >= 60, score_field < 80))
|
||||
elif score_range == 'fail':
|
||||
query = query.where(score_field < 60)
|
||||
|
||||
# 日期范围筛选
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
query = query.where(Exam.created_at >= start_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
query = query.where(Exam.created_at <= end_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 按创建时间倒序
|
||||
query = query.order_by(Exam.created_at.desc())
|
||||
|
||||
# 计算总数
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * size
|
||||
results = await db.execute(query.offset(offset).limit(size))
|
||||
|
||||
# 构建响应数据
|
||||
items = []
|
||||
for exam, student_name, student_id, course_name, wrong_count in results:
|
||||
# 查询该学员的所有岗位
|
||||
position_query = (
|
||||
select(Position.name)
|
||||
.join(PositionMember, Position.id == PositionMember.position_id)
|
||||
.where(
|
||||
PositionMember.user_id == student_id,
|
||||
PositionMember.is_deleted == False,
|
||||
Position.is_deleted == False
|
||||
)
|
||||
)
|
||||
position_result = await db.execute(position_query)
|
||||
positions = position_result.scalars().all()
|
||||
position_str = ', '.join(positions) if positions else None
|
||||
|
||||
# 计算正确率和用时
|
||||
accuracy = None
|
||||
correct_count = None
|
||||
duration_seconds = None
|
||||
|
||||
if exam.question_count and exam.question_count > 0:
|
||||
correct_count = exam.question_count - wrong_count
|
||||
accuracy = round((correct_count / exam.question_count) * 100, 1)
|
||||
|
||||
if exam.start_time and exam.end_time:
|
||||
duration_seconds = int((exam.end_time - exam.start_time).total_seconds())
|
||||
|
||||
items.append({
|
||||
"id": exam.id,
|
||||
"student_id": student_id,
|
||||
"student_name": student_name,
|
||||
"position": position_str, # 所有岗位,逗号分隔
|
||||
"course_id": exam.course_id,
|
||||
"course_name": course_name,
|
||||
"exam_type": "assessment", # 简化处理,统一为assessment
|
||||
"score": float(exam.round1_score) if exam.round1_score else 0,
|
||||
"round1_score": float(exam.round1_score) if exam.round1_score else None,
|
||||
"round2_score": float(exam.round2_score) if exam.round2_score else None,
|
||||
"round3_score": float(exam.round3_score) if exam.round3_score else None,
|
||||
"total_score": float(exam.total_score) if exam.total_score else 100,
|
||||
"accuracy": accuracy,
|
||||
"correct_count": correct_count,
|
||||
"wrong_count": wrong_count,
|
||||
"total_count": exam.question_count,
|
||||
"duration_seconds": duration_seconds,
|
||||
"exam_date": exam.created_at.strftime('%Y-%m-%d %H:%M:%S') if exam.created_at else None
|
||||
})
|
||||
|
||||
# 计算分页信息
|
||||
pages = (total + size - 1) // size
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=PaginatedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=size,
|
||||
pages=pages
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取学员考试成绩失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取学员考试成绩失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=ResponseModel)
|
||||
async def get_student_scores_statistics(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取学员考试成绩统计数据
|
||||
|
||||
返回:
|
||||
- total_exams: 总考试次数
|
||||
- avg_score: 平均分
|
||||
- pass_rate: 通过率
|
||||
- excellent_rate: 优秀率
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role not in ['admin', 'manager']:
|
||||
return ResponseModel(code=403, message="无权访问", data=None)
|
||||
|
||||
# 查询所有用户的已完成考试
|
||||
query = (
|
||||
select(Exam)
|
||||
.join(User, Exam.user_id == User.id)
|
||||
.where(
|
||||
Exam.status.in_(['completed', 'submitted']),
|
||||
Exam.round1_score.isnot(None)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
exams = result.scalars().all()
|
||||
|
||||
if not exams:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"total_exams": 0,
|
||||
"avg_score": 0,
|
||||
"pass_rate": 0,
|
||||
"excellent_rate": 0
|
||||
}
|
||||
)
|
||||
|
||||
total_exams = len(exams)
|
||||
total_score = sum(exam.round1_score for exam in exams if exam.round1_score)
|
||||
avg_score = round(total_score / total_exams, 1) if total_exams > 0 else 0
|
||||
|
||||
passed = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 60)
|
||||
pass_rate = round((passed / total_exams) * 100, 1) if total_exams > 0 else 0
|
||||
|
||||
excellent = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 90)
|
||||
excellent_rate = round((excellent / total_exams) * 100, 1) if total_exams > 0 else 0
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"total_exams": total_exams,
|
||||
"avg_score": avg_score,
|
||||
"pass_rate": pass_rate,
|
||||
"excellent_rate": excellent_rate
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取学员考试成绩统计失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取学员考试成绩统计失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.delete("/{exam_id}", response_model=ResponseModel)
|
||||
async def delete_exam_record(
|
||||
exam_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
删除单条考试记录(管理员可访问)
|
||||
|
||||
会同时删除关联的错题记录
|
||||
"""
|
||||
try:
|
||||
# 权限检查 - 仅管理员可删除
|
||||
if current_user.role != 'admin':
|
||||
return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None)
|
||||
|
||||
# 查询考试记录
|
||||
result = await db.execute(
|
||||
select(Exam).where(Exam.id == exam_id)
|
||||
)
|
||||
exam = result.scalar_one_or_none()
|
||||
|
||||
if not exam:
|
||||
return ResponseModel(code=404, message="考试记录不存在", data=None)
|
||||
|
||||
# 删除关联的错题记录
|
||||
await db.execute(
|
||||
delete(ExamMistake).where(ExamMistake.exam_id == exam_id)
|
||||
)
|
||||
|
||||
# 删除考试记录
|
||||
await db.delete(exam)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 删除了考试记录 {exam_id}")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="考试记录已删除",
|
||||
data={"deleted_id": exam_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"删除考试记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"删除考试记录失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.delete("/batch/delete", response_model=ResponseModel)
|
||||
async def batch_delete_exam_records(
|
||||
request: BatchDeleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
批量删除考试记录(管理员可访问)
|
||||
|
||||
会同时删除关联的错题记录
|
||||
"""
|
||||
try:
|
||||
# 权限检查 - 仅管理员可删除
|
||||
if current_user.role != 'admin':
|
||||
return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None)
|
||||
|
||||
if not request.ids:
|
||||
return ResponseModel(code=400, message="请选择要删除的记录", data=None)
|
||||
|
||||
# 查询存在的考试记录
|
||||
result = await db.execute(
|
||||
select(Exam.id).where(Exam.id.in_(request.ids))
|
||||
)
|
||||
existing_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not existing_ids:
|
||||
return ResponseModel(code=404, message="未找到要删除的记录", data=None)
|
||||
|
||||
# 删除关联的错题记录
|
||||
await db.execute(
|
||||
delete(ExamMistake).where(ExamMistake.exam_id.in_(existing_ids))
|
||||
)
|
||||
|
||||
# 删除考试记录
|
||||
await db.execute(
|
||||
delete(Exam).where(Exam.id.in_(existing_ids))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
deleted_count = len(existing_ids)
|
||||
logger.info(f"管理员 {current_user.username} 批量删除了 {deleted_count} 条考试记录")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message=f"成功删除 {deleted_count} 条考试记录",
|
||||
data={
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_ids": existing_ids
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"批量删除考试记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"批量删除考试记录失败: {str(e)}", data=None)
|
||||
|
||||
255
backend/app/api/v1/notifications.py
Normal file
255
backend/app/api/v1/notifications.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
站内消息通知 API
|
||||
提供通知的查询、标记已读、删除等功能
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.notification import (
|
||||
NotificationCreate,
|
||||
NotificationBatchCreate,
|
||||
NotificationResponse,
|
||||
NotificationListResponse,
|
||||
NotificationCountResponse,
|
||||
MarkReadRequest,
|
||||
)
|
||||
from app.services.notification_service import notification_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
|
||||
|
||||
@router.get("", response_model=ResponseModel[NotificationListResponse])
|
||||
async def get_notifications(
|
||||
is_read: Optional[bool] = Query(None, description="是否已读筛选"),
|
||||
type: Optional[str] = Query(None, description="通知类型筛选"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取当前用户的通知列表
|
||||
|
||||
支持按已读状态和通知类型筛选
|
||||
"""
|
||||
try:
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
notifications, total, unread_count = await notification_service.get_user_notifications(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
is_read=is_read,
|
||||
notification_type=type
|
||||
)
|
||||
|
||||
response_data = NotificationListResponse(
|
||||
items=notifications,
|
||||
total=total,
|
||||
unread_count=unread_count
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取通知列表成功",
|
||||
data=response_data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取通知列表失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取通知列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=ResponseModel[NotificationCountResponse])
|
||||
async def get_unread_count(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取当前用户的未读通知数量
|
||||
|
||||
用于顶部导航栏显示未读消息数
|
||||
"""
|
||||
try:
|
||||
unread_count, total = await notification_service.get_unread_count(
|
||||
db=db,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取未读数量成功",
|
||||
data=NotificationCountResponse(
|
||||
unread_count=unread_count,
|
||||
total=total
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取未读数量失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取未读数量失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/mark-read", response_model=ResponseModel)
|
||||
async def mark_notifications_read(
|
||||
request: MarkReadRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
标记通知为已读
|
||||
|
||||
- 传入 notification_ids 则标记指定通知
|
||||
- 不传则标记全部未读通知为已读
|
||||
"""
|
||||
try:
|
||||
updated_count = await notification_service.mark_as_read(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
notification_ids=request.notification_ids
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message=f"成功标记 {updated_count} 条通知为已读",
|
||||
data={"updated_count": updated_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"标记已读失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"标记已读失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", response_model=ResponseModel)
|
||||
async def delete_notification(
|
||||
notification_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
删除单条通知
|
||||
|
||||
只能删除自己的通知
|
||||
"""
|
||||
try:
|
||||
success = await notification_service.delete_notification(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
notification_id=notification_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="通知不存在或无权删除")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="删除通知成功",
|
||||
data={"deleted": True}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"删除通知失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"删除通知失败: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 管理员接口 ====================
|
||||
|
||||
@router.post("/send", response_model=ResponseModel[NotificationResponse])
|
||||
async def send_notification(
|
||||
notification_in: NotificationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
发送单条通知(管理员接口)
|
||||
|
||||
向指定用户发送通知
|
||||
"""
|
||||
try:
|
||||
# 权限检查:仅管理员和管理者可发送通知
|
||||
if current_user.role not in ["admin", "manager"]:
|
||||
raise HTTPException(status_code=403, detail="无权限发送通知")
|
||||
|
||||
# 设置发送者
|
||||
notification_in.sender_id = current_user.id
|
||||
|
||||
notification = await notification_service.create_notification(
|
||||
db=db,
|
||||
notification_in=notification_in
|
||||
)
|
||||
|
||||
# 构建响应
|
||||
response = NotificationResponse(
|
||||
id=notification.id,
|
||||
user_id=notification.user_id,
|
||||
title=notification.title,
|
||||
content=notification.content,
|
||||
type=notification.type,
|
||||
is_read=notification.is_read,
|
||||
related_id=notification.related_id,
|
||||
related_type=notification.related_type,
|
||||
sender_id=notification.sender_id,
|
||||
sender_name=current_user.full_name,
|
||||
created_at=notification.created_at,
|
||||
updated_at=notification.updated_at
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="发送通知成功",
|
||||
data=response
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"发送通知失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/send-batch", response_model=ResponseModel)
|
||||
async def send_batch_notifications(
|
||||
batch_in: NotificationBatchCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
批量发送通知(管理员接口)
|
||||
|
||||
向多个用户发送相同的通知
|
||||
"""
|
||||
try:
|
||||
# 权限检查:仅管理员和管理者可发送通知
|
||||
if current_user.role not in ["admin", "manager"]:
|
||||
raise HTTPException(status_code=403, detail="无权限发送通知")
|
||||
|
||||
# 设置发送者
|
||||
batch_in.sender_id = current_user.id
|
||||
|
||||
notifications = await notification_service.batch_create_notifications(
|
||||
db=db,
|
||||
batch_in=batch_in
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message=f"成功发送 {len(notifications)} 条通知",
|
||||
data={"sent_count": len(notifications)}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"批量发送通知失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"批量发送通知失败: {str(e)}")
|
||||
|
||||
658
backend/app/api/v1/positions.py
Normal file
658
backend/app/api/v1/positions.py
Normal file
@@ -0,0 +1,658 @@
|
||||
"""
|
||||
岗位管理 API(真实数据库)
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db, require_admin, require_admin_or_manager
|
||||
from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.position_course import PositionCourse
|
||||
from app.models.user import User
|
||||
from app.models.course import Course
|
||||
|
||||
|
||||
router = APIRouter(prefix="/admin/positions")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_positions(
|
||||
pagination: PaginationParams = Depends(),
|
||||
keyword: Optional[str] = Query(None, description="关键词"),
|
||||
current_user=Depends(require_admin_or_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""分页获取岗位列表(管理员或经理)。"""
|
||||
stmt = select(Position).where(Position.is_deleted == False)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
stmt = stmt.where((Position.name.ilike(like)) | (Position.description.ilike(like)))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
total = len(rows)
|
||||
sliced = rows[pagination.offset : pagination.offset + pagination.limit]
|
||||
|
||||
async def to_dict(p: Position) -> dict:
|
||||
"""将Position对象转换为字典,并添加统计数据"""
|
||||
d = p.__dict__.copy()
|
||||
d.pop("_sa_instance_state", None)
|
||||
|
||||
# 统计岗位成员数量
|
||||
member_count_result = await db.execute(
|
||||
select(func.count(PositionMember.id)).where(
|
||||
and_(
|
||||
PositionMember.position_id == p.id,
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
d["memberCount"] = member_count_result.scalar() or 0
|
||||
|
||||
# 统计必修课程数量
|
||||
required_count_result = await db.execute(
|
||||
select(func.count(PositionCourse.id)).where(
|
||||
and_(
|
||||
PositionCourse.position_id == p.id,
|
||||
PositionCourse.course_type == "required",
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
d["requiredCourses"] = required_count_result.scalar() or 0
|
||||
|
||||
# 统计选修课程数量
|
||||
optional_count_result = await db.execute(
|
||||
select(func.count(PositionCourse.id)).where(
|
||||
and_(
|
||||
PositionCourse.position_id == p.id,
|
||||
PositionCourse.course_type == "optional",
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
d["optionalCourses"] = optional_count_result.scalar() or 0
|
||||
|
||||
return d
|
||||
|
||||
# 为每个岗位添加统计数据(使用异步)
|
||||
items = []
|
||||
for p in sliced:
|
||||
item = await to_dict(p)
|
||||
items.append(item)
|
||||
|
||||
paged = {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": pagination.page,
|
||||
"page_size": pagination.page_size,
|
||||
"pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1,
|
||||
}
|
||||
return ResponseModel(message="获取岗位列表成功", data=paged)
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_position_tree(
|
||||
current_user=Depends(require_admin_or_manager), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
"""获取岗位树(管理员或经理)。"""
|
||||
rows = (await db.execute(select(Position).where(Position.is_deleted == False))).scalars().all()
|
||||
id_to_node = {p.id: {**p.__dict__, "children": []} for p in rows}
|
||||
roots: List[dict] = []
|
||||
for p in rows:
|
||||
node = id_to_node[p.id]
|
||||
parent_id = p.parent_id
|
||||
if parent_id and parent_id in id_to_node:
|
||||
id_to_node[parent_id]["children"].append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
# 清理 _sa_instance_state
|
||||
def clean(d: dict):
|
||||
d.pop("_sa_instance_state", None)
|
||||
for c in d.get("children", []):
|
||||
clean(c)
|
||||
for r in roots:
|
||||
clean(r)
|
||||
return ResponseModel(message="获取岗位树成功", data=roots)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_position(
|
||||
payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
obj = Position(
|
||||
name=payload.get("name"),
|
||||
code=payload.get("code"),
|
||||
description=payload.get("description"),
|
||||
parent_id=payload.get("parentId"),
|
||||
status=payload.get("status", "active"),
|
||||
skills=payload.get("skills"),
|
||||
level=payload.get("level"),
|
||||
sort_order=payload.get("sort_order", 0),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(obj)
|
||||
await db.commit()
|
||||
await db.refresh(obj)
|
||||
return ResponseModel(message="创建岗位成功", data={"id": obj.id})
|
||||
|
||||
|
||||
@router.put("/{position_id}")
|
||||
async def update_position(
|
||||
position_id: int, payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
obj = await db.get(Position, position_id)
|
||||
if not obj or obj.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
obj.name = payload.get("name", obj.name)
|
||||
obj.code = payload.get("code", obj.code)
|
||||
obj.description = payload.get("description", obj.description)
|
||||
obj.parent_id = payload.get("parentId", obj.parent_id)
|
||||
obj.status = payload.get("status", obj.status)
|
||||
obj.skills = payload.get("skills", obj.skills)
|
||||
obj.level = payload.get("level", obj.level)
|
||||
obj.sort_order = payload.get("sort_order", obj.sort_order)
|
||||
obj.updated_by = current_user.id
|
||||
await db.commit()
|
||||
await db.refresh(obj)
|
||||
|
||||
# 返回更新后的完整数据
|
||||
data = obj.__dict__.copy()
|
||||
data.pop("_sa_instance_state", None)
|
||||
return ResponseModel(message="更新岗位成功", data=data)
|
||||
|
||||
|
||||
@router.get("/{position_id}")
|
||||
async def get_position_detail(
|
||||
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
obj = await db.get(Position, position_id)
|
||||
if not obj or obj.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
data = obj.__dict__.copy()
|
||||
data.pop("_sa_instance_state", None)
|
||||
return ResponseModel(data=data)
|
||||
|
||||
|
||||
@router.get("/{position_id}/check-delete")
|
||||
async def check_position_delete(
|
||||
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
obj = await db.get(Position, position_id)
|
||||
if not obj or obj.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
# 检查是否有子岗位
|
||||
child_count_result = await db.execute(
|
||||
select(func.count(Position.id)).where(
|
||||
and_(
|
||||
Position.parent_id == position_id,
|
||||
Position.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
child_count = child_count_result.scalar() or 0
|
||||
|
||||
if child_count > 0:
|
||||
return ResponseModel(data={
|
||||
"deletable": False,
|
||||
"reason": f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位"
|
||||
})
|
||||
|
||||
# 检查是否有成员(仅作为提醒,不阻止删除)
|
||||
member_count_result = await db.execute(
|
||||
select(func.count(PositionMember.id)).where(
|
||||
and_(
|
||||
PositionMember.position_id == position_id,
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
member_count = member_count_result.scalar() or 0
|
||||
|
||||
warning = ""
|
||||
if member_count > 0:
|
||||
warning = f"注意:该岗位当前有 {member_count} 名成员,删除后这些成员将不再属于此岗位"
|
||||
|
||||
return ResponseModel(data={"deletable": True, "reason": "", "warning": warning, "member_count": member_count})
|
||||
|
||||
|
||||
@router.delete("/{position_id}")
|
||||
async def delete_position(
|
||||
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
obj = await db.get(Position, position_id)
|
||||
if not obj or obj.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
# 检查是否有子岗位
|
||||
child_count_result = await db.execute(
|
||||
select(func.count(Position.id)).where(
|
||||
and_(
|
||||
Position.parent_id == position_id,
|
||||
Position.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
child_count = child_count_result.scalar() or 0
|
||||
|
||||
if child_count > 0:
|
||||
return ResponseModel(
|
||||
code=400,
|
||||
message=f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位"
|
||||
)
|
||||
|
||||
# 软删除岗位成员关联
|
||||
await db.execute(
|
||||
sa.update(PositionMember)
|
||||
.where(PositionMember.position_id == position_id)
|
||||
.values(is_deleted=True)
|
||||
)
|
||||
|
||||
# 软删除岗位课程关联
|
||||
await db.execute(
|
||||
sa.update(PositionCourse)
|
||||
.where(PositionCourse.position_id == position_id)
|
||||
.values(is_deleted=True)
|
||||
)
|
||||
|
||||
# 软删除岗位
|
||||
obj.is_deleted = True
|
||||
await db.commit()
|
||||
return ResponseModel(message="岗位已删除")
|
||||
|
||||
|
||||
# ========== 岗位成员管理 API ==========
|
||||
|
||||
@router.get("/{position_id}/members")
|
||||
async def get_position_members(
|
||||
position_id: int,
|
||||
pagination: PaginationParams = Depends(),
|
||||
keyword: Optional[str] = Query(None, description="搜索关键词"),
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""获取岗位成员列表"""
|
||||
# 验证岗位存在
|
||||
position = await db.get(Position, position_id)
|
||||
if not position or position.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
# 构建查询
|
||||
stmt = (
|
||||
select(PositionMember, User)
|
||||
.join(User, PositionMember.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
PositionMember.position_id == position_id,
|
||||
PositionMember.is_deleted == False,
|
||||
User.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 关键词搜索
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
stmt = stmt.where(
|
||||
(User.username.ilike(like)) |
|
||||
(User.full_name.ilike(like)) |
|
||||
(User.email.ilike(like))
|
||||
)
|
||||
|
||||
# 执行查询
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
total = len(rows)
|
||||
sliced = rows[pagination.offset : pagination.offset + pagination.limit]
|
||||
|
||||
# 格式化数据
|
||||
items = []
|
||||
for pm, user in sliced:
|
||||
items.append({
|
||||
"id": pm.id,
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"role": pm.role,
|
||||
"joined_at": pm.joined_at.isoformat() if pm.joined_at else None,
|
||||
"user_role": user.role, # 系统角色
|
||||
"is_active": user.is_active,
|
||||
})
|
||||
|
||||
return ResponseModel(
|
||||
message="获取成员列表成功",
|
||||
data={
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": pagination.page,
|
||||
"page_size": pagination.page_size,
|
||||
"pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{position_id}/members")
|
||||
async def add_position_members(
|
||||
position_id: int,
|
||||
payload: dict,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""批量添加岗位成员"""
|
||||
# 验证岗位存在
|
||||
position = await db.get(Position, position_id)
|
||||
if not position or position.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
user_ids = payload.get("user_ids", [])
|
||||
if not user_ids:
|
||||
return ResponseModel(code=400, message="请选择要添加的用户")
|
||||
|
||||
# 验证用户存在
|
||||
users = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.id.in_(user_ids),
|
||||
User.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
valid_users = {u.id: u for u in users.scalars().all()}
|
||||
|
||||
if len(valid_users) != len(user_ids):
|
||||
invalid_ids = set(user_ids) - set(valid_users.keys())
|
||||
return ResponseModel(code=400, message=f"部分用户不存在: {invalid_ids}")
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await db.execute(
|
||||
select(PositionMember).where(
|
||||
and_(
|
||||
PositionMember.position_id == position_id,
|
||||
PositionMember.user_id.in_(user_ids),
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_user_ids = {pm.user_id for pm in existing.scalars().all()}
|
||||
|
||||
# 添加新成员
|
||||
added_count = 0
|
||||
for user_id in user_ids:
|
||||
if user_id not in existing_user_ids:
|
||||
member = PositionMember(
|
||||
position_id=position_id,
|
||||
user_id=user_id,
|
||||
role=payload.get("role")
|
||||
)
|
||||
db.add(member)
|
||||
added_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ResponseModel(
|
||||
message=f"成功添加 {added_count} 个成员",
|
||||
data={"added_count": added_count}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{position_id}/members/{user_id}")
|
||||
async def remove_position_member(
|
||||
position_id: int,
|
||||
user_id: int,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""移除岗位成员"""
|
||||
# 查找成员关系
|
||||
member = await db.execute(
|
||||
select(PositionMember).where(
|
||||
and_(
|
||||
PositionMember.position_id == position_id,
|
||||
PositionMember.user_id == user_id,
|
||||
PositionMember.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
member = member.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
return ResponseModel(code=404, message="成员关系不存在")
|
||||
|
||||
# 软删除
|
||||
member.is_deleted = True
|
||||
await db.commit()
|
||||
|
||||
return ResponseModel(message="成员已移除")
|
||||
|
||||
|
||||
# ========== 岗位课程管理 API ==========
|
||||
|
||||
@router.get("/{position_id}/courses")
|
||||
async def get_position_courses(
|
||||
position_id: int,
|
||||
course_type: Optional[str] = Query(None, description="课程类型:required/optional"),
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""获取岗位课程列表"""
|
||||
# 验证岗位存在
|
||||
position = await db.get(Position, position_id)
|
||||
if not position or position.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
# 构建查询
|
||||
stmt = (
|
||||
select(PositionCourse, Course)
|
||||
.join(Course, PositionCourse.course_id == Course.id)
|
||||
.where(
|
||||
and_(
|
||||
PositionCourse.position_id == position_id,
|
||||
PositionCourse.is_deleted == False,
|
||||
Course.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 课程类型筛选
|
||||
if course_type:
|
||||
stmt = stmt.where(PositionCourse.course_type == course_type)
|
||||
|
||||
# 按优先级排序
|
||||
stmt = stmt.order_by(PositionCourse.priority, PositionCourse.id)
|
||||
|
||||
# 执行查询
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 格式化数据
|
||||
items = []
|
||||
for pc, course in rows:
|
||||
items.append({
|
||||
"id": pc.id,
|
||||
"course_id": course.id,
|
||||
"course_name": course.name,
|
||||
"course_description": course.description,
|
||||
"course_category": course.category,
|
||||
"course_status": course.status,
|
||||
"course_duration_hours": course.duration_hours,
|
||||
"course_difficulty_level": course.difficulty_level,
|
||||
"course_type": pc.course_type,
|
||||
"priority": pc.priority,
|
||||
"created_at": pc.created_at.isoformat() if pc.created_at else None,
|
||||
})
|
||||
|
||||
# 统计
|
||||
stats = {
|
||||
"total": len(items),
|
||||
"required_count": sum(1 for item in items if item["course_type"] == "required"),
|
||||
"optional_count": sum(1 for item in items if item["course_type"] == "optional"),
|
||||
}
|
||||
|
||||
return ResponseModel(
|
||||
message="获取课程列表成功",
|
||||
data={
|
||||
"items": items,
|
||||
"stats": stats
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{position_id}/courses")
|
||||
async def add_position_courses(
|
||||
position_id: int,
|
||||
payload: dict,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""批量添加岗位课程"""
|
||||
# 验证岗位存在
|
||||
position = await db.get(Position, position_id)
|
||||
if not position or position.is_deleted:
|
||||
return ResponseModel(code=404, message="岗位不存在")
|
||||
|
||||
course_ids = payload.get("course_ids", [])
|
||||
if not course_ids:
|
||||
return ResponseModel(code=400, message="请选择要添加的课程")
|
||||
|
||||
course_type = payload.get("course_type", "required")
|
||||
if course_type not in ["required", "optional"]:
|
||||
return ResponseModel(code=400, message="课程类型无效")
|
||||
|
||||
# 验证课程存在
|
||||
courses = await db.execute(
|
||||
select(Course).where(
|
||||
and_(
|
||||
Course.id.in_(course_ids),
|
||||
Course.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
valid_courses = {c.id: c for c in courses.scalars().all()}
|
||||
|
||||
if len(valid_courses) != len(course_ids):
|
||||
invalid_ids = set(course_ids) - set(valid_courses.keys())
|
||||
return ResponseModel(code=400, message=f"部分课程不存在: {invalid_ids}")
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await db.execute(
|
||||
select(PositionCourse).where(
|
||||
and_(
|
||||
PositionCourse.position_id == position_id,
|
||||
PositionCourse.course_id.in_(course_ids),
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_course_ids = {pc.course_id for pc in existing.scalars().all()}
|
||||
|
||||
# 获取当前最大优先级
|
||||
max_priority_result = await db.execute(
|
||||
select(sa.func.max(PositionCourse.priority)).where(
|
||||
and_(
|
||||
PositionCourse.position_id == position_id,
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
max_priority = max_priority_result.scalar() or 0
|
||||
|
||||
# 添加新课程
|
||||
added_count = 0
|
||||
for idx, course_id in enumerate(course_ids):
|
||||
if course_id not in existing_course_ids:
|
||||
pc = PositionCourse(
|
||||
position_id=position_id,
|
||||
course_id=course_id,
|
||||
course_type=course_type,
|
||||
priority=max_priority + idx + 1,
|
||||
)
|
||||
db.add(pc)
|
||||
added_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ResponseModel(
|
||||
message=f"成功添加 {added_count} 门课程",
|
||||
data={"added_count": added_count}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{position_id}/courses/{pc_id}")
|
||||
async def update_position_course(
|
||||
position_id: int,
|
||||
pc_id: int,
|
||||
payload: dict,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""更新岗位课程设置"""
|
||||
# 查找课程关系
|
||||
pc = await db.execute(
|
||||
select(PositionCourse).where(
|
||||
and_(
|
||||
PositionCourse.id == pc_id,
|
||||
PositionCourse.position_id == position_id,
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
pc = pc.scalar_one_or_none()
|
||||
|
||||
if not pc:
|
||||
return ResponseModel(code=404, message="课程关系不存在")
|
||||
|
||||
# 更新课程类型
|
||||
if "course_type" in payload:
|
||||
course_type = payload["course_type"]
|
||||
if course_type not in ["required", "optional"]:
|
||||
return ResponseModel(code=400, message="课程类型无效")
|
||||
pc.course_type = course_type
|
||||
|
||||
# 更新优先级
|
||||
if "priority" in payload:
|
||||
pc.priority = payload["priority"]
|
||||
|
||||
# PositionCourse 未继承审计字段,避免写入不存在字段
|
||||
await db.commit()
|
||||
|
||||
return ResponseModel(message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{position_id}/courses/{course_id}")
|
||||
async def remove_position_course(
|
||||
position_id: int,
|
||||
course_id: int,
|
||||
current_user=Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""移除岗位课程"""
|
||||
# 查找课程关系
|
||||
pc = await db.execute(
|
||||
select(PositionCourse).where(
|
||||
and_(
|
||||
PositionCourse.position_id == position_id,
|
||||
PositionCourse.course_id == course_id,
|
||||
PositionCourse.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
pc = pc.scalar_one_or_none()
|
||||
|
||||
if not pc:
|
||||
return ResponseModel(code=404, message="课程关系不存在")
|
||||
|
||||
# 软删除
|
||||
pc.is_deleted = True
|
||||
# PositionCourse 未继承审计字段,避免写入不存在字段
|
||||
await db.commit()
|
||||
|
||||
return ResponseModel(message="课程已移除")
|
||||
|
||||
|
||||
1139
backend/app/api/v1/practice.py
Normal file
1139
backend/app/api/v1/practice.py
Normal file
File diff suppressed because it is too large
Load Diff
285
backend/app/api/v1/preview.py
Normal file
285
backend/app/api/v1/preview.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
文件预览API
|
||||
提供课程资料的在线预览功能
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.course import CourseMaterial
|
||||
from app.services.document_converter import document_converter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PreviewType:
|
||||
"""预览类型常量
|
||||
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
||||
"""
|
||||
PDF = "pdf"
|
||||
TEXT = "text"
|
||||
HTML = "html"
|
||||
EXCEL_HTML = "excel_html" # Excel转HTML预览
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
IMAGE = "image"
|
||||
DOWNLOAD = "download"
|
||||
|
||||
|
||||
# 文件类型到预览类型的映射
|
||||
FILE_TYPE_MAPPING = {
|
||||
# PDF - 直接预览
|
||||
'.pdf': PreviewType.PDF,
|
||||
|
||||
# 文本 - 直接显示内容
|
||||
'.txt': PreviewType.TEXT,
|
||||
'.md': PreviewType.TEXT,
|
||||
'.mdx': PreviewType.TEXT,
|
||||
'.csv': PreviewType.TEXT,
|
||||
'.vtt': PreviewType.TEXT,
|
||||
'.properties': PreviewType.TEXT,
|
||||
|
||||
# HTML - 在iframe中预览
|
||||
'.html': PreviewType.HTML,
|
||||
'.htm': PreviewType.HTML,
|
||||
}
|
||||
|
||||
|
||||
def get_preview_type(file_ext: str) -> str:
|
||||
"""
|
||||
根据文件扩展名获取预览类型
|
||||
|
||||
Args:
|
||||
file_ext: 文件扩展名(带点,如 .pdf)
|
||||
|
||||
Returns:
|
||||
预览类型
|
||||
"""
|
||||
file_ext_lower = file_ext.lower()
|
||||
|
||||
# 直接映射的类型
|
||||
if file_ext_lower in FILE_TYPE_MAPPING:
|
||||
return FILE_TYPE_MAPPING[file_ext_lower]
|
||||
|
||||
# Excel文件使用HTML预览(避免分页问题)
|
||||
if file_ext_lower in {'.xlsx', '.xls'}:
|
||||
return PreviewType.EXCEL_HTML
|
||||
|
||||
# 其他Office文档,需要转换为PDF预览
|
||||
if document_converter.is_convertible(file_ext_lower):
|
||||
return PreviewType.PDF
|
||||
|
||||
# 其他类型,只提供下载
|
||||
return PreviewType.DOWNLOAD
|
||||
|
||||
|
||||
def get_file_path_from_url(file_url: str) -> Optional[Path]:
|
||||
"""
|
||||
从文件URL获取本地文件路径
|
||||
|
||||
Args:
|
||||
file_url: 文件URL(如 /static/uploads/courses/1/xxx.pdf)
|
||||
|
||||
Returns:
|
||||
本地文件路径,如果无效返回None
|
||||
"""
|
||||
try:
|
||||
# 移除 /static/uploads/ 前缀
|
||||
if file_url.startswith('/static/uploads/'):
|
||||
relative_path = file_url.replace('/static/uploads/', '')
|
||||
full_path = Path(settings.UPLOAD_PATH) / relative_path
|
||||
return full_path
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/material/{material_id}", response_model=ResponseModel[dict])
|
||||
async def get_material_preview(
|
||||
material_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取资料预览信息
|
||||
|
||||
Args:
|
||||
material_id: 资料ID
|
||||
|
||||
Returns:
|
||||
预览信息,包括预览类型、预览URL等
|
||||
"""
|
||||
try:
|
||||
# 查询资料信息
|
||||
stmt = select(CourseMaterial).where(
|
||||
CourseMaterial.id == material_id,
|
||||
CourseMaterial.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
material = result.scalar_one_or_none()
|
||||
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="资料不存在"
|
||||
)
|
||||
|
||||
# TODO: 权限检查 - 确认当前用户是否有权访问该课程的资料
|
||||
# 可以通过查询 position_courses 表和用户的岗位关系来判断
|
||||
|
||||
# 获取文件扩展名
|
||||
file_ext = Path(material.name).suffix.lower()
|
||||
|
||||
# 确定预览类型
|
||||
preview_type = get_preview_type(file_ext)
|
||||
|
||||
logger.info(
|
||||
f"资料预览请求 - material_id: {material_id}, "
|
||||
f"file_type: {file_ext}, preview_type: {preview_type}, "
|
||||
f"user_id: {current_user.id}"
|
||||
)
|
||||
|
||||
# 构建响应数据
|
||||
response_data = {
|
||||
"preview_type": preview_type,
|
||||
"file_name": material.name,
|
||||
"original_url": material.file_url,
|
||||
"file_size": material.file_size,
|
||||
}
|
||||
|
||||
# 根据预览类型处理
|
||||
if preview_type == PreviewType.TEXT:
|
||||
# 文本类型,读取文件内容
|
||||
file_path = get_file_path_from_url(material.file_url)
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
response_data["content"] = content
|
||||
response_data["preview_url"] = None
|
||||
except Exception as e:
|
||||
logger.error(f"读取文本文件失败: {str(e)}")
|
||||
# 读取失败,改为下载模式
|
||||
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||
response_data["preview_url"] = material.file_url
|
||||
else:
|
||||
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||
response_data["preview_url"] = material.file_url
|
||||
|
||||
elif preview_type == PreviewType.EXCEL_HTML:
|
||||
# Excel文件转换为HTML预览
|
||||
file_path = get_file_path_from_url(material.file_url)
|
||||
if file_path and file_path.exists():
|
||||
converted_url = document_converter.convert_excel_to_html(
|
||||
str(file_path),
|
||||
material.course_id,
|
||||
material.id
|
||||
)
|
||||
if converted_url:
|
||||
response_data["preview_url"] = converted_url
|
||||
response_data["preview_type"] = "html" # 前端使用html类型渲染
|
||||
response_data["is_converted"] = True
|
||||
else:
|
||||
logger.warning(f"Excel转HTML失败,改为下载模式 - material_id: {material_id}")
|
||||
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||
response_data["preview_url"] = material.file_url
|
||||
response_data["is_converted"] = False
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文件不存在"
|
||||
)
|
||||
|
||||
elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext):
|
||||
# Office文档,需要转换为PDF
|
||||
file_path = get_file_path_from_url(material.file_url)
|
||||
if file_path and file_path.exists():
|
||||
# 执行转换
|
||||
converted_url = document_converter.convert_to_pdf(
|
||||
str(file_path),
|
||||
material.course_id,
|
||||
material.id
|
||||
)
|
||||
if converted_url:
|
||||
response_data["preview_url"] = converted_url
|
||||
response_data["is_converted"] = True
|
||||
else:
|
||||
# 转换失败,改为下载模式
|
||||
logger.warning(f"文档转换失败,改为下载模式 - material_id: {material_id}")
|
||||
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||
response_data["preview_url"] = material.file_url
|
||||
response_data["is_converted"] = False
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文件不存在"
|
||||
)
|
||||
|
||||
else:
|
||||
# 其他类型,直接返回原始URL
|
||||
response_data["preview_url"] = material.file_url
|
||||
|
||||
return ResponseModel(data=response_data, message="获取预览信息成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取资料预览信息失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="获取预览信息失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-converter", response_model=ResponseModel[dict])
|
||||
async def check_converter_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
检查文档转换服务状态(用于调试)
|
||||
|
||||
Returns:
|
||||
转换服务状态信息
|
||||
"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# 检查 LibreOffice 是否安装
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['libreoffice', '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
libreoffice_installed = result.returncode == 0
|
||||
libreoffice_version = result.stdout.strip() if libreoffice_installed else None
|
||||
except Exception:
|
||||
libreoffice_installed = False
|
||||
libreoffice_version = None
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"libreoffice_installed": libreoffice_installed,
|
||||
"libreoffice_version": libreoffice_version,
|
||||
"supported_formats": list(document_converter.SUPPORTED_FORMATS),
|
||||
"converted_path": str(document_converter.converted_path),
|
||||
},
|
||||
message="转换服务状态检查完成"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查转换服务状态失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="检查转换服务状态失败"
|
||||
)
|
||||
|
||||
311
backend/app/api/v1/scrm.py
Normal file
311
backend/app/api/v1/scrm.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
SCRM 系统对接 API 路由
|
||||
|
||||
提供给 SCRM 系统调用的数据查询接口
|
||||
认证方式:Bearer Token (SCRM_API_KEY)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, verify_scrm_api_key
|
||||
from app.services.scrm_service import SCRMService
|
||||
from app.schemas.scrm import (
|
||||
EmployeePositionResponse,
|
||||
EmployeePositionData,
|
||||
PositionCoursesResponse,
|
||||
PositionCoursesData,
|
||||
KnowledgePointSearchRequest,
|
||||
KnowledgePointSearchResponse,
|
||||
KnowledgePointSearchData,
|
||||
KnowledgePointDetailResponse,
|
||||
KnowledgePointDetailData,
|
||||
SCRMErrorResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/scrm", tags=["scrm"])
|
||||
|
||||
|
||||
# ==================== 1. 获取员工岗位 ====================
|
||||
|
||||
@router.get(
|
||||
"/employees/{userid}/position",
|
||||
response_model=EmployeePositionResponse,
|
||||
summary="获取员工岗位(通过userid)",
|
||||
description="根据企微 userid 查询员工在考陪练系统中的岗位信息",
|
||||
responses={
|
||||
200: {"model": EmployeePositionResponse, "description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||
}
|
||||
)
|
||||
async def get_employee_position_by_userid(
|
||||
userid: str,
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取员工岗位(通过企微userid)
|
||||
|
||||
- **userid**: 企微员工 userid
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.get_employee_position(userid=userid)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": 404,
|
||||
"message": "员工不存在",
|
||||
"data": None
|
||||
}
|
||||
)
|
||||
|
||||
# 检查是否有多个匹配结果
|
||||
if result.get("multiple_matches"):
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"找到 {result['count']} 个匹配的员工,请确认",
|
||||
"data": result
|
||||
}
|
||||
|
||||
return EmployeePositionResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=EmployeePositionData(**result)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/employees/search/by-name",
|
||||
summary="获取员工岗位(通过姓名搜索)",
|
||||
description="根据员工姓名查询员工在考陪练系统中的岗位信息,支持精确匹配和模糊匹配",
|
||||
responses={
|
||||
200: {"description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||
}
|
||||
)
|
||||
async def get_employee_position_by_name(
|
||||
name: str = Query(..., description="员工姓名,支持精确匹配和模糊匹配"),
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取员工岗位(通过姓名搜索)
|
||||
|
||||
- **name**: 员工姓名(必填),优先精确匹配,无结果时模糊匹配
|
||||
|
||||
注意:如果有多个同名员工,会返回员工列表供确认
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.get_employee_position(name=name)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": 404,
|
||||
"message": f"未找到姓名包含 '{name}' 的员工",
|
||||
"data": None
|
||||
}
|
||||
)
|
||||
|
||||
# 检查是否有多个匹配结果
|
||||
if result.get("multiple_matches"):
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"找到 {result['count']} 个匹配的员工,请确认后使用 employee_id 精确查询",
|
||||
"data": result
|
||||
}
|
||||
|
||||
return EmployeePositionResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=EmployeePositionData(**result)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/employees/by-id/{employee_id}/position",
|
||||
response_model=EmployeePositionResponse,
|
||||
summary="获取员工岗位(通过员工ID)",
|
||||
description="根据员工ID精确查询员工岗位信息,用于多个同名员工时的精确查询",
|
||||
responses={
|
||||
200: {"model": EmployeePositionResponse, "description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||
}
|
||||
)
|
||||
async def get_employee_position_by_id(
|
||||
employee_id: int,
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取员工岗位(通过员工ID精确查询)
|
||||
|
||||
- **employee_id**: 员工ID(考陪练系统用户ID)
|
||||
|
||||
适用场景:通过姓名搜索返回多个匹配结果后,使用此接口精确查询
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.get_employee_position_by_id(employee_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": 404,
|
||||
"message": "员工不存在",
|
||||
"data": None
|
||||
}
|
||||
)
|
||||
|
||||
return EmployeePositionResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=EmployeePositionData(**result)
|
||||
)
|
||||
|
||||
|
||||
# ==================== 2. 获取岗位课程列表 ====================
|
||||
|
||||
@router.get(
|
||||
"/positions/{position_id}/courses",
|
||||
response_model=PositionCoursesResponse,
|
||||
summary="获取岗位课程列表",
|
||||
description="获取指定岗位的必修/选修课程列表",
|
||||
responses={
|
||||
200: {"model": PositionCoursesResponse, "description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
404: {"model": SCRMErrorResponse, "description": "岗位不存在"},
|
||||
}
|
||||
)
|
||||
async def get_position_courses(
|
||||
position_id: int,
|
||||
course_type: Optional[str] = Query(
|
||||
default="all",
|
||||
description="课程类型:required/optional/all",
|
||||
regex="^(required|optional|all)$"
|
||||
),
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取岗位课程列表
|
||||
|
||||
- **position_id**: 岗位ID
|
||||
- **course_type**: 课程类型筛选(required/optional/all,默认 all)
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.get_position_courses(position_id, course_type)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": 40002,
|
||||
"message": "position_id 不存在",
|
||||
"data": None
|
||||
}
|
||||
)
|
||||
|
||||
return PositionCoursesResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=PositionCoursesData(**result)
|
||||
)
|
||||
|
||||
|
||||
# ==================== 3. 搜索知识点 ====================
|
||||
|
||||
@router.post(
|
||||
"/knowledge-points/search",
|
||||
response_model=KnowledgePointSearchResponse,
|
||||
summary="搜索知识点",
|
||||
description="根据关键词和岗位搜索匹配的知识点",
|
||||
responses={
|
||||
200: {"model": KnowledgePointSearchResponse, "description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
400: {"model": SCRMErrorResponse, "description": "请求参数错误"},
|
||||
}
|
||||
)
|
||||
async def search_knowledge_points(
|
||||
request: KnowledgePointSearchRequest,
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
搜索知识点
|
||||
|
||||
- **keywords**: 搜索关键词列表(必填)
|
||||
- **position_id**: 岗位ID(用于优先排序,可选)
|
||||
- **course_ids**: 限定课程范围(可选)
|
||||
- **knowledge_type**: 知识点类型筛选(可选)
|
||||
- **limit**: 返回数量,默认10,最大100
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.search_knowledge_points(
|
||||
keywords=request.keywords,
|
||||
position_id=request.position_id,
|
||||
course_ids=request.course_ids,
|
||||
knowledge_type=request.knowledge_type,
|
||||
limit=request.limit
|
||||
)
|
||||
|
||||
return KnowledgePointSearchResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=KnowledgePointSearchData(**result)
|
||||
)
|
||||
|
||||
|
||||
# ==================== 4. 获取知识点详情 ====================
|
||||
|
||||
@router.get(
|
||||
"/knowledge-points/{knowledge_point_id}",
|
||||
response_model=KnowledgePointDetailResponse,
|
||||
summary="获取知识点详情",
|
||||
description="获取知识点的完整信息",
|
||||
responses={
|
||||
200: {"model": KnowledgePointDetailResponse, "description": "成功"},
|
||||
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||
404: {"model": SCRMErrorResponse, "description": "知识点不存在"},
|
||||
}
|
||||
)
|
||||
async def get_knowledge_point_detail(
|
||||
knowledge_point_id: int,
|
||||
_: bool = Depends(verify_scrm_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取知识点详情
|
||||
|
||||
- **knowledge_point_id**: 知识点ID
|
||||
"""
|
||||
service = SCRMService(db)
|
||||
result = await service.get_knowledge_point_detail(knowledge_point_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": 40003,
|
||||
"message": "knowledge_point_id 不存在",
|
||||
"data": None
|
||||
}
|
||||
)
|
||||
|
||||
return KnowledgePointDetailResponse(
|
||||
code=0,
|
||||
message="success",
|
||||
data=KnowledgePointDetailData(**result)
|
||||
)
|
||||
|
||||
363
backend/app/api/v1/sql_executor.py
Normal file
363
backend/app/api/v1/sql_executor.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
SQL 执行器 API - 用于内部服务调用
|
||||
支持执行查询和写入操作的 SQL 语句
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.engine.result import Result
|
||||
import structlog
|
||||
|
||||
from app.core.deps import get_current_user, get_db
|
||||
try:
|
||||
from app.core.simple_auth import get_current_user_simple
|
||||
except ImportError:
|
||||
get_current_user_simple = None
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=["SQL Executor"])
|
||||
|
||||
|
||||
class SQLExecutorRequest:
|
||||
"""SQL执行请求模型"""
|
||||
def __init__(self, sql: str, params: Optional[Dict[str, Any]] = None):
|
||||
self.sql = sql
|
||||
self.params = params or {}
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""处理日期时间对象的 JSON 编码器"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
def serialize_row(row: Any) -> Union[Dict[str, Any], Any]:
|
||||
"""序列化数据库行结果"""
|
||||
if hasattr(row, '_mapping'):
|
||||
# 处理 SQLAlchemy Row 对象
|
||||
return dict(row._mapping)
|
||||
elif hasattr(row, '__dict__'):
|
||||
# 处理 ORM 对象
|
||||
return {k: v for k, v in row.__dict__.items() if not k.startswith('_')}
|
||||
else:
|
||||
# 处理单值结果
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/execute", response_model=ResponseModel)
|
||||
async def execute_sql(
|
||||
request: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
执行 SQL 语句
|
||||
|
||||
Args:
|
||||
request: 包含 sql 和可选的 params 字段
|
||||
- sql: SQL 语句
|
||||
- params: 参数字典(可选)
|
||||
|
||||
Returns:
|
||||
执行结果,包括:
|
||||
- 查询操作:返回数据行
|
||||
- 写入操作:返回影响的行数
|
||||
|
||||
安全说明:
|
||||
- 需要用户身份验证
|
||||
- 所有操作都会记录日志
|
||||
- 建议在生产环境中限制可执行的 SQL 类型
|
||||
"""
|
||||
try:
|
||||
# 提取参数
|
||||
sql = request.get('sql', '').strip()
|
||||
params = request.get('params', {})
|
||||
|
||||
if not sql:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="SQL 语句不能为空"
|
||||
)
|
||||
|
||||
# 记录 SQL 执行日志
|
||||
logger.info(
|
||||
"sql_execution_request",
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
sql_type=sql.split()[0].upper() if sql else "UNKNOWN",
|
||||
sql_length=len(sql),
|
||||
has_params=bool(params)
|
||||
)
|
||||
|
||||
# 判断 SQL 类型
|
||||
sql_upper = sql.upper().strip()
|
||||
is_select = sql_upper.startswith('SELECT')
|
||||
is_show = sql_upper.startswith('SHOW')
|
||||
is_describe = sql_upper.startswith(('DESCRIBE', 'DESC'))
|
||||
is_query = is_select or is_show or is_describe
|
||||
|
||||
# 执行 SQL
|
||||
try:
|
||||
result = await db.execute(text(sql), params)
|
||||
|
||||
if is_query:
|
||||
# 查询操作
|
||||
rows = result.fetchall()
|
||||
columns = list(result.keys()) if result.keys() else []
|
||||
|
||||
# 序列化结果
|
||||
data = []
|
||||
for row in rows:
|
||||
serialized_row = serialize_row(row)
|
||||
if isinstance(serialized_row, dict):
|
||||
data.append(serialized_row)
|
||||
else:
|
||||
# 单列结果
|
||||
data.append({columns[0] if columns else 'value': serialized_row})
|
||||
|
||||
# 使用自定义编码器处理日期时间
|
||||
response_data = {
|
||||
"type": "query",
|
||||
"columns": columns,
|
||||
"rows": json.loads(json.dumps(data, cls=DateTimeEncoder)),
|
||||
"row_count": len(data)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"sql_query_success",
|
||||
user_id=current_user.id,
|
||||
row_count=len(data),
|
||||
column_count=len(columns)
|
||||
)
|
||||
|
||||
else:
|
||||
# 写入操作
|
||||
await db.commit()
|
||||
affected_rows = result.rowcount
|
||||
|
||||
response_data = {
|
||||
"type": "execute",
|
||||
"affected_rows": affected_rows,
|
||||
"success": True
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"sql_execute_success",
|
||||
user_id=current_user.id,
|
||||
affected_rows=affected_rows
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="SQL 执行成功",
|
||||
data=response_data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 回滚事务
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"sql_execution_error",
|
||||
user_id=current_user.id,
|
||||
sql_type=sql.split()[0].upper() if sql else "UNKNOWN",
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"SQL 执行失败: {str(e)}"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"sql_executor_error",
|
||||
user_id=current_user.id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"处理请求时发生错误: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/validate", response_model=ResponseModel)
|
||||
async def validate_sql(
|
||||
request: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
验证 SQL 语句的语法(不执行)
|
||||
|
||||
Args:
|
||||
request: 包含 sql 字段的请求
|
||||
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
try:
|
||||
sql = request.get('sql', '').strip()
|
||||
|
||||
if not sql:
|
||||
return ResponseModel(
|
||||
code=400,
|
||||
message="SQL 语句不能为空",
|
||||
data={"valid": False, "error": "SQL 语句不能为空"}
|
||||
)
|
||||
|
||||
# 基本的 SQL 验证
|
||||
sql_upper = sql.upper().strip()
|
||||
|
||||
# 检查危险操作(可根据需要调整)
|
||||
dangerous_keywords = ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE']
|
||||
warnings = []
|
||||
|
||||
for keyword in dangerous_keywords:
|
||||
if keyword in sql_upper:
|
||||
warnings.append(f"包含危险操作: {keyword}")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="SQL 验证完成",
|
||||
data={
|
||||
"valid": True,
|
||||
"warnings": warnings,
|
||||
"sql_type": sql_upper.split()[0] if sql_upper else "UNKNOWN"
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"sql_validation_error",
|
||||
user_id=current_user.id,
|
||||
error=str(e)
|
||||
)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message="SQL 验证失败",
|
||||
data={"valid": False, "error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tables", response_model=ResponseModel)
|
||||
async def get_tables(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取数据库中的所有表
|
||||
|
||||
Returns:
|
||||
数据库表列表
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(text("SHOW TABLES"))
|
||||
tables = [row[0] for row in result.fetchall()]
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取表列表成功",
|
||||
data={
|
||||
"tables": tables,
|
||||
"count": len(tables)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"get_tables_error",
|
||||
user_id=current_user.id,
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取表列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/table/{table_name}/schema", response_model=ResponseModel)
|
||||
async def get_table_schema(
|
||||
table_name: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取指定表的结构信息
|
||||
|
||||
Args:
|
||||
table_name: 表名
|
||||
|
||||
Returns:
|
||||
表结构信息
|
||||
"""
|
||||
try:
|
||||
# MySQL 的 DESCRIBE 不支持参数化,需要直接拼接
|
||||
# 但为了安全,先验证表名
|
||||
if not table_name.replace('_', '').isalnum():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的表名"
|
||||
)
|
||||
|
||||
result = await db.execute(text(f"DESCRIBE {table_name}"))
|
||||
|
||||
columns = []
|
||||
for row in result.fetchall():
|
||||
columns.append({
|
||||
"field": row[0],
|
||||
"type": row[1],
|
||||
"null": row[2],
|
||||
"key": row[3],
|
||||
"default": row[4],
|
||||
"extra": row[5]
|
||||
})
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取表结构成功",
|
||||
data={
|
||||
"table_name": table_name,
|
||||
"columns": columns,
|
||||
"column_count": len(columns)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"get_table_schema_error",
|
||||
user_id=current_user.id,
|
||||
table_name=table_name,
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取表结构失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# 简化认证版本的端点(如果启用)
|
||||
if get_current_user_simple:
|
||||
@router.post("/execute-simple", response_model=ResponseModel)
|
||||
async def execute_sql_simple(
|
||||
request: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_user_simple),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
执行 SQL 语句(简化认证版本)
|
||||
|
||||
支持 API Key 和 Token 两种认证方式,专为内部服务设计。
|
||||
"""
|
||||
return await execute_sql(request, current_user, db)
|
||||
5
backend/app/api/v1/sql_executor_simple_auth.py
Normal file
5
backend/app/api/v1/sql_executor_simple_auth.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
SQL 执行器 API - 简化认证版本(已删除,功能已整合到主文件)
|
||||
"""
|
||||
# 此文件的功能已经整合到 sql_executor.py 中
|
||||
# 请使用 /api/v1/sql/execute-simple 端点
|
||||
238
backend/app/api/v1/statistics.py
Normal file
238
backend/app/api/v1/statistics.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
统计分析API路由
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.services.statistics_service import StatisticsService
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("/key-metrics", response_model=ResponseModel)
|
||||
async def get_key_metrics(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取关键指标
|
||||
|
||||
返回:
|
||||
- learningEfficiency: 学习效率
|
||||
- knowledgeCoverage: 知识覆盖率
|
||||
- avgTimePerQuestion: 平均用时
|
||||
- progressSpeed: 进步速度
|
||||
"""
|
||||
try:
|
||||
metrics = await StatisticsService.get_key_metrics(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
period=period
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取关键指标成功",
|
||||
data=metrics
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取关键指标失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取关键指标失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/score-distribution", response_model=ResponseModel)
|
||||
async def get_score_distribution(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取成绩分布统计
|
||||
|
||||
返回各分数段的考试数量:
|
||||
- excellent: 优秀(90-100)
|
||||
- good: 良好(80-89)
|
||||
- medium: 中等(70-79)
|
||||
- pass: 及格(60-69)
|
||||
- fail: 不及格(<60)
|
||||
"""
|
||||
try:
|
||||
distribution = await StatisticsService.get_score_distribution(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
period=period
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取成绩分布成功",
|
||||
data=distribution
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取成绩分布失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取成绩分布失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/difficulty-analysis", response_model=ResponseModel)
|
||||
async def get_difficulty_analysis(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取题目难度分析
|
||||
|
||||
返回各难度题目的正确率:
|
||||
- 简单题
|
||||
- 中等题
|
||||
- 困难题
|
||||
- 综合题
|
||||
- 应用题
|
||||
"""
|
||||
try:
|
||||
analysis = await StatisticsService.get_difficulty_analysis(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
period=period
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取难度分析成功",
|
||||
data=analysis
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取难度分析失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取难度分析失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge-mastery", response_model=ResponseModel)
|
||||
async def get_knowledge_mastery(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取知识点掌握度
|
||||
|
||||
返回知识点列表及其掌握度:
|
||||
- name: 知识点名称
|
||||
- mastery: 掌握度(0-100)
|
||||
"""
|
||||
try:
|
||||
mastery = await StatisticsService.get_knowledge_mastery(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取知识点掌握度成功",
|
||||
data=mastery
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取知识点掌握度失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取知识点掌握度失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/study-time", response_model=ResponseModel)
|
||||
async def get_study_time_stats(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取学习时长统计
|
||||
|
||||
返回学习时长和练习时长的日期分布:
|
||||
- labels: 日期标签列表
|
||||
- studyTime: 学习时长列表(小时)
|
||||
- practiceTime: 练习时长列表(小时)
|
||||
"""
|
||||
try:
|
||||
time_stats = await StatisticsService.get_study_time_stats(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
period=period
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取学习时长统计成功",
|
||||
data=time_stats
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取学习时长统计失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取学习时长统计失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/detail", response_model=ResponseModel)
|
||||
async def get_detail_data(
|
||||
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取详细统计数据(按日期)
|
||||
|
||||
返回每日详细统计数据:
|
||||
- date: 日期
|
||||
- examCount: 考试次数
|
||||
- avgScore: 平均分
|
||||
- studyTime: 学习时长(小时)
|
||||
- questionCount: 练习题数
|
||||
- accuracy: 正确率
|
||||
- improvement: 进步指数
|
||||
"""
|
||||
try:
|
||||
detail = await StatisticsService.get_detail_data(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
period=period
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取详细数据成功",
|
||||
data=detail
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取详细数据失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取详细数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
139
backend/app/api/v1/system.py
Normal file
139
backend/app/api/v1/system.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
系统API - 供外部服务回调使用
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.course import KnowledgePointCreate
|
||||
from app.services.course_service import knowledge_point_service, course_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/system")
|
||||
|
||||
|
||||
class KnowledgePointData(BaseModel):
|
||||
"""知识点数据模型"""
|
||||
name: str = Field(..., description="知识点名称")
|
||||
description: str = Field(default="", description="知识点描述")
|
||||
type: str = Field(default="理论知识", description="知识点类型")
|
||||
source: int = Field(default=1, description="来源:0=手动,1=AI分析")
|
||||
topic_relation: Optional[str] = Field(None, description="与主题的关系描述")
|
||||
|
||||
|
||||
class KnowledgeCallbackRequest(BaseModel):
|
||||
"""知识点回调请求模型(已弃用,保留向后兼容)"""
|
||||
course_id: int = Field(..., description="课程ID")
|
||||
material_id: int = Field(..., description="资料ID")
|
||||
knowledge_points: List[KnowledgePointData] = Field(..., description="知识点列表")
|
||||
|
||||
|
||||
@router.post("/knowledge", response_model=ResponseModel[Dict[str, Any]])
|
||||
async def create_knowledge_points_callback(
|
||||
request: KnowledgeCallbackRequest,
|
||||
authorization: str = Header(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建知识点回调接口(已弃用)
|
||||
|
||||
注意:此接口已弃用,知识点分析现使用 Python 原生实现。
|
||||
保留此接口仅为向后兼容。
|
||||
"""
|
||||
try:
|
||||
# API密钥验证(已弃用的接口,保留向后兼容)
|
||||
expected_token = "Bearer callback-token-2025"
|
||||
if authorization != expected_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的授权令牌"
|
||||
)
|
||||
|
||||
# 验证课程是否存在
|
||||
course = await course_service.get_by_id(db, request.course_id)
|
||||
if not course:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"课程 {request.course_id} 不存在"
|
||||
)
|
||||
|
||||
# 验证资料是否存在
|
||||
materials = await course_service.get_course_materials(db, course_id=request.course_id)
|
||||
material = next((m for m in materials if m.id == request.material_id), None)
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"资料 {request.material_id} 不存在"
|
||||
)
|
||||
|
||||
# 创建知识点
|
||||
created_points = []
|
||||
for kp_data in request.knowledge_points:
|
||||
try:
|
||||
knowledge_point_create = KnowledgePointCreate(
|
||||
name=kp_data.name,
|
||||
description=kp_data.description,
|
||||
type=kp_data.type,
|
||||
source=kp_data.source, # AI分析来源=1
|
||||
topic_relation=kp_data.topic_relation,
|
||||
material_id=request.material_id # 关联资料ID
|
||||
)
|
||||
|
||||
# 使用系统用户ID (假设为1,或者可以配置)
|
||||
system_user_id = 1
|
||||
knowledge_point = await knowledge_point_service.create_knowledge_point(
|
||||
db=db,
|
||||
course_id=request.course_id,
|
||||
point_in=knowledge_point_create,
|
||||
created_by=system_user_id
|
||||
)
|
||||
|
||||
created_points.append({
|
||||
"id": knowledge_point.id,
|
||||
"name": knowledge_point.name,
|
||||
"description": knowledge_point.description,
|
||||
"type": knowledge_point.type,
|
||||
"source": knowledge_point.source,
|
||||
"material_id": knowledge_point.material_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"创建知识点失败 - name: {kp_data.name}, error: {str(e)}"
|
||||
)
|
||||
# 继续处理其他知识点,不因为单个失败而中断
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"知识点回调成功 - course_id: {request.course_id}, material_id: {request.material_id}, created_points: {len(created_points)}"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"course_id": request.course_id,
|
||||
"material_id": request.material_id,
|
||||
"knowledge_points_count": len(created_points),
|
||||
"knowledge_points": created_points
|
||||
},
|
||||
message=f"成功创建 {len(created_points)} 个知识点"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"知识点回调处理失败 - course_id: {request.course_id}, material_id: {request.material_id}, error: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="知识点创建失败"
|
||||
)
|
||||
|
||||
|
||||
184
backend/app/api/v1/system_logs.py
Normal file
184
backend/app/api/v1/system_logs.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
系统日志 API
|
||||
提供日志查询、筛选、详情查看等功能
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.system_log import (
|
||||
SystemLogCreate,
|
||||
SystemLogResponse,
|
||||
SystemLogQuery,
|
||||
SystemLogListResponse
|
||||
)
|
||||
from app.services.system_log_service import system_log_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/logs")
|
||||
|
||||
|
||||
@router.get("", response_model=ResponseModel[SystemLogListResponse])
|
||||
async def get_system_logs(
|
||||
level: Optional[str] = Query(None, description="日志级别筛选"),
|
||||
type: Optional[str] = Query(None, description="日志类型筛选"),
|
||||
user: Optional[str] = Query(None, description="用户筛选"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||
start_date: Optional[datetime] = Query(None, description="开始日期"),
|
||||
end_date: Optional[datetime] = Query(None, description="结束日期"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取系统日志列表
|
||||
支持按级别、类型、用户、关键词、日期范围筛选
|
||||
仅管理员可访问
|
||||
"""
|
||||
try:
|
||||
# 权限检查:仅管理员可查看系统日志
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权限访问系统日志")
|
||||
|
||||
# 构建查询参数
|
||||
query_params = SystemLogQuery(
|
||||
level=level,
|
||||
type=type,
|
||||
user=user,
|
||||
keyword=keyword,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
# 查询日志
|
||||
logs, total = await system_log_service.get_logs(db, query_params)
|
||||
|
||||
# 计算总页数
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
# 转换为响应格式
|
||||
log_responses = [SystemLogResponse.model_validate(log) for log in logs]
|
||||
|
||||
response_data = SystemLogListResponse(
|
||||
items=log_responses,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取系统日志成功",
|
||||
data=response_data
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取系统日志失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取系统日志失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{log_id}", response_model=ResponseModel[SystemLogResponse])
|
||||
async def get_log_detail(
|
||||
log_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取日志详情
|
||||
仅管理员可访问
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权限访问系统日志")
|
||||
|
||||
# 查询日志
|
||||
log = await system_log_service.get_log_by_id(db, log_id)
|
||||
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取日志详情成功",
|
||||
data=SystemLogResponse.model_validate(log)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取日志详情失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取日志详情失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("", response_model=ResponseModel[SystemLogResponse])
|
||||
async def create_system_log(
|
||||
log_data: SystemLogCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
创建系统日志(内部API,供系统各模块调用)
|
||||
注意:此接口不需要用户认证,但应该只供内部调用
|
||||
"""
|
||||
try:
|
||||
log = await system_log_service.create_log(db, log_data)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="创建日志成功",
|
||||
data=SystemLogResponse.model_validate(log)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建日志失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"创建日志失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/cleanup")
|
||||
async def cleanup_old_logs(
|
||||
before_days: int = Query(90, ge=1, description="删除多少天之前的日志"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
清理旧日志
|
||||
仅管理员可访问
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权限执行此操作")
|
||||
|
||||
# 计算截止日期
|
||||
from datetime import timedelta
|
||||
before_date = datetime.now() - timedelta(days=before_days)
|
||||
|
||||
# 删除旧日志
|
||||
deleted_count = await system_log_service.delete_logs_before_date(db, before_date)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message=f"成功清理 {deleted_count} 条日志",
|
||||
data={"deleted_count": deleted_count}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"清理日志失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"清理日志失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
228
backend/app/api/v1/tasks.py
Normal file
228
backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
任务管理API
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.deps import get_db, get_current_user, require_admin_or_manager
|
||||
from app.schemas.base import ResponseModel, PaginatedResponse
|
||||
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse, TaskStatsResponse
|
||||
from app.services.task_service import task_service
|
||||
from app.services.system_log_service import system_log_service
|
||||
from app.schemas.system_log import SystemLogCreate
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/manager/tasks", tags=["Tasks"], redirect_slashes=False)
|
||||
|
||||
|
||||
@router.post("", response_model=ResponseModel[TaskResponse], summary="创建任务")
|
||||
async def create_task(
|
||||
task_in: TaskCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""创建新任务"""
|
||||
task = await task_service.create_task(db, task_in, current_user.id)
|
||||
|
||||
# 记录任务创建日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"创建任务: {task.title}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/manager/tasks",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
# 构建响应
|
||||
courses = [link.course.name for link in task.course_links]
|
||||
return ResponseModel(
|
||||
data=TaskResponse(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
priority=task.priority.value,
|
||||
status=task.status.value,
|
||||
creator_id=task.creator_id,
|
||||
deadline=task.deadline,
|
||||
requirements=task.requirements,
|
||||
progress=task.progress,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
courses=courses,
|
||||
assigned_count=len(task.assignments),
|
||||
completed_count=sum(1 for a in task.assignments if a.status.value == "completed")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ResponseModel[PaginatedResponse[TaskResponse]], summary="获取任务列表")
|
||||
async def get_tasks(
|
||||
status: Optional[str] = Query(None, description="任务状态筛选"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""获取任务列表"""
|
||||
tasks, total = await task_service.get_tasks(db, status, page, page_size)
|
||||
|
||||
# 构建响应
|
||||
items = []
|
||||
for task in tasks:
|
||||
# 加载关联数据
|
||||
task_detail = await task_service.get_task_detail(db, task.id)
|
||||
if task_detail:
|
||||
courses = [link.course.name for link in task_detail.course_links]
|
||||
items.append(TaskResponse(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
priority=task.priority.value,
|
||||
status=task.status.value,
|
||||
creator_id=task.creator_id,
|
||||
deadline=task.deadline,
|
||||
requirements=task.requirements,
|
||||
progress=task.progress,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
courses=courses,
|
||||
assigned_count=len(task_detail.assignments),
|
||||
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed")
|
||||
))
|
||||
|
||||
return ResponseModel(
|
||||
data=PaginatedResponse.create(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ResponseModel[TaskStatsResponse], summary="获取任务统计")
|
||||
async def get_task_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""获取任务统计数据"""
|
||||
stats = await task_service.get_task_stats(db)
|
||||
return ResponseModel(data=stats)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=ResponseModel[TaskResponse], summary="获取任务详情")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""获取任务详情"""
|
||||
task = await task_service.get_task_detail(db, task_id)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
courses = [link.course.name for link in task.course_links]
|
||||
return ResponseModel(
|
||||
data=TaskResponse(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
priority=task.priority.value,
|
||||
status=task.status.value,
|
||||
creator_id=task.creator_id,
|
||||
deadline=task.deadline,
|
||||
requirements=task.requirements,
|
||||
progress=task.progress,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
courses=courses,
|
||||
assigned_count=len(task.assignments),
|
||||
completed_count=sum(1 for a in task.assignments if a.status.value == "completed")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{task_id}", response_model=ResponseModel[TaskResponse], summary="更新任务")
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
task_in: TaskUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""更新任务"""
|
||||
task = await task_service.update_task(db, task_id, task_in)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 自动更新任务进度和状态
|
||||
await task_service.update_task_status(db, task_id)
|
||||
|
||||
# 重新加载详情
|
||||
task_detail = await task_service.get_task_detail(db, task.id)
|
||||
courses = [link.course.name for link in task_detail.course_links] if task_detail else []
|
||||
|
||||
return ResponseModel(
|
||||
data=TaskResponse(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
priority=task.priority.value,
|
||||
status=task.status.value,
|
||||
creator_id=task.creator_id,
|
||||
deadline=task.deadline,
|
||||
requirements=task.requirements,
|
||||
progress=task.progress,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
courses=courses,
|
||||
assigned_count=len(task_detail.assignments) if task_detail else 0,
|
||||
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") if task_detail else 0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", response_model=ResponseModel, summary="删除任务")
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_manager)
|
||||
):
|
||||
"""删除任务"""
|
||||
# 先获取任务信息用于日志
|
||||
task_detail = await task_service.get_task_detail(db, task_id)
|
||||
task_title = task_detail.title if task_detail else f"ID:{task_id}"
|
||||
|
||||
success = await task_service.delete_task(db, task_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 记录任务删除日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="api",
|
||||
message=f"删除任务: {task_title}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path=f"/api/v1/manager/tasks/{task_id}",
|
||||
method="DELETE",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(message="任务已删除")
|
||||
|
||||
750
backend/app/api/v1/team_dashboard.py
Normal file
750
backend/app/api/v1/team_dashboard.py
Normal file
@@ -0,0 +1,750 @@
|
||||
"""
|
||||
团队看板 API 路由
|
||||
提供团队概览、学习进度、排行榜、动态等数据
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.course import Course
|
||||
from app.models.exam import Exam
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.practice import PracticeReport, PracticeSession
|
||||
from app.models.user import Team, User, UserTeam
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/team/dashboard", tags=["team-dashboard"])
|
||||
|
||||
|
||||
async def get_accessible_teams(
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[int]:
|
||||
"""获取用户可访问的团队ID列表"""
|
||||
if current_user.role in ['admin', 'manager']:
|
||||
# 管理员查看所有团队
|
||||
stmt = select(Team.id).where(Team.is_deleted == False) # noqa: E712
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
else:
|
||||
# 普通用户只查看自己的团队
|
||||
stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def get_team_member_ids(
|
||||
team_ids: List[int],
|
||||
db: AsyncSession
|
||||
) -> List[int]:
|
||||
"""获取团队成员ID列表"""
|
||||
if not team_ids:
|
||||
return []
|
||||
|
||||
stmt = select(UserTeam.user_id).where(
|
||||
UserTeam.team_id.in_(team_ids)
|
||||
).distinct()
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ResponseModel)
|
||||
async def get_team_overview(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取团队概览统计
|
||||
|
||||
返回团队总数、成员数、平均学习进度、平均成绩、课程完成率等
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
|
||||
# 获取团队成员ID
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
# 统计团队数
|
||||
team_count = len(team_ids)
|
||||
|
||||
# 统计成员数
|
||||
member_count = len(member_ids)
|
||||
|
||||
# 计算平均考试成绩(使用round1_score)
|
||||
avg_score = 0.0
|
||||
if member_ids:
|
||||
stmt = select(func.avg(Exam.round1_score)).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
avg_score_value = result.scalar()
|
||||
avg_score = float(avg_score_value) if avg_score_value else 0.0
|
||||
|
||||
# 计算平均学习进度(基于考试完成情况)
|
||||
avg_progress = 0.0
|
||||
if member_ids:
|
||||
# 统计每个成员完成的考试数
|
||||
stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
completed_exams = result.scalar() or 0
|
||||
|
||||
# 假设每个成员应完成10个考试,计算完成率作为进度
|
||||
total_expected = member_count * 10
|
||||
if total_expected > 0:
|
||||
avg_progress = (completed_exams / total_expected) * 100
|
||||
|
||||
# 计算课程完成率
|
||||
course_completion_rate = 0.0
|
||||
if member_ids:
|
||||
# 统计已完成的课程数(有考试记录且成绩>=60)
|
||||
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
completed_courses = result.scalar() or 0
|
||||
|
||||
# 统计总课程数
|
||||
stmt = select(func.count(Course.id)).where(
|
||||
and_(
|
||||
Course.is_deleted == False, # noqa: E712
|
||||
Course.status == 'published'
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
total_courses = result.scalar() or 0
|
||||
|
||||
if total_courses > 0:
|
||||
course_completion_rate = (completed_courses / total_courses) * 100
|
||||
|
||||
# 趋势数据(暂时返回固定值,后续可实现真实趋势计算)
|
||||
trends = {
|
||||
"member_trend": 0,
|
||||
"progress_trend": 12.3 if avg_progress > 0 else 0,
|
||||
"score_trend": 5.8 if avg_score > 0 else 0,
|
||||
"completion_trend": -3.2 if course_completion_rate > 0 else 0
|
||||
}
|
||||
|
||||
data = {
|
||||
"team_count": team_count,
|
||||
"member_count": member_count,
|
||||
"avg_progress": round(avg_progress, 1),
|
||||
"avg_score": round(avg_score, 1),
|
||||
"course_completion_rate": round(course_completion_rate, 1),
|
||||
"trends": trends
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取团队概览失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取团队概览失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/progress", response_model=ResponseModel)
|
||||
async def get_progress_data(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取学习进度数据
|
||||
|
||||
返回Top 5成员的8周学习进度数据
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
if not member_ids:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={"members": [], "weeks": [], "data": []}
|
||||
)
|
||||
|
||||
# 获取Top 5学习时长最高的成员
|
||||
stmt = (
|
||||
select(
|
||||
User.id,
|
||||
User.full_name,
|
||||
func.sum(PracticeSession.duration_seconds).label('total_duration')
|
||||
)
|
||||
.join(PracticeSession, PracticeSession.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
.group_by(User.id, User.full_name)
|
||||
.order_by(func.sum(PracticeSession.duration_seconds).desc())
|
||||
.limit(5)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
top_members = result.all()
|
||||
|
||||
if not top_members:
|
||||
# 如果没有陪练记录,按考试成绩选择Top 5
|
||||
stmt = (
|
||||
select(
|
||||
User.id,
|
||||
User.full_name,
|
||||
func.avg(Exam.round1_score).label('avg_score')
|
||||
)
|
||||
.join(Exam, Exam.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
.group_by(User.id, User.full_name)
|
||||
.order_by(func.avg(Exam.round1_score).desc())
|
||||
.limit(5)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
top_members = result.all()
|
||||
|
||||
# 生成周标签
|
||||
weeks = [f"第{i+1}周" for i in range(8)]
|
||||
|
||||
# 为每个成员生成进度数据
|
||||
members = []
|
||||
data = []
|
||||
|
||||
for member in top_members:
|
||||
member_name = member.full_name or f"用户{member.id}"
|
||||
members.append(member_name)
|
||||
|
||||
# 查询该成员8周内的考试完成情况
|
||||
eight_weeks_ago = datetime.now() - timedelta(weeks=8)
|
||||
stmt = select(Exam).where(
|
||||
and_(
|
||||
Exam.user_id == member.id,
|
||||
Exam.created_at >= eight_weeks_ago,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
).order_by(Exam.created_at)
|
||||
result = await db.execute(stmt)
|
||||
exams = result.scalars().all()
|
||||
|
||||
# 计算每周的进度(0-100)
|
||||
values = []
|
||||
for week in range(8):
|
||||
week_start = datetime.now() - timedelta(weeks=8-week)
|
||||
week_end = week_start + timedelta(weeks=1)
|
||||
|
||||
# 统计该周完成的考试数
|
||||
week_exams = [
|
||||
e for e in exams
|
||||
if week_start <= e.created_at < week_end
|
||||
]
|
||||
|
||||
# 进度 = 累计完成考试数 * 10(假设每个考试代表10%进度)
|
||||
cumulative_exams = len([e for e in exams if e.created_at < week_end])
|
||||
progress = min(cumulative_exams * 10, 100)
|
||||
values.append(progress)
|
||||
|
||||
data.append({"name": member_name, "values": values})
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={"members": members, "weeks": weeks, "data": data}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取学习进度数据失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取学习进度数据失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/course-distribution", response_model=ResponseModel)
|
||||
async def get_course_distribution(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取课程完成分布
|
||||
|
||||
返回已完成、进行中、未开始的课程数量
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
# 统计所有已发布的课程
|
||||
stmt = select(func.count(Course.id)).where(
|
||||
and_(
|
||||
Course.is_deleted == False, # noqa: E712
|
||||
Course.status == 'published'
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
total_courses = result.scalar() or 0
|
||||
|
||||
if not member_ids or total_courses == 0:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={"completed": 0, "in_progress": 0, "not_started": 0}
|
||||
)
|
||||
|
||||
# 统计已完成的课程(有及格成绩)
|
||||
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
completed = result.scalar() or 0
|
||||
|
||||
# 统计进行中的课程(有考试记录但未及格)
|
||||
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
or_(
|
||||
Exam.round1_score < 60,
|
||||
Exam.status == 'started'
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
in_progress = result.scalar() or 0
|
||||
|
||||
# 未开始 = 总数 - 已完成 - 进行中
|
||||
not_started = max(0, total_courses - completed - in_progress)
|
||||
|
||||
data = {
|
||||
"completed": completed,
|
||||
"in_progress": in_progress,
|
||||
"not_started": not_started
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取课程分布失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取课程分布失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/ability-analysis", response_model=ResponseModel)
|
||||
async def get_ability_analysis(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取能力分析数据
|
||||
|
||||
返回团队能力雷达图数据和短板列表
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
if not member_ids:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"radar_data": {
|
||||
"dimensions": [],
|
||||
"values": []
|
||||
},
|
||||
"weaknesses": []
|
||||
}
|
||||
)
|
||||
|
||||
# 查询所有陪练报告的能力维度数据
|
||||
# 需要通过PracticeSession关联,因为PracticeReport没有user_id
|
||||
stmt = (
|
||||
select(PracticeReport.ability_dimensions)
|
||||
.join(PracticeSession, PracticeSession.session_id == PracticeReport.session_id)
|
||||
.where(PracticeSession.user_id.in_(member_ids))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
all_dimensions = result.scalars().all()
|
||||
|
||||
if not all_dimensions:
|
||||
# 如果没有陪练报告,返回默认能力维度
|
||||
default_dimensions = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"]
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"radar_data": {
|
||||
"dimensions": default_dimensions,
|
||||
"values": [0] * len(default_dimensions)
|
||||
},
|
||||
"weaknesses": []
|
||||
}
|
||||
)
|
||||
|
||||
# 聚合能力数据
|
||||
ability_scores: Dict[str, List[float]] = {}
|
||||
|
||||
# 能力维度名称映射
|
||||
dimension_name_map = {
|
||||
"sales_ability": "销售能力",
|
||||
"service_attitude": "服务态度",
|
||||
"technical_skills": "技术能力",
|
||||
"沟通表达": "沟通表达",
|
||||
"倾听理解": "倾听理解",
|
||||
"需求挖掘": "需求挖掘",
|
||||
"异议处理": "异议处理",
|
||||
"成交技巧": "成交技巧",
|
||||
"客户维护": "客户维护"
|
||||
}
|
||||
|
||||
for dimensions in all_dimensions:
|
||||
if dimensions:
|
||||
# 如果是字符串,进行JSON反序列化
|
||||
if isinstance(dimensions, str):
|
||||
try:
|
||||
dimensions = json.loads(dimensions)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"无法解析能力维度数据: {dimensions}")
|
||||
continue
|
||||
|
||||
# 处理字典格式:{"sales_ability": 79.0, ...}
|
||||
if isinstance(dimensions, dict):
|
||||
for key, score in dimensions.items():
|
||||
name = dimension_name_map.get(key, key)
|
||||
if name not in ability_scores:
|
||||
ability_scores[name] = []
|
||||
ability_scores[name].append(float(score))
|
||||
|
||||
# 处理列表格式:[{"name": "沟通表达", "score": 85}, ...]
|
||||
elif isinstance(dimensions, list):
|
||||
for dim in dimensions:
|
||||
if not isinstance(dim, dict):
|
||||
logger.warning(f"能力维度项格式错误: {type(dim)}")
|
||||
continue
|
||||
|
||||
name = dim.get('name', '')
|
||||
score = dim.get('score', 0)
|
||||
if name:
|
||||
mapped_name = dimension_name_map.get(name, name)
|
||||
if mapped_name not in ability_scores:
|
||||
ability_scores[mapped_name] = []
|
||||
ability_scores[mapped_name].append(float(score))
|
||||
else:
|
||||
logger.warning(f"能力维度数据格式错误: {type(dimensions)}")
|
||||
|
||||
# 计算平均分
|
||||
avg_scores = {
|
||||
name: sum(scores) / len(scores)
|
||||
for name, scores in ability_scores.items()
|
||||
}
|
||||
|
||||
# 按固定顺序排列维度(支持多种维度组合)
|
||||
# 优先使用六维度,如果没有则使用三维度
|
||||
standard_dimensions_six = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"]
|
||||
standard_dimensions_three = ["销售能力", "服务态度", "技术能力"]
|
||||
|
||||
# 判断使用哪种维度标准
|
||||
has_six_dimensions = any(dim in avg_scores for dim in standard_dimensions_six)
|
||||
has_three_dimensions = any(dim in avg_scores for dim in standard_dimensions_three)
|
||||
|
||||
if has_six_dimensions:
|
||||
standard_dimensions = standard_dimensions_six
|
||||
elif has_three_dimensions:
|
||||
standard_dimensions = standard_dimensions_three
|
||||
else:
|
||||
# 如果都没有,使用实际数据的维度
|
||||
standard_dimensions = list(avg_scores.keys())
|
||||
|
||||
dimensions = []
|
||||
values = []
|
||||
|
||||
for dim in standard_dimensions:
|
||||
if dim in avg_scores:
|
||||
dimensions.append(dim)
|
||||
values.append(round(avg_scores[dim], 1))
|
||||
|
||||
# 找出短板(平均分<80)
|
||||
weaknesses = []
|
||||
weakness_suggestions = {
|
||||
# 六维度建议
|
||||
"异议处理": "建议加强异议处理专项训练,增加实战演练",
|
||||
"成交技巧": "需要系统学习成交话术和时机把握",
|
||||
"需求挖掘": "提升提问技巧,深入了解客户需求",
|
||||
"沟通表达": "加强沟通技巧训练,提升表达能力",
|
||||
"倾听理解": "培养同理心,提高倾听和理解能力",
|
||||
"客户维护": "学习客户关系管理,提升服务质量",
|
||||
# 三维度建议
|
||||
"销售能力": "建议加强销售技巧训练,提升成交率",
|
||||
"服务态度": "需要改善服务态度,提高客户满意度",
|
||||
"技术能力": "建议学习产品知识,提升专业能力"
|
||||
}
|
||||
|
||||
for name, score in avg_scores.items():
|
||||
if score < 80:
|
||||
weaknesses.append({
|
||||
"name": name,
|
||||
"avg_score": int(score),
|
||||
"suggestion": weakness_suggestions.get(name, f"建议加强{name}专项训练")
|
||||
})
|
||||
|
||||
# 按分数升序排列
|
||||
weaknesses.sort(key=lambda x: x['avg_score'])
|
||||
|
||||
data = {
|
||||
"radar_data": {
|
||||
"dimensions": dimensions,
|
||||
"values": values
|
||||
},
|
||||
"weaknesses": weaknesses
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取能力分析失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取能力分析失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/rankings", response_model=ResponseModel)
|
||||
async def get_rankings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取排行榜数据
|
||||
|
||||
返回学习时长排行和成绩排行Top 5
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
if not member_ids:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"study_time_ranking": [],
|
||||
"score_ranking": []
|
||||
}
|
||||
)
|
||||
|
||||
# 学习时长排行(基于陪练会话)
|
||||
stmt = (
|
||||
select(
|
||||
User.id,
|
||||
User.full_name,
|
||||
User.avatar_url,
|
||||
Position.name.label('position_name'),
|
||||
func.sum(PracticeSession.duration_seconds).label('total_duration')
|
||||
)
|
||||
.join(PracticeSession, PracticeSession.user_id == User.id)
|
||||
.outerjoin(PositionMember, and_(
|
||||
PositionMember.user_id == User.id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
))
|
||||
.outerjoin(Position, Position.id == PositionMember.position_id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
.group_by(User.id, User.full_name, User.avatar_url, Position.name)
|
||||
.order_by(func.sum(PracticeSession.duration_seconds).desc())
|
||||
.limit(5)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
study_time_data = result.all()
|
||||
|
||||
study_time_ranking = []
|
||||
for row in study_time_data:
|
||||
study_time_ranking.append({
|
||||
"id": row.id,
|
||||
"name": row.full_name or f"用户{row.id}",
|
||||
"position": row.position_name or "未分配岗位",
|
||||
"avatar": row.avatar_url or "",
|
||||
"study_time": round(row.total_duration / 3600, 1) # 转换为小时
|
||||
})
|
||||
|
||||
# 成绩排行(基于考试round1_score)
|
||||
stmt = (
|
||||
select(
|
||||
User.id,
|
||||
User.full_name,
|
||||
User.avatar_url,
|
||||
Position.name.label('position_name'),
|
||||
func.avg(Exam.round1_score).label('avg_score')
|
||||
)
|
||||
.join(Exam, Exam.user_id == User.id)
|
||||
.outerjoin(PositionMember, and_(
|
||||
PositionMember.user_id == User.id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
))
|
||||
.outerjoin(Position, Position.id == PositionMember.position_id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
.group_by(User.id, User.full_name, User.avatar_url, Position.name)
|
||||
.order_by(func.avg(Exam.round1_score).desc())
|
||||
.limit(5)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
score_data = result.all()
|
||||
|
||||
score_ranking = []
|
||||
for row in score_data:
|
||||
score_ranking.append({
|
||||
"id": row.id,
|
||||
"name": row.full_name or f"用户{row.id}",
|
||||
"position": row.position_name or "未分配岗位",
|
||||
"avatar": row.avatar_url or "",
|
||||
"avg_score": round(row.avg_score, 1)
|
||||
})
|
||||
|
||||
data = {
|
||||
"study_time_ranking": study_time_ranking,
|
||||
"score_ranking": score_ranking
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取排行榜失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取排行榜失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/activities", response_model=ResponseModel)
|
||||
async def get_activities(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取团队学习动态
|
||||
|
||||
返回最近20条活动记录(考试、陪练等)
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队
|
||||
team_ids = await get_accessible_teams(current_user, db)
|
||||
member_ids = await get_team_member_ids(team_ids, db)
|
||||
|
||||
if not member_ids:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={"activities": []}
|
||||
)
|
||||
|
||||
activities = []
|
||||
|
||||
# 获取最近的考试记录
|
||||
stmt = (
|
||||
select(Exam, User.full_name, Course.name.label('course_name'))
|
||||
.join(User, User.id == Exam.user_id)
|
||||
.join(Course, Course.id == Exam.course_id)
|
||||
.where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
.order_by(Exam.updated_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
exam_records = result.all()
|
||||
|
||||
for exam, user_name, course_name in exam_records:
|
||||
score = exam.round1_score or 0
|
||||
activity_type = "success" if score >= 60 else "danger"
|
||||
result_type = "success" if score >= 60 else "danger"
|
||||
result_text = f"成绩:{int(score)}分" if score >= 60 else "未通过"
|
||||
|
||||
activities.append({
|
||||
"id": f"exam_{exam.id}",
|
||||
"user_name": user_name or f"用户{exam.user_id}",
|
||||
"action": "完成了" if score >= 60 else "参加了",
|
||||
"target": f"《{course_name}》课程考试",
|
||||
"time": exam.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"type": activity_type,
|
||||
"result": {"type": result_type, "text": result_text}
|
||||
})
|
||||
|
||||
# 获取最近的陪练记录
|
||||
stmt = (
|
||||
select(PracticeSession, User.full_name, PracticeReport.total_score)
|
||||
.join(User, User.id == PracticeSession.user_id)
|
||||
.outerjoin(PracticeReport, PracticeReport.session_id == PracticeSession.session_id)
|
||||
.where(
|
||||
and_(
|
||||
PracticeSession.user_id.in_(member_ids),
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
.order_by(PracticeSession.end_time.desc())
|
||||
.limit(10)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
practice_records = result.all()
|
||||
|
||||
for session, user_name, total_score in practice_records:
|
||||
activity_type = "primary"
|
||||
result_data = None
|
||||
if total_score:
|
||||
result_data = {"type": "", "text": f"评分:{int(total_score)}分"}
|
||||
|
||||
activities.append({
|
||||
"id": f"practice_{session.id}",
|
||||
"user_name": user_name or f"用户{session.user_id}",
|
||||
"action": "参加了",
|
||||
"target": "AI陪练训练",
|
||||
"time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "",
|
||||
"type": activity_type,
|
||||
"result": result_data
|
||||
})
|
||||
|
||||
# 按时间倒序排列,取前20条
|
||||
activities.sort(key=lambda x: x['time'], reverse=True)
|
||||
activities = activities[:20]
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={"activities": activities}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取团队动态失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取团队动态失败: {str(e)}", data=None)
|
||||
|
||||
896
backend/app/api/v1/team_management.py
Normal file
896
backend/app/api/v1/team_management.py
Normal file
@@ -0,0 +1,896 @@
|
||||
"""
|
||||
团队成员管理 API 路由
|
||||
提供团队统计、成员列表、成员详情、学习报告等功能
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.course import Course
|
||||
from app.models.exam import Exam
|
||||
from app.models.position import Position
|
||||
from app.models.position_course import PositionCourse
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.practice import PracticeReport, PracticeSession
|
||||
from app.models.user import User, UserTeam
|
||||
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/team/management", tags=["team-management"])
|
||||
|
||||
|
||||
async def get_accessible_team_member_ids(
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[int]:
|
||||
"""获取用户可访问的团队成员ID列表"""
|
||||
if current_user.role in ['admin', 'manager']:
|
||||
# 管理员查看所有团队成员
|
||||
stmt = select(UserTeam.user_id).distinct()
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
else:
|
||||
# 普通用户只查看自己团队的成员
|
||||
# 1. 先查询用户所在的团队
|
||||
stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
team_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not team_ids:
|
||||
return []
|
||||
|
||||
# 2. 查询这些团队的所有成员
|
||||
stmt = select(UserTeam.user_id).where(
|
||||
UserTeam.team_id.in_(team_ids)
|
||||
).distinct()
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
def calculate_member_status(
|
||||
last_login: Optional[datetime],
|
||||
last_exam: Optional[datetime],
|
||||
last_practice: Optional[datetime],
|
||||
has_ongoing: bool
|
||||
) -> str:
|
||||
"""
|
||||
计算成员活跃状态
|
||||
|
||||
Args:
|
||||
last_login: 最后登录时间
|
||||
last_exam: 最后考试时间
|
||||
last_practice: 最后陪练时间
|
||||
has_ongoing: 是否有进行中的活动
|
||||
|
||||
Returns:
|
||||
状态: active(活跃), learning(学习中), rest(休息)
|
||||
"""
|
||||
# 获取最近活跃时间
|
||||
times = [t for t in [last_login, last_exam, last_practice] if t is not None]
|
||||
if not times:
|
||||
return 'rest'
|
||||
|
||||
last_active = max(times)
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
|
||||
# 判断状态
|
||||
if last_active >= thirty_days_ago:
|
||||
if has_ongoing:
|
||||
return 'learning'
|
||||
else:
|
||||
return 'active'
|
||||
else:
|
||||
return 'rest'
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=ResponseModel)
|
||||
async def get_team_statistics(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取团队统计数据
|
||||
|
||||
返回:团队总人数、活跃成员数、平均学习进度、团队平均分
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队成员ID
|
||||
member_ids = await get_accessible_team_member_ids(current_user, db)
|
||||
|
||||
# 团队总人数
|
||||
team_count = len(member_ids)
|
||||
|
||||
if team_count == 0:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"teamCount": 0,
|
||||
"activeMembers": 0,
|
||||
"avgProgress": 0,
|
||||
"avgScore": 0
|
||||
}
|
||||
)
|
||||
|
||||
# 统计活跃成员数(最近30天有活动)
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
|
||||
# 统计最近30天有登录或有考试或有陪练的用户
|
||||
active_users_stmt = select(func.count(func.distinct(User.id))).where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
or_(
|
||||
User.last_login_at >= thirty_days_ago,
|
||||
User.id.in_(
|
||||
select(Exam.user_id).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.created_at >= thirty_days_ago
|
||||
)
|
||||
)
|
||||
),
|
||||
User.id.in_(
|
||||
select(PracticeSession.user_id).where(
|
||||
and_(
|
||||
PracticeSession.user_id.in_(member_ids),
|
||||
PracticeSession.start_time >= thirty_days_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(active_users_stmt)
|
||||
active_members = result.scalar() or 0
|
||||
|
||||
# 计算平均学习进度(每个成员的完成课程/应完成课程的平均值)
|
||||
# 统计每个成员的进度,然后计算平均值
|
||||
total_progress = 0.0
|
||||
members_with_courses = 0
|
||||
|
||||
for member_id in member_ids:
|
||||
# 获取该成员岗位分配的课程数
|
||||
member_courses_stmt = select(
|
||||
func.count(func.distinct(PositionCourse.course_id))
|
||||
).select_from(PositionMember).join(
|
||||
PositionCourse,
|
||||
PositionCourse.position_id == PositionMember.position_id
|
||||
).where(
|
||||
and_(
|
||||
PositionMember.user_id == member_id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await db.execute(member_courses_stmt)
|
||||
member_total_courses = result.scalar() or 0
|
||||
|
||||
if member_total_courses > 0:
|
||||
# 获取该成员已完成(及格)的课程数
|
||||
member_completed_stmt = select(
|
||||
func.count(func.distinct(Exam.course_id))
|
||||
).where(
|
||||
and_(
|
||||
Exam.user_id == member_id,
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(member_completed_stmt)
|
||||
member_completed = result.scalar() or 0
|
||||
|
||||
# 计算该成员的进度(最大100%)
|
||||
member_progress = min((member_completed / member_total_courses) * 100, 100)
|
||||
total_progress += member_progress
|
||||
members_with_courses += 1
|
||||
|
||||
avg_progress = round(total_progress / members_with_courses, 1) if members_with_courses > 0 else 0.0
|
||||
|
||||
# 计算团队平均分(使用round1_score)
|
||||
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||
and_(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(avg_score_stmt)
|
||||
avg_score_value = result.scalar()
|
||||
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||
|
||||
data = {
|
||||
"teamCount": team_count,
|
||||
"activeMembers": active_members,
|
||||
"avgProgress": avg_progress,
|
||||
"avgScore": avg_score
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取团队统计失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取团队统计失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/members", response_model=ResponseModel[PaginatedResponse])
|
||||
async def get_team_members(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
search_text: Optional[str] = Query(None, description="搜索姓名、岗位"),
|
||||
status: Optional[str] = Query(None, description="筛选状态: active/learning/rest"),
|
||||
position: Optional[str] = Query(None, description="筛选岗位"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取团队成员列表(带筛选、搜索、分页)
|
||||
|
||||
返回成员基本信息、学习进度、成绩、学习时长等
|
||||
"""
|
||||
try:
|
||||
# 获取可访问的团队成员ID
|
||||
member_ids = await get_accessible_team_member_ids(current_user, db)
|
||||
|
||||
if not member_ids:
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=PaginatedResponse(
|
||||
items=[],
|
||||
total=0,
|
||||
page=page,
|
||||
page_size=size,
|
||||
pages=0
|
||||
)
|
||||
)
|
||||
|
||||
# 构建基础查询
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.id.in_(member_ids),
|
||||
User.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
|
||||
# 搜索条件(姓名)
|
||||
if search_text:
|
||||
like_pattern = f"%{search_text}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
User.full_name.ilike(like_pattern),
|
||||
User.username.ilike(like_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
# 先获取所有符合条件的用户,然后在Python中过滤状态和岗位
|
||||
result = await db.execute(stmt)
|
||||
all_users = result.scalars().all()
|
||||
|
||||
# 为每个用户计算详细信息
|
||||
member_list = []
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
|
||||
for user in all_users:
|
||||
# 获取用户岗位
|
||||
position_stmt = select(Position.name).select_from(PositionMember).join(
|
||||
Position,
|
||||
Position.id == PositionMember.position_id
|
||||
).where(
|
||||
and_(
|
||||
PositionMember.user_id == user.id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
)
|
||||
).limit(1)
|
||||
result = await db.execute(position_stmt)
|
||||
position_name = result.scalar()
|
||||
|
||||
# 如果有岗位筛选且不匹配,跳过
|
||||
if position and position_name != position:
|
||||
continue
|
||||
|
||||
# 获取最近考试时间
|
||||
last_exam_stmt = select(func.max(Exam.created_at)).where(
|
||||
Exam.user_id == user.id
|
||||
)
|
||||
result = await db.execute(last_exam_stmt)
|
||||
last_exam = result.scalar()
|
||||
|
||||
# 获取最近陪练时间
|
||||
last_practice_stmt = select(func.max(PracticeSession.start_time)).where(
|
||||
PracticeSession.user_id == user.id
|
||||
)
|
||||
result = await db.execute(last_practice_stmt)
|
||||
last_practice = result.scalar()
|
||||
|
||||
# 检查是否有进行中的活动
|
||||
has_ongoing_stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.status == 'started'
|
||||
)
|
||||
)
|
||||
result = await db.execute(has_ongoing_stmt)
|
||||
has_ongoing = (result.scalar() or 0) > 0
|
||||
|
||||
# 计算状态
|
||||
member_status = calculate_member_status(
|
||||
user.last_login_at,
|
||||
last_exam,
|
||||
last_practice,
|
||||
has_ongoing
|
||||
)
|
||||
|
||||
# 如果有状态筛选且不匹配,跳过
|
||||
if status and member_status != status:
|
||||
continue
|
||||
|
||||
# 统计学习进度
|
||||
# 1. 获取岗位分配的课程总数
|
||||
total_courses_stmt = select(
|
||||
func.count(func.distinct(PositionCourse.course_id))
|
||||
).select_from(PositionMember).join(
|
||||
PositionCourse,
|
||||
PositionCourse.position_id == PositionMember.position_id
|
||||
).where(
|
||||
and_(
|
||||
PositionMember.user_id == user.id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await db.execute(total_courses_stmt)
|
||||
total_courses = result.scalar() or 0
|
||||
|
||||
# 2. 统计已完成的考试(及格)
|
||||
completed_courses_stmt = select(
|
||||
func.count(func.distinct(Exam.course_id))
|
||||
).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(completed_courses_stmt)
|
||||
completed_courses = result.scalar() or 0
|
||||
|
||||
# 3. 计算进度
|
||||
progress = 0
|
||||
if total_courses > 0:
|
||||
progress = int((completed_courses / total_courses) * 100)
|
||||
|
||||
# 统计平均成绩
|
||||
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(avg_score_stmt)
|
||||
avg_score_value = result.scalar()
|
||||
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||
|
||||
# 统计学习时长(考试时长+陪练时长)
|
||||
exam_time_stmt = select(
|
||||
func.coalesce(func.sum(Exam.duration_minutes), 0)
|
||||
).where(Exam.user_id == user.id)
|
||||
result = await db.execute(exam_time_stmt)
|
||||
exam_minutes = float(result.scalar() or 0)
|
||||
|
||||
practice_time_stmt = select(
|
||||
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||
).where(
|
||||
and_(
|
||||
PracticeSession.user_id == user.id,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
result = await db.execute(practice_time_stmt)
|
||||
practice_seconds = float(result.scalar() or 0)
|
||||
|
||||
total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||
|
||||
# 获取最近活跃时间
|
||||
active_times = [t for t in [user.last_login_at, last_exam, last_practice] if t is not None]
|
||||
last_active = max(active_times).strftime("%Y-%m-%d %H:%M") if active_times else "-"
|
||||
|
||||
member_list.append({
|
||||
"id": user.id,
|
||||
"name": user.full_name or user.username,
|
||||
"avatar": user.avatar_url or "",
|
||||
"position": position_name or "未分配岗位",
|
||||
"status": member_status,
|
||||
"progress": progress,
|
||||
"completedCourses": completed_courses,
|
||||
"totalCourses": total_courses,
|
||||
"avgScore": avg_score,
|
||||
"studyTime": total_hours,
|
||||
"lastActive": last_active,
|
||||
"joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-",
|
||||
"email": user.email or "",
|
||||
"phone": user.phone or "",
|
||||
"passRate": 100 if completed_courses > 0 else 0 # 简化计算
|
||||
})
|
||||
|
||||
# 分页
|
||||
total = len(member_list)
|
||||
pages = (total + size - 1) // size if size > 0 else 0
|
||||
start = (page - 1) * size
|
||||
end = start + size
|
||||
items = member_list[start:end]
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="success",
|
||||
data=PaginatedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=size,
|
||||
pages=pages
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取团队成员列表失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取团队成员列表失败: {str(e)}",
|
||||
data=None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/members/{member_id}/detail", response_model=ResponseModel)
|
||||
async def get_member_detail(
|
||||
member_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取成员详情
|
||||
|
||||
返回完整的成员信息和最近学习记录
|
||||
"""
|
||||
try:
|
||||
# 权限检查:确保member_id在可访问范围内
|
||||
accessible_ids = await get_accessible_team_member_ids(current_user, db)
|
||||
if member_id not in accessible_ids:
|
||||
return ResponseModel(
|
||||
code=403,
|
||||
message="无权访问该成员信息",
|
||||
data=None
|
||||
)
|
||||
|
||||
# 获取用户基本信息
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.id == member_id,
|
||||
User.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return ResponseModel(code=404, message="成员不存在", data=None)
|
||||
|
||||
# 获取岗位
|
||||
position_stmt = select(Position.name).select_from(PositionMember).join(
|
||||
Position,
|
||||
Position.id == PositionMember.position_id
|
||||
).where(
|
||||
and_(
|
||||
PositionMember.user_id == user.id,
|
||||
PositionMember.is_deleted == False # noqa: E712
|
||||
)
|
||||
).limit(1)
|
||||
result = await db.execute(position_stmt)
|
||||
position_name = result.scalar() or "未分配岗位"
|
||||
|
||||
# 计算状态
|
||||
last_exam_stmt = select(func.max(Exam.created_at)).where(Exam.user_id == user.id)
|
||||
result = await db.execute(last_exam_stmt)
|
||||
last_exam = result.scalar()
|
||||
|
||||
last_practice_stmt = select(func.max(PracticeSession.start_time)).where(
|
||||
PracticeSession.user_id == user.id
|
||||
)
|
||||
result = await db.execute(last_practice_stmt)
|
||||
last_practice = result.scalar()
|
||||
|
||||
has_ongoing_stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.status == 'started'
|
||||
)
|
||||
)
|
||||
result = await db.execute(has_ongoing_stmt)
|
||||
has_ongoing = (result.scalar() or 0) > 0
|
||||
|
||||
member_status = calculate_member_status(
|
||||
user.last_login_at,
|
||||
last_exam,
|
||||
last_practice,
|
||||
has_ongoing
|
||||
)
|
||||
|
||||
# 统计学习数据
|
||||
# 学习时长
|
||||
exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where(
|
||||
Exam.user_id == user.id
|
||||
)
|
||||
result = await db.execute(exam_time_stmt)
|
||||
exam_minutes = result.scalar() or 0
|
||||
|
||||
practice_time_stmt = select(
|
||||
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||
).where(
|
||||
and_(
|
||||
PracticeSession.user_id == user.id,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
result = await db.execute(practice_time_stmt)
|
||||
practice_seconds = result.scalar() or 0
|
||||
|
||||
study_time = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||
|
||||
# 完成课程数
|
||||
completed_courses_stmt = select(
|
||||
func.count(func.distinct(Exam.course_id))
|
||||
).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(completed_courses_stmt)
|
||||
completed_courses = result.scalar() or 0
|
||||
|
||||
# 平均成绩
|
||||
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(avg_score_stmt)
|
||||
avg_score_value = result.scalar()
|
||||
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||
|
||||
# 通过率
|
||||
total_exams_stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(total_exams_stmt)
|
||||
total_exams = result.scalar() or 0
|
||||
|
||||
passed_exams_stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(passed_exams_stmt)
|
||||
passed_exams = result.scalar() or 0
|
||||
|
||||
pass_rate = round((passed_exams / total_exams) * 100) if total_exams > 0 else 0
|
||||
|
||||
# 获取最近学习记录(最近10条考试和陪练)
|
||||
recent_records = []
|
||||
|
||||
# 考试记录
|
||||
exam_records_stmt = (
|
||||
select(Exam, Course.name.label('course_name'))
|
||||
.join(Course, Course.id == Exam.course_id)
|
||||
.where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
.order_by(Exam.updated_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
result = await db.execute(exam_records_stmt)
|
||||
exam_records = result.all()
|
||||
|
||||
for exam, course_name in exam_records:
|
||||
score = exam.round1_score or 0
|
||||
record_type = "success" if score >= 60 else "danger"
|
||||
recent_records.append({
|
||||
"id": f"exam_{exam.id}",
|
||||
"time": exam.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"content": f"完成《{course_name}》课程考试,成绩:{int(score)}分",
|
||||
"type": record_type
|
||||
})
|
||||
|
||||
# 陪练记录
|
||||
practice_records_stmt = (
|
||||
select(PracticeSession)
|
||||
.where(
|
||||
and_(
|
||||
PracticeSession.user_id == user.id,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
.order_by(PracticeSession.end_time.desc())
|
||||
.limit(5)
|
||||
)
|
||||
result = await db.execute(practice_records_stmt)
|
||||
practice_records = result.scalars().all()
|
||||
|
||||
for session in practice_records:
|
||||
recent_records.append({
|
||||
"id": f"practice_{session.id}",
|
||||
"time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "",
|
||||
"content": "参加AI陪练训练",
|
||||
"type": "primary"
|
||||
})
|
||||
|
||||
# 按时间排序
|
||||
recent_records.sort(key=lambda x: x['time'], reverse=True)
|
||||
recent_records = recent_records[:10]
|
||||
|
||||
data = {
|
||||
"id": user.id,
|
||||
"name": user.full_name or user.username,
|
||||
"avatar": user.avatar_url or "",
|
||||
"position": position_name,
|
||||
"status": member_status,
|
||||
"joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-",
|
||||
"email": user.email or "",
|
||||
"phone": user.phone or "",
|
||||
"studyTime": study_time,
|
||||
"completedCourses": completed_courses,
|
||||
"avgScore": avg_score,
|
||||
"passRate": pass_rate,
|
||||
"recentRecords": recent_records
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取成员详情失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取成员详情失败: {str(e)}",
|
||||
data=None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/members/{member_id}/report", response_model=ResponseModel)
|
||||
async def get_member_report(
|
||||
member_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取成员学习报告
|
||||
|
||||
返回学习概览、30天进度趋势、能力评估、详细学习记录
|
||||
"""
|
||||
try:
|
||||
# 权限检查
|
||||
accessible_ids = await get_accessible_team_member_ids(current_user, db)
|
||||
if member_id not in accessible_ids:
|
||||
return ResponseModel(code=403, message="无权访问该成员信息", data=None)
|
||||
|
||||
# 获取用户信息
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.id == member_id,
|
||||
User.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return ResponseModel(code=404, message="成员不存在", data=None)
|
||||
|
||||
# 1. 报告概览
|
||||
# 学习总时长
|
||||
exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where(
|
||||
Exam.user_id == user.id
|
||||
)
|
||||
result = await db.execute(exam_time_stmt)
|
||||
exam_minutes = result.scalar() or 0
|
||||
|
||||
practice_time_stmt = select(
|
||||
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||
).where(
|
||||
and_(
|
||||
PracticeSession.user_id == user.id,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
result = await db.execute(practice_time_stmt)
|
||||
practice_seconds = result.scalar() or 0
|
||||
|
||||
total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||
|
||||
# 完成课程数
|
||||
completed_courses_stmt = select(
|
||||
func.count(func.distinct(Exam.course_id))
|
||||
).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score >= 60,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(completed_courses_stmt)
|
||||
completed_courses = result.scalar() or 0
|
||||
|
||||
# 平均成绩
|
||||
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.round1_score.isnot(None),
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(avg_score_stmt)
|
||||
avg_score_value = result.scalar()
|
||||
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||
|
||||
# 学习排名(简化:在团队中的排名)
|
||||
# TODO: 实现真实排名计算
|
||||
ranking = "第5名"
|
||||
|
||||
overview = [
|
||||
{
|
||||
"label": "学习总时长",
|
||||
"value": f"{total_hours}小时",
|
||||
"icon": "Clock",
|
||||
"color": "#667eea",
|
||||
"bgColor": "rgba(102, 126, 234, 0.1)"
|
||||
},
|
||||
{
|
||||
"label": "完成课程",
|
||||
"value": f"{completed_courses}门",
|
||||
"icon": "CircleCheck",
|
||||
"color": "#67c23a",
|
||||
"bgColor": "rgba(103, 194, 58, 0.1)"
|
||||
},
|
||||
{
|
||||
"label": "平均成绩",
|
||||
"value": f"{avg_score}分",
|
||||
"icon": "Trophy",
|
||||
"color": "#e6a23c",
|
||||
"bgColor": "rgba(230, 162, 60, 0.1)"
|
||||
},
|
||||
{
|
||||
"label": "学习排名",
|
||||
"value": ranking,
|
||||
"icon": "Medal",
|
||||
"color": "#f56c6c",
|
||||
"bgColor": "rgba(245, 108, 108, 0.1)"
|
||||
}
|
||||
]
|
||||
|
||||
# 2. 30天学习进度趋势
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
dates = []
|
||||
progress_data = []
|
||||
|
||||
for i in range(30):
|
||||
date = thirty_days_ago + timedelta(days=i)
|
||||
dates.append(date.strftime("%m-%d"))
|
||||
|
||||
# 统计该日期之前完成的考试数
|
||||
cumulative_exams_stmt = select(func.count(Exam.id)).where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.created_at <= date,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
result = await db.execute(cumulative_exams_stmt)
|
||||
cumulative = result.scalar() or 0
|
||||
|
||||
# 进度 = 累计考试数 * 10(简化计算)
|
||||
progress = min(cumulative * 10, 100)
|
||||
progress_data.append(progress)
|
||||
|
||||
# 3. 能力评估(从陪练报告聚合)
|
||||
ability_stmt = select(PracticeReport.ability_dimensions).where(
|
||||
PracticeReport.user_id == user.id
|
||||
)
|
||||
result = await db.execute(ability_stmt)
|
||||
all_dimensions = result.scalars().all()
|
||||
|
||||
abilities = []
|
||||
if all_dimensions:
|
||||
# 聚合能力数据
|
||||
ability_scores: Dict[str, List[float]] = {}
|
||||
|
||||
for dimensions in all_dimensions:
|
||||
if dimensions:
|
||||
for dim in dimensions:
|
||||
name = dim.get('name', '')
|
||||
score = dim.get('score', 0)
|
||||
if name:
|
||||
if name not in ability_scores:
|
||||
ability_scores[name] = []
|
||||
ability_scores[name].append(float(score))
|
||||
|
||||
# 计算平均分
|
||||
for name, scores in ability_scores.items():
|
||||
avg = sum(scores) / len(scores)
|
||||
description = "表现良好" if avg >= 80 else "需要加强"
|
||||
abilities.append({
|
||||
"name": name,
|
||||
"score": int(avg),
|
||||
"description": description
|
||||
})
|
||||
else:
|
||||
# 默认能力评估
|
||||
default_abilities = [
|
||||
{"name": "沟通表达", "score": 0, "description": "暂无数据"},
|
||||
{"name": "需求挖掘", "score": 0, "description": "暂无数据"},
|
||||
{"name": "产品知识", "score": 0, "description": "暂无数据"},
|
||||
{"name": "成交技巧", "score": 0, "description": "暂无数据"}
|
||||
]
|
||||
abilities = default_abilities
|
||||
|
||||
# 4. 详细学习记录(最近20条)
|
||||
records = []
|
||||
|
||||
# 考试记录
|
||||
exam_records_stmt = (
|
||||
select(Exam, Course.name.label('course_name'))
|
||||
.join(Course, Course.id == Exam.course_id)
|
||||
.where(
|
||||
and_(
|
||||
Exam.user_id == user.id,
|
||||
Exam.status.in_(['completed', 'submitted'])
|
||||
)
|
||||
)
|
||||
.order_by(Exam.updated_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
result = await db.execute(exam_records_stmt)
|
||||
exam_records = result.all()
|
||||
|
||||
for exam, course_name in exam_records:
|
||||
score = exam.round1_score or 0
|
||||
records.append({
|
||||
"date": exam.updated_at.strftime("%Y-%m-%d"),
|
||||
"course": course_name,
|
||||
"duration": exam.duration_minutes or 0,
|
||||
"score": int(score),
|
||||
"status": "completed"
|
||||
})
|
||||
|
||||
data = {
|
||||
"overview": overview,
|
||||
"progressTrend": {
|
||||
"dates": dates,
|
||||
"data": progress_data
|
||||
},
|
||||
"abilities": abilities,
|
||||
"records": records[:20]
|
||||
}
|
||||
|
||||
return ResponseModel(code=200, message="success", data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取成员学习报告失败: {e}", exc_info=True)
|
||||
return ResponseModel(
|
||||
code=500,
|
||||
message=f"获取成员学习报告失败: {str(e)}",
|
||||
data=None
|
||||
)
|
||||
|
||||
55
backend/app/api/v1/teams.py
Normal file
55
backend/app/api/v1/teams.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
团队相关 API 路由
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.user import Team
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
|
||||
router = APIRouter(prefix="/teams", tags=["teams"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ResponseModel)
|
||||
async def list_teams(
|
||||
keyword: Optional[str] = Query(None, description="按名称或编码模糊搜索"),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取团队列表
|
||||
|
||||
任何登录用户均可查询团队列表,用于前端下拉选择。
|
||||
"""
|
||||
try:
|
||||
stmt = select(Team).where(Team.is_deleted == False) # noqa: E712
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
stmt = stmt.where(or_(Team.name.ilike(like), Team.code.ilike(like)))
|
||||
|
||||
rows: List[Team] = (await db.execute(stmt)).scalars().all()
|
||||
data = [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"code": t.code,
|
||||
"team_type": t.team_type,
|
||||
}
|
||||
for t in rows
|
||||
]
|
||||
return ResponseModel(code=200, message="OK", data=data)
|
||||
except Exception:
|
||||
logger.error("查询团队列表失败", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="查询团队列表失败",
|
||||
)
|
||||
|
||||
|
||||
507
backend/app/api/v1/training.py
Normal file
507
backend/app/api/v1/training.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""陪练模块API路由"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user, require_admin
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.training import (
|
||||
TrainingSceneCreate,
|
||||
TrainingSceneUpdate,
|
||||
TrainingSceneResponse,
|
||||
TrainingSessionResponse,
|
||||
TrainingMessageResponse,
|
||||
TrainingReportResponse,
|
||||
StartTrainingRequest,
|
||||
StartTrainingResponse,
|
||||
EndTrainingRequest,
|
||||
EndTrainingResponse,
|
||||
TrainingSceneListQuery,
|
||||
TrainingSessionListQuery,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from app.services.training_service import (
|
||||
TrainingSceneService,
|
||||
TrainingSessionService,
|
||||
TrainingMessageService,
|
||||
TrainingReportService,
|
||||
)
|
||||
from app.models.training import TrainingSceneStatus, TrainingSessionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/training", tags=["陪练模块"])
|
||||
|
||||
# 服务实例
|
||||
scene_service = TrainingSceneService()
|
||||
session_service = TrainingSessionService()
|
||||
message_service = TrainingMessageService()
|
||||
report_service = TrainingReportService()
|
||||
|
||||
|
||||
# ========== 陪练场景管理 ==========
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scenes", response_model=ResponseModel[PaginatedResponse[TrainingSceneResponse]]
|
||||
)
|
||||
async def get_training_scenes(
|
||||
category: Optional[str] = Query(None, description="场景分类"),
|
||||
status: Optional[TrainingSceneStatus] = Query(None, description="场景状态"),
|
||||
is_public: Optional[bool] = Query(None, description="是否公开"),
|
||||
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取陪练场景列表
|
||||
|
||||
- 支持按分类、状态、是否公开筛选
|
||||
- 支持关键词搜索
|
||||
- 支持分页
|
||||
"""
|
||||
try:
|
||||
# 计算分页参数
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# 获取用户等级(TODO: 从User服务获取)
|
||||
user_level = 1
|
||||
|
||||
# 获取场景列表
|
||||
scenes = await scene_service.get_active_scenes(
|
||||
db,
|
||||
category=category,
|
||||
is_public=is_public,
|
||||
user_level=user_level,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
)
|
||||
|
||||
# 获取总数
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.training import TrainingScene
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(TrainingScene)
|
||||
.where(
|
||||
and_(
|
||||
TrainingScene.status == TrainingSceneStatus.ACTIVE,
|
||||
TrainingScene.is_deleted == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if category:
|
||||
count_query = count_query.where(TrainingScene.category == category)
|
||||
if is_public is not None:
|
||||
count_query = count_query.where(TrainingScene.is_public == is_public)
|
||||
|
||||
result = await db.execute(count_query)
|
||||
total = result.scalar_one()
|
||||
|
||||
# 计算总页数
|
||||
pages = (total + page_size - 1) // page_size
|
||||
|
||||
return ResponseModel(
|
||||
data=PaginatedResponse(
|
||||
items=scenes, total=total, page=page, page_size=page_size, pages=pages
|
||||
),
|
||||
message="获取陪练场景列表成功",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取陪练场景列表失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练场景列表失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse])
|
||||
async def get_training_scene(
|
||||
scene_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取陪练场景详情"""
|
||||
scene = await scene_service.get(db, scene_id)
|
||||
|
||||
if not scene or scene.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||
|
||||
# 检查访问权限
|
||||
if not scene.is_public and current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此场景")
|
||||
|
||||
return ResponseModel(data=scene, message="获取陪练场景成功")
|
||||
|
||||
|
||||
@router.post("/scenes", response_model=ResponseModel[TrainingSceneResponse])
|
||||
async def create_training_scene(
|
||||
scene_in: TrainingSceneCreate,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建陪练场景(管理员)
|
||||
|
||||
- 需要管理员权限
|
||||
- 场景默认为草稿状态
|
||||
"""
|
||||
try:
|
||||
scene = await scene_service.create_scene(
|
||||
db, scene_in=scene_in, created_by=current_user["id"]
|
||||
)
|
||||
|
||||
logger.info(f"管理员 {current_user['id']} 创建了陪练场景: {scene.id}")
|
||||
|
||||
return ResponseModel(data=scene, message="创建陪练场景成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建陪练场景失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建陪练场景失败"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse])
|
||||
async def update_training_scene(
|
||||
scene_id: int,
|
||||
scene_in: TrainingSceneUpdate,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新陪练场景(管理员)"""
|
||||
scene = await scene_service.update_scene(
|
||||
db, scene_id=scene_id, scene_in=scene_in, updated_by=current_user["id"]
|
||||
)
|
||||
|
||||
if not scene:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||
|
||||
logger.info(f"管理员 {current_user['id']} 更新了陪练场景: {scene_id}")
|
||||
|
||||
return ResponseModel(data=scene, message="更新陪练场景成功")
|
||||
|
||||
|
||||
@router.delete("/scenes/{scene_id}", response_model=ResponseModel[bool])
|
||||
async def delete_training_scene(
|
||||
scene_id: int,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除陪练场景(管理员)"""
|
||||
success = await scene_service.soft_delete(db, id=scene_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||
|
||||
logger.info(f"管理员 {current_user['id']} 删除了陪练场景: {scene_id}")
|
||||
|
||||
return ResponseModel(data=True, message="删除陪练场景成功")
|
||||
|
||||
|
||||
# ========== 陪练会话管理 ==========
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=ResponseModel[StartTrainingResponse])
|
||||
async def start_training(
|
||||
request: StartTrainingRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
开始陪练会话
|
||||
|
||||
- 需要登录
|
||||
- 创建会话记录
|
||||
- 初始化Coze对话(如果配置了Bot)
|
||||
- 返回会话信息和WebSocket连接地址(如果支持)
|
||||
"""
|
||||
try:
|
||||
response = await session_service.start_training(
|
||||
db, request=request, user_id=current_user["id"]
|
||||
)
|
||||
|
||||
logger.info(f"用户 {current_user['id']} 开始陪练会话: {response.session_id}")
|
||||
|
||||
return ResponseModel(data=response, message="开始陪练成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"开始陪练失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="开始陪练失败"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/end", response_model=ResponseModel[EndTrainingResponse]
|
||||
)
|
||||
async def end_training(
|
||||
session_id: int,
|
||||
request: EndTrainingRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
结束陪练会话
|
||||
|
||||
- 需要登录且是会话创建者
|
||||
- 更新会话状态
|
||||
- 可选生成陪练报告
|
||||
"""
|
||||
try:
|
||||
response = await session_service.end_training(
|
||||
db, session_id=session_id, request=request, user_id=current_user["id"]
|
||||
)
|
||||
|
||||
logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}")
|
||||
|
||||
return ResponseModel(data=response, message="结束陪练成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"结束陪练失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="结束陪练失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions",
|
||||
response_model=ResponseModel[PaginatedResponse[TrainingSessionResponse]],
|
||||
)
|
||||
async def get_training_sessions(
|
||||
scene_id: Optional[int] = Query(None, description="场景ID"),
|
||||
status: Optional[TrainingSessionStatus] = Query(None, description="会话状态"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取用户的陪练会话列表"""
|
||||
try:
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
sessions = await session_service.get_user_sessions(
|
||||
db,
|
||||
user_id=current_user["id"],
|
||||
scene_id=scene_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
)
|
||||
|
||||
# 获取总数
|
||||
from sqlalchemy import select, func
|
||||
from app.models.training import TrainingSession
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(TrainingSession)
|
||||
.where(TrainingSession.user_id == current_user["id"])
|
||||
)
|
||||
|
||||
if scene_id:
|
||||
count_query = count_query.where(TrainingSession.scene_id == scene_id)
|
||||
if status:
|
||||
count_query = count_query.where(TrainingSession.status == status)
|
||||
|
||||
result = await db.execute(count_query)
|
||||
total = result.scalar_one()
|
||||
|
||||
pages = (total + page_size - 1) // page_size
|
||||
|
||||
# 加载关联的场景信息
|
||||
for session in sessions:
|
||||
await db.refresh(session, ["scene"])
|
||||
|
||||
return ResponseModel(
|
||||
data=PaginatedResponse(
|
||||
items=sessions, total=total, page=page, page_size=page_size, pages=pages
|
||||
),
|
||||
message="获取陪练会话列表成功",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取陪练会话列表失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练会话列表失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}", response_model=ResponseModel[TrainingSessionResponse]
|
||||
)
|
||||
async def get_training_session(
|
||||
session_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取陪练会话详情"""
|
||||
session = await session_service.get(db, session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||
|
||||
# 检查访问权限
|
||||
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话")
|
||||
|
||||
# 加载关联数据
|
||||
await db.refresh(session, ["scene"])
|
||||
|
||||
# 获取消息数量
|
||||
messages = await message_service.get_session_messages(db, session_id=session_id)
|
||||
session.message_count = len(messages)
|
||||
|
||||
return ResponseModel(data=session, message="获取陪练会话成功")
|
||||
|
||||
|
||||
# ========== 消息管理 ==========
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/messages",
|
||||
response_model=ResponseModel[List[TrainingMessageResponse]],
|
||||
)
|
||||
async def get_training_messages(
|
||||
session_id: int,
|
||||
skip: int = Query(0, ge=0, description="跳过数量"),
|
||||
limit: int = Query(100, ge=1, le=500, description="返回数量"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取陪练会话的消息列表"""
|
||||
# 验证会话访问权限
|
||||
session = await session_service.get(db, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||
|
||||
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话消息")
|
||||
|
||||
messages = await message_service.get_session_messages(
|
||||
db, session_id=session_id, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
return ResponseModel(data=messages, message="获取消息列表成功")
|
||||
|
||||
|
||||
# ========== 报告管理 ==========
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reports", response_model=ResponseModel[PaginatedResponse[TrainingReportResponse]]
|
||||
)
|
||||
async def get_training_reports(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取用户的陪练报告列表"""
|
||||
try:
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
reports = await report_service.get_user_reports(
|
||||
db, user_id=current_user["id"], skip=skip, limit=page_size
|
||||
)
|
||||
|
||||
# 获取总数
|
||||
from sqlalchemy import select, func
|
||||
from app.models.training import TrainingReport
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(TrainingReport)
|
||||
.where(TrainingReport.user_id == current_user["id"])
|
||||
)
|
||||
|
||||
result = await db.execute(count_query)
|
||||
total = result.scalar_one()
|
||||
|
||||
pages = (total + page_size - 1) // page_size
|
||||
|
||||
# 加载关联的会话信息
|
||||
for report in reports:
|
||||
await db.refresh(report, ["session"])
|
||||
if report.session:
|
||||
await db.refresh(report.session, ["scene"])
|
||||
|
||||
return ResponseModel(
|
||||
data=PaginatedResponse(
|
||||
items=reports, total=total, page=page, page_size=page_size, pages=pages
|
||||
),
|
||||
message="获取陪练报告列表成功",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取陪练报告列表失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练报告列表失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reports/{report_id}", response_model=ResponseModel[TrainingReportResponse]
|
||||
)
|
||||
async def get_training_report(
|
||||
report_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取陪练报告详情"""
|
||||
report = await report_service.get(db, report_id)
|
||||
|
||||
if not report:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练报告不存在")
|
||||
|
||||
# 检查访问权限
|
||||
if report.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此报告")
|
||||
|
||||
# 加载关联数据
|
||||
await db.refresh(report, ["session"])
|
||||
if report.session:
|
||||
await db.refresh(report.session, ["scene"])
|
||||
|
||||
return ResponseModel(data=report, message="获取陪练报告成功")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/report",
|
||||
response_model=ResponseModel[TrainingReportResponse],
|
||||
)
|
||||
async def get_session_report(
|
||||
session_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""根据会话ID获取陪练报告"""
|
||||
# 验证会话访问权限
|
||||
session = await session_service.get(db, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||
|
||||
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话报告")
|
||||
|
||||
# 获取报告
|
||||
report = await report_service.get_by_session(db, session_id=session_id)
|
||||
|
||||
if not report:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该会话暂无报告")
|
||||
|
||||
# 加载关联数据
|
||||
await db.refresh(report, ["session"])
|
||||
if report.session:
|
||||
await db.refresh(report.session, ["scene"])
|
||||
|
||||
return ResponseModel(data=report, message="获取会话报告成功")
|
||||
854
backend/app/api/v1/training_api_contract.yaml
Normal file
854
backend/app/api/v1/training_api_contract.yaml
Normal file
@@ -0,0 +1,854 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Training Module API
|
||||
description: 考培练系统陪练模块API契约
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8000/api/v1
|
||||
description: 本地开发服务器
|
||||
|
||||
paths:
|
||||
/training/scenes:
|
||||
get:
|
||||
summary: 获取陪练场景列表
|
||||
tags:
|
||||
- 陪练场景
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: category
|
||||
in: query
|
||||
description: 场景分类
|
||||
schema:
|
||||
type: string
|
||||
- name: status
|
||||
in: query
|
||||
description: 场景状态
|
||||
schema:
|
||||
type: string
|
||||
enum: [draft, active, inactive]
|
||||
- name: is_public
|
||||
in: query
|
||||
description: 是否公开
|
||||
schema:
|
||||
type: boolean
|
||||
- name: search
|
||||
in: query
|
||||
description: 搜索关键词
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: 页码
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
description: 每页数量
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedScenesResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
post:
|
||||
summary: 创建陪练场景(管理员)
|
||||
tags:
|
||||
- 陪练场景
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSceneCreate'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/training/scenes/{scene_id}:
|
||||
get:
|
||||
summary: 获取陪练场景详情
|
||||
tags:
|
||||
- 陪练场景
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: scene_id
|
||||
in: path
|
||||
required: true
|
||||
description: 场景ID
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
put:
|
||||
summary: 更新陪练场景(管理员)
|
||||
tags:
|
||||
- 陪练场景
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: scene_id
|
||||
in: path
|
||||
required: true
|
||||
description: 场景ID
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSceneUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
delete:
|
||||
summary: 删除陪练场景(管理员)
|
||||
tags:
|
||||
- 陪练场景
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: scene_id
|
||||
in: path
|
||||
required: true
|
||||
description: 场景ID
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
example: 200
|
||||
message:
|
||||
type: string
|
||||
example: "删除陪练场景成功"
|
||||
data:
|
||||
type: boolean
|
||||
example: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/training/sessions:
|
||||
post:
|
||||
summary: 开始陪练会话
|
||||
tags:
|
||||
- 陪练会话
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StartTrainingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StartTrainingResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
description: 场景不存在
|
||||
|
||||
get:
|
||||
summary: 获取用户的陪练会话列表
|
||||
tags:
|
||||
- 陪练会话
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: scene_id
|
||||
in: query
|
||||
description: 场景ID
|
||||
schema:
|
||||
type: integer
|
||||
- name: status
|
||||
in: query
|
||||
description: 会话状态
|
||||
schema:
|
||||
type: string
|
||||
enum: [created, in_progress, completed, cancelled, error]
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedSessionsResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/training/sessions/{session_id}:
|
||||
get:
|
||||
summary: 获取陪练会话详情
|
||||
tags:
|
||||
- 陪练会话
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: session_id
|
||||
in: path
|
||||
required: true
|
||||
description: 会话ID
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingSessionResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/training/sessions/{session_id}/end:
|
||||
post:
|
||||
summary: 结束陪练会话
|
||||
tags:
|
||||
- 陪练会话
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: session_id
|
||||
in: path
|
||||
required: true
|
||||
description: 会话ID
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EndTrainingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EndTrainingResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/training/sessions/{session_id}/messages:
|
||||
get:
|
||||
summary: 获取陪练会话的消息列表
|
||||
tags:
|
||||
- 陪练消息
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: session_id
|
||||
in: path
|
||||
required: true
|
||||
description: 会话ID
|
||||
schema:
|
||||
type: integer
|
||||
- name: skip
|
||||
in: query
|
||||
description: 跳过数量
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- name: limit
|
||||
in: query
|
||||
description: 返回数量
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 500
|
||||
default: 100
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TrainingMessage'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/training/reports:
|
||||
get:
|
||||
summary: 获取用户的陪练报告列表
|
||||
tags:
|
||||
- 陪练报告
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedReportsResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/training/reports/{report_id}:
|
||||
get:
|
||||
summary: 获取陪练报告详情
|
||||
tags:
|
||||
- 陪练报告
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: report_id
|
||||
in: path
|
||||
required: true
|
||||
description: 报告ID
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingReportResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/training/sessions/{session_id}/report:
|
||||
get:
|
||||
summary: 根据会话ID获取陪练报告
|
||||
tags:
|
||||
- 陪练报告
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: session_id
|
||||
in: path
|
||||
required: true
|
||||
description: 会话ID
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrainingReportResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: 未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
Forbidden:
|
||||
description: 禁止访问
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
NotFound:
|
||||
description: 资源未找到
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
schemas:
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
detail:
|
||||
type: object
|
||||
|
||||
BaseResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
example: 200
|
||||
message:
|
||||
type: string
|
||||
example: "success"
|
||||
request_id:
|
||||
type: string
|
||||
|
||||
PaginationMeta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
|
||||
TrainingSceneCreate:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- category
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: 场景名称
|
||||
description:
|
||||
type: string
|
||||
description: 场景描述
|
||||
category:
|
||||
type: string
|
||||
maxLength: 50
|
||||
description: 场景分类
|
||||
ai_config:
|
||||
type: object
|
||||
description: AI配置
|
||||
prompt_template:
|
||||
type: string
|
||||
description: 提示词模板
|
||||
evaluation_criteria:
|
||||
type: object
|
||||
description: 评估标准
|
||||
is_public:
|
||||
type: boolean
|
||||
default: true
|
||||
description: 是否公开
|
||||
required_level:
|
||||
type: integer
|
||||
description: 所需用户等级
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, active, inactive]
|
||||
default: draft
|
||||
|
||||
TrainingSceneUpdate:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
maxLength: 50
|
||||
ai_config:
|
||||
type: object
|
||||
prompt_template:
|
||||
type: string
|
||||
evaluation_criteria:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, active, inactive]
|
||||
is_public:
|
||||
type: boolean
|
||||
required_level:
|
||||
type: integer
|
||||
|
||||
TrainingScene:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
ai_config:
|
||||
type: object
|
||||
prompt_template:
|
||||
type: string
|
||||
evaluation_criteria:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, active, inactive]
|
||||
is_public:
|
||||
type: boolean
|
||||
required_level:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
TrainingSceneResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TrainingScene'
|
||||
|
||||
PaginatedScenesResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TrainingScene'
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
|
||||
StartTrainingRequest:
|
||||
type: object
|
||||
required:
|
||||
- scene_id
|
||||
properties:
|
||||
scene_id:
|
||||
type: integer
|
||||
description: 场景ID
|
||||
config:
|
||||
type: object
|
||||
description: 会话配置
|
||||
|
||||
StartTrainingResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
coze_conversation_id:
|
||||
type: string
|
||||
scene:
|
||||
$ref: '#/components/schemas/TrainingScene'
|
||||
websocket_url:
|
||||
type: string
|
||||
|
||||
EndTrainingRequest:
|
||||
type: object
|
||||
properties:
|
||||
generate_report:
|
||||
type: boolean
|
||||
default: true
|
||||
description: 是否生成报告
|
||||
|
||||
EndTrainingResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
session:
|
||||
$ref: '#/components/schemas/TrainingSession'
|
||||
report:
|
||||
$ref: '#/components/schemas/TrainingReport'
|
||||
|
||||
TrainingSession:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
user_id:
|
||||
type: integer
|
||||
scene_id:
|
||||
type: integer
|
||||
coze_conversation_id:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
duration_seconds:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [created, in_progress, completed, cancelled, error]
|
||||
session_config:
|
||||
type: object
|
||||
total_score:
|
||||
type: number
|
||||
evaluation_result:
|
||||
type: object
|
||||
scene:
|
||||
$ref: '#/components/schemas/TrainingScene'
|
||||
message_count:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
TrainingSessionResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TrainingSession'
|
||||
|
||||
PaginatedSessionsResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TrainingSession'
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
|
||||
TrainingMessage:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
session_id:
|
||||
type: integer
|
||||
role:
|
||||
type: string
|
||||
enum: [user, assistant, system]
|
||||
type:
|
||||
type: string
|
||||
enum: [text, voice, system]
|
||||
content:
|
||||
type: string
|
||||
voice_url:
|
||||
type: string
|
||||
voice_duration:
|
||||
type: number
|
||||
metadata:
|
||||
type: object
|
||||
coze_message_id:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
TrainingReport:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
session_id:
|
||||
type: integer
|
||||
user_id:
|
||||
type: integer
|
||||
overall_score:
|
||||
type: number
|
||||
dimension_scores:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: number
|
||||
strengths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
weaknesses:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
suggestions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
detailed_analysis:
|
||||
type: string
|
||||
transcript:
|
||||
type: string
|
||||
statistics:
|
||||
type: object
|
||||
session:
|
||||
$ref: '#/components/schemas/TrainingSession'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
TrainingReportResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TrainingReport'
|
||||
|
||||
PaginatedReportsResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TrainingReport'
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
275
backend/app/api/v1/upload.py
Normal file
275
backend/app/api/v1/upload.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
文件上传API接口
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.course import Course
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/upload")
|
||||
|
||||
# 支持的文件类型和大小限制
|
||||
# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'txt', 'md', 'mdx', 'pdf', 'html', 'htm',
|
||||
'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties'
|
||||
}
|
||||
MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB
|
||||
|
||||
|
||||
def get_file_extension(filename: str) -> str:
|
||||
"""获取文件扩展名"""
|
||||
return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
"""生成唯一的文件名"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
random_str = hashlib.md5(f"{original_filename}{timestamp}".encode()).hexdigest()[:8]
|
||||
ext = get_file_extension(original_filename)
|
||||
return f"{timestamp}_{random_str}.{ext}"
|
||||
|
||||
|
||||
def get_upload_path(file_type: str = "general") -> Path:
|
||||
"""获取上传路径"""
|
||||
base_path = Path(settings.UPLOAD_PATH)
|
||||
upload_path = base_path / file_type
|
||||
upload_path.mkdir(parents=True, exist_ok=True)
|
||||
return upload_path
|
||||
|
||||
|
||||
@router.post("/file", response_model=ResponseModel[dict])
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = "general",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
上传单个文件
|
||||
|
||||
- **file**: 要上传的文件
|
||||
- **file_type**: 文件类型分类(general, course, avatar等)
|
||||
|
||||
返回:
|
||||
- **file_url**: 文件访问URL
|
||||
- **file_name**: 原始文件名
|
||||
- **file_size**: 文件大小
|
||||
- **file_type**: 文件类型
|
||||
"""
|
||||
try:
|
||||
# 检查文件扩展名
|
||||
file_ext = get_file_extension(file.filename)
|
||||
if file_ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型: {file_ext}"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
contents = await file.read()
|
||||
file_size = len(contents)
|
||||
|
||||
# 检查文件大小
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB"
|
||||
)
|
||||
|
||||
# 生成唯一文件名
|
||||
unique_filename = generate_unique_filename(file.filename)
|
||||
|
||||
# 获取上传路径
|
||||
upload_path = get_upload_path(file_type)
|
||||
file_path = upload_path / unique_filename
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# 生成文件访问URL
|
||||
file_url = f"/static/uploads/{file_type}/{unique_filename}"
|
||||
|
||||
logger.info(
|
||||
"文件上传成功",
|
||||
user_id=current_user.id,
|
||||
original_filename=file.filename,
|
||||
saved_filename=unique_filename,
|
||||
file_size=file_size,
|
||||
file_type=file_type,
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"file_url": file_url,
|
||||
"file_name": file.filename,
|
||||
"file_size": file_size,
|
||||
"file_type": file_ext,
|
||||
},
|
||||
message="文件上传成功"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"文件上传失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="文件上传失败"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/course/{course_id}/materials", response_model=ResponseModel[dict])
|
||||
async def upload_course_material(
|
||||
course_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
上传课程资料
|
||||
|
||||
- **course_id**: 课程ID
|
||||
- **file**: 要上传的文件
|
||||
|
||||
返回上传结果,包含文件URL等信息
|
||||
"""
|
||||
try:
|
||||
# 验证课程是否存在
|
||||
from sqlalchemy import select
|
||||
from app.models.course import Course
|
||||
|
||||
stmt = select(Course).where(Course.id == course_id, Course.is_deleted == False)
|
||||
result = await db.execute(stmt)
|
||||
course = result.scalar_one_or_none()
|
||||
if not course:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"课程 {course_id} 不存在"
|
||||
)
|
||||
|
||||
# 检查文件扩展名
|
||||
file_ext = get_file_extension(file.filename)
|
||||
if file_ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型: {file_ext}"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
contents = await file.read()
|
||||
file_size = len(contents)
|
||||
|
||||
# 检查文件大小
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB"
|
||||
)
|
||||
|
||||
# 生成唯一文件名
|
||||
unique_filename = generate_unique_filename(file.filename)
|
||||
|
||||
# 创建课程专属目录
|
||||
course_upload_path = Path(settings.UPLOAD_PATH) / "courses" / str(course_id)
|
||||
course_upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存文件
|
||||
file_path = course_upload_path / unique_filename
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# 生成文件访问URL
|
||||
file_url = f"/static/uploads/courses/{course_id}/{unique_filename}"
|
||||
|
||||
logger.info(
|
||||
"课程资料上传成功",
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
original_filename=file.filename,
|
||||
saved_filename=unique_filename,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"file_url": file_url,
|
||||
"file_name": file.filename,
|
||||
"file_size": file_size,
|
||||
"file_type": file_ext,
|
||||
},
|
||||
message="课程资料上传成功"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"课程资料上传失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="课程资料上传失败"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/file", response_model=ResponseModel[bool])
|
||||
async def delete_file(
|
||||
file_url: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
删除已上传的文件
|
||||
|
||||
- **file_url**: 文件URL路径
|
||||
"""
|
||||
try:
|
||||
# 解析文件路径
|
||||
if not file_url.startswith("/static/uploads/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的文件URL"
|
||||
)
|
||||
|
||||
# 转换为实际文件路径
|
||||
relative_path = file_url.replace("/static/uploads/", "")
|
||||
file_path = Path(settings.UPLOAD_PATH) / relative_path
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文件不存在"
|
||||
)
|
||||
|
||||
# 删除文件
|
||||
os.remove(file_path)
|
||||
|
||||
logger.info(
|
||||
"文件删除成功",
|
||||
user_id=current_user.id,
|
||||
file_url=file_url,
|
||||
)
|
||||
|
||||
return ResponseModel(data=True, message="文件删除成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"文件删除失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="文件删除失败"
|
||||
)
|
||||
474
backend/app/api/v1/users.py
Normal file
474
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
用户管理 API
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db, require_admin
|
||||
from app.core.logger import logger
|
||||
from app.models.user import User
|
||||
from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel
|
||||
from app.schemas.user import User as UserSchema
|
||||
from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate
|
||||
from app.services.user_service import UserService
|
||||
from app.services.system_log_service import system_log_service
|
||||
from app.schemas.system_log import SystemLogCreate
|
||||
from app.models.exam import Exam, ExamResult
|
||||
from app.models.training import TrainingSession
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.position import Position
|
||||
from app.models.course import Course
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=ResponseModel)
|
||||
async def get_current_user_info(
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取当前用户信息
|
||||
|
||||
权限:需要登录
|
||||
"""
|
||||
return ResponseModel(data=UserSchema.model_validate(current_user))
|
||||
|
||||
|
||||
@router.get("/me/statistics", response_model=ResponseModel)
|
||||
async def get_current_user_statistics(
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取当前用户学习统计
|
||||
|
||||
返回字段:
|
||||
- learningDays: 学习天数(按陪练会话开始日期去重)
|
||||
- totalHours: 学习总时长(小时,取整到1位小数)
|
||||
- practiceQuestions: 练习题数(答题记录条数汇总)
|
||||
- averageScore: 平均成绩(已提交考试的平均分,保留1位小数)
|
||||
- examsCompleted: 已完成考试数量
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.id
|
||||
|
||||
# 学习天数:按会话开始日期去重
|
||||
learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where(
|
||||
TrainingSession.user_id == user_id
|
||||
)
|
||||
learning_days = (await db.scalar(learning_days_stmt)) or 0
|
||||
|
||||
# 总时长(小时)
|
||||
total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where(
|
||||
TrainingSession.user_id == user_id
|
||||
)
|
||||
total_seconds = (await db.scalar(total_seconds_stmt)) or 0
|
||||
total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0
|
||||
|
||||
# 练习题数:用户所有考试的题目总数
|
||||
practice_questions_stmt = (
|
||||
select(func.coalesce(func.sum(Exam.question_count), 0))
|
||||
.where(Exam.user_id == user_id, Exam.status == "completed")
|
||||
)
|
||||
practice_questions = (await db.scalar(practice_questions_stmt)) or 0
|
||||
|
||||
# 平均成绩:用户已完成考试的平均分
|
||||
avg_score_stmt = select(func.avg(Exam.score)).where(
|
||||
Exam.user_id == user_id, Exam.status == "completed"
|
||||
)
|
||||
avg_score_val = await db.scalar(avg_score_stmt)
|
||||
average_score = round(float(avg_score_val), 1) if avg_score_val is not None else 0.0
|
||||
|
||||
# 已完成考试数量
|
||||
exams_completed_stmt = select(func.count(Exam.id)).where(
|
||||
Exam.user_id == user_id,
|
||||
Exam.status == "completed"
|
||||
)
|
||||
exams_completed = (await db.scalar(exams_completed_stmt)) or 0
|
||||
|
||||
return ResponseModel(
|
||||
data={
|
||||
"learningDays": int(learning_days),
|
||||
"totalHours": total_hours,
|
||||
"practiceQuestions": int(practice_questions),
|
||||
"averageScore": average_score,
|
||||
"examsCompleted": int(exams_completed),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("获取用户学习统计失败", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"获取用户学习统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/me/recent-exams", response_model=ResponseModel)
|
||||
async def get_recent_exams(
|
||||
limit: int = Query(5, ge=1, le=20, description="返回数量"),
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取当前用户最近的考试记录
|
||||
|
||||
返回最近的考试列表,按创建时间降序排列
|
||||
只返回已完成或已提交的考试(不包括started状态)
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.id
|
||||
|
||||
# 查询最近的考试记录,关联课程表获取课程名称
|
||||
stmt = (
|
||||
select(Exam, Course.name.label("course_name"))
|
||||
.join(Course, Exam.course_id == Course.id)
|
||||
.where(
|
||||
Exam.user_id == user_id,
|
||||
Exam.status.in_(["completed", "submitted"])
|
||||
)
|
||||
.order_by(Exam.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
results = await db.execute(stmt)
|
||||
rows = results.all()
|
||||
|
||||
# 构建返回数据
|
||||
exams_list = []
|
||||
for exam, course_name in rows:
|
||||
exams_list.append({
|
||||
"id": exam.id,
|
||||
"title": exam.exam_name,
|
||||
"courseName": course_name,
|
||||
"courseId": exam.course_id,
|
||||
"time": exam.created_at.strftime("%Y-%m-%d %H:%M") if exam.created_at else "",
|
||||
"questions": exam.question_count or 0,
|
||||
"status": exam.status,
|
||||
"score": exam.score
|
||||
})
|
||||
|
||||
return ResponseModel(data=exams_list)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("获取最近考试记录失败", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"获取最近考试记录失败: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/me", response_model=ResponseModel)
|
||||
async def update_current_user(
|
||||
user_in: UserUpdate,
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
更新当前用户信息
|
||||
|
||||
权限:需要登录
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
user = await user_service.update_user(
|
||||
user_id=current_user.id,
|
||||
obj_in=user_in,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
return ResponseModel(data=UserSchema.model_validate(user))
|
||||
|
||||
|
||||
@router.put("/me/password", response_model=ResponseModel)
|
||||
async def update_current_user_password(
|
||||
password_in: UserPasswordUpdate,
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
更新当前用户密码
|
||||
|
||||
权限:需要登录
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
user = await user_service.update_password(
|
||||
user_id=current_user.id,
|
||||
old_password=password_in.old_password,
|
||||
new_password=password_in.new_password,
|
||||
)
|
||||
return ResponseModel(message="密码更新成功", data=UserSchema.model_validate(user))
|
||||
|
||||
|
||||
@router.get("/", response_model=ResponseModel)
|
||||
async def get_users(
|
||||
pagination: PaginationParams = Depends(),
|
||||
role: str = Query(None, description="用户角色"),
|
||||
is_active: bool = Query(None, description="是否激活"),
|
||||
team_id: int = Query(None, description="团队ID"),
|
||||
keyword: str = Query(None, description="搜索关键词"),
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取用户列表
|
||||
|
||||
权限:需要登录
|
||||
- 普通用户只能看到激活的用户
|
||||
- 管理员可以看到所有用户
|
||||
"""
|
||||
# 构建筛选条件
|
||||
filter_params = UserFilter(
|
||||
role=role,
|
||||
is_active=is_active,
|
||||
team_id=team_id,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
# 普通用户只能看到激活的用户
|
||||
if current_user.role == "trainee":
|
||||
filter_params.is_active = True
|
||||
|
||||
# 获取用户列表
|
||||
user_service = UserService(db)
|
||||
users, total = await user_service.get_users_with_filter(
|
||||
skip=pagination.offset,
|
||||
limit=pagination.limit,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
|
||||
# 构建分页响应
|
||||
paginated = PaginatedResponse.create(
|
||||
items=[UserSchema.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
)
|
||||
|
||||
return ResponseModel(data=paginated.model_dump())
|
||||
|
||||
|
||||
@router.post("/", response_model=ResponseModel, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
request: Request,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
创建用户
|
||||
|
||||
权限:需要管理员权限
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
user = await user_service.create_user(
|
||||
obj_in=user_in,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"管理员创建用户",
|
||||
admin_id=current_user.id,
|
||||
admin_username=current_user.username,
|
||||
new_user_id=user.id,
|
||||
new_username=user.username,
|
||||
)
|
||||
|
||||
# 记录用户创建日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="user",
|
||||
message=f"管理员 {current_user.username} 创建用户: {user.username}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path="/api/v1/users/",
|
||||
method="POST",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(message="用户创建成功", data=UserSchema.model_validate(user))
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=ResponseModel)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
权限:需要登录
|
||||
- 普通用户只能查看自己的信息
|
||||
- 管理员和经理可以查看所有用户信息
|
||||
"""
|
||||
# 权限检查
|
||||
if current_user.role == "trainee" and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息"
|
||||
)
|
||||
|
||||
# 获取用户
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
return ResponseModel(data=UserSchema.model_validate(user))
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=ResponseModel)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_in: UserUpdate,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
权限:需要管理员权限
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
user = await user_service.update_user(
|
||||
user_id=user_id,
|
||||
obj_in=user_in,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"管理员更新用户",
|
||||
admin_id=current_user.id,
|
||||
admin_username=current_user.username,
|
||||
updated_user_id=user.id,
|
||||
updated_username=user.username,
|
||||
)
|
||||
|
||||
return ResponseModel(data=UserSchema.model_validate(user))
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=ResponseModel)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
删除用户(软删除)
|
||||
|
||||
权限:需要管理员权限
|
||||
"""
|
||||
# 不能删除自己
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除自己")
|
||||
|
||||
# 获取用户
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# 软删除
|
||||
await user_service.soft_delete(db_obj=user)
|
||||
|
||||
logger.info(
|
||||
"管理员删除用户",
|
||||
admin_id=current_user.id,
|
||||
admin_username=current_user.username,
|
||||
deleted_user_id=user.id,
|
||||
deleted_username=user.username,
|
||||
)
|
||||
|
||||
# 记录用户删除日志
|
||||
await system_log_service.create_log(
|
||||
db,
|
||||
SystemLogCreate(
|
||||
level="INFO",
|
||||
type="user",
|
||||
message=f"管理员 {current_user.username} 删除用户: {user.username}",
|
||||
user_id=current_user.id,
|
||||
user=current_user.username,
|
||||
ip=request.client.host if request.client else None,
|
||||
path=f"/api/v1/users/{user_id}",
|
||||
method="DELETE",
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
)
|
||||
|
||||
return ResponseModel(message="用户删除成功")
|
||||
|
||||
|
||||
@router.post("/{user_id}/teams/{team_id}", response_model=ResponseModel)
|
||||
async def add_user_to_team(
|
||||
user_id: int,
|
||||
team_id: int,
|
||||
role: str = Query("member", regex="^(member|leader)$"),
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
将用户添加到团队
|
||||
|
||||
权限:需要管理员权限
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
await user_service.add_user_to_team(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
role=role,
|
||||
)
|
||||
|
||||
return ResponseModel(message="用户已添加到团队")
|
||||
|
||||
|
||||
@router.delete("/{user_id}/teams/{team_id}", response_model=ResponseModel)
|
||||
async def remove_user_from_team(
|
||||
user_id: int,
|
||||
team_id: int,
|
||||
current_user: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
从团队中移除用户
|
||||
|
||||
权限:需要管理员权限
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
await user_service.remove_user_from_team(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
return ResponseModel(message="用户已从团队中移除")
|
||||
|
||||
|
||||
@router.get("/{user_id}/positions", response_model=ResponseModel)
|
||||
async def get_user_positions(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取用户所属岗位列表(用于前端展示与编辑)
|
||||
|
||||
权限:登录即可;普通用户仅能查看自己的信息
|
||||
返回:[{id,name,code}]
|
||||
"""
|
||||
# 权限检查
|
||||
if current_user.role == "trainee" and current_user.id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息")
|
||||
|
||||
stmt = (
|
||||
select(Position)
|
||||
.join(PositionMember, PositionMember.position_id == Position.id)
|
||||
.where(PositionMember.user_id == user_id, PositionMember.is_deleted == False, Position.is_deleted == False)
|
||||
.order_by(Position.id)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
data = [
|
||||
{"id": p.id, "name": p.name, "code": p.code}
|
||||
for p in rows
|
||||
]
|
||||
return ResponseModel(data=data)
|
||||
120
backend/app/api/v1/yanji.py
Normal file
120
backend/app/api/v1/yanji.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
言迹智能工牌API接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
from app.schemas.yanji import (
|
||||
GetConversationsByVisitIdsResponse,
|
||||
GetConversationsResponse,
|
||||
YanjiConversation,
|
||||
)
|
||||
from app.services.yanji_service import YanjiService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/conversations/by-visit-ids", response_model=ResponseModel[GetConversationsByVisitIdsResponse])
|
||||
async def get_conversations_by_visit_ids(
|
||||
external_visit_ids: List[str] = Query(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
description="三方来访单ID列表(最多10个)",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
根据来访单ID获取对话记录(ASR转写文字)
|
||||
|
||||
这是获取对话记录的主要接口,适用于:
|
||||
1. 已知来访单ID的场景
|
||||
2. 获取特定对话记录用于AI评分
|
||||
3. 批量获取多个对话记录
|
||||
"""
|
||||
try:
|
||||
yanji_service = YanjiService()
|
||||
conversations = await yanji_service.get_conversations_by_visit_ids(
|
||||
external_visit_ids=external_visit_ids
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取成功",
|
||||
data=GetConversationsByVisitIdsResponse(
|
||||
conversations=conversations, total=len(conversations)
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取对话记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ResponseModel[GetConversationsResponse])
|
||||
async def get_employee_conversations(
|
||||
consultant_phone: str = Query(..., description="员工手机号"),
|
||||
limit: int = Query(10, ge=1, le=100, description="获取数量"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取员工最近的对话记录
|
||||
|
||||
注意:目前此接口功能有限,因为言迹API没有直接通过员工手机号查询录音的接口。
|
||||
推荐使用 /conversations/by-visit-ids 接口。
|
||||
|
||||
后续可扩展:
|
||||
1. 先查询员工的来访单列表
|
||||
2. 再获取这些来访单的对话记录
|
||||
"""
|
||||
try:
|
||||
yanji_service = YanjiService()
|
||||
conversations = await yanji_service.get_recent_conversations(
|
||||
consultant_phone=consultant_phone, limit=limit
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="获取成功",
|
||||
data=GetConversationsResponse(
|
||||
conversations=conversations, total=len(conversations)
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取员工对话记录失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None)
|
||||
|
||||
|
||||
@router.get("/test-auth")
|
||||
async def test_yanji_auth(current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
测试言迹API认证
|
||||
|
||||
用于验证OAuth2.0认证是否正常工作
|
||||
"""
|
||||
try:
|
||||
yanji_service = YanjiService()
|
||||
access_token = await yanji_service.get_access_token()
|
||||
|
||||
return ResponseModel(
|
||||
code=200,
|
||||
message="认证成功",
|
||||
data={
|
||||
"access_token": access_token[:20] + "...", # 只显示前20个字符
|
||||
"base_url": yanji_service.base_url,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"言迹API认证失败: {e}", exc_info=True)
|
||||
return ResponseModel(code=500, message=f"认证失败: {str(e)}", data=None)
|
||||
|
||||
Reference in New Issue
Block a user