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

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

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

View File

@@ -0,0 +1,837 @@
"""
课程服务层
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy import select, or_, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.logger import get_logger
from app.core.exceptions import NotFoundError, BadRequestError, ConflictError
from app.models.course import (
Course,
CourseStatus,
CourseMaterial,
KnowledgePoint,
GrowthPath,
)
from app.models.course_exam_settings import CourseExamSettings
from app.models.position_member import PositionMember
from app.models.position_course import PositionCourse
from app.schemas.course import (
CourseCreate,
CourseUpdate,
CourseList,
CourseInDB,
CourseMaterialCreate,
KnowledgePointCreate,
KnowledgePointUpdate,
KnowledgePointInDB,
GrowthPathCreate,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.services.base_service import BaseService
logger = get_logger(__name__)
class CourseService(BaseService[Course]):
"""
课程服务类
"""
def __init__(self):
super().__init__(Course)
async def get_course_list(
self,
db: AsyncSession,
*,
page_params: PaginationParams,
filters: CourseList,
user_id: Optional[int] = None,
) -> PaginatedResponse[CourseInDB]:
"""
获取课程列表(支持筛选)
Args:
db: 数据库会话
page_params: 分页参数
filters: 筛选条件
user_id: 用户ID用于记录访问日志
Returns:
分页的课程列表
"""
# 构建筛选条件
filter_conditions = []
# 状态筛选(默认只显示已发布的课程)
if filters.status is not None:
filter_conditions.append(Course.status == filters.status)
else:
# 如果没有指定状态,默认只返回已发布的课程
filter_conditions.append(Course.status == CourseStatus.PUBLISHED)
# 分类筛选
if filters.category is not None:
filter_conditions.append(Course.category == filters.category)
# 是否推荐筛选
if filters.is_featured is not None:
filter_conditions.append(Course.is_featured == filters.is_featured)
# 关键词搜索
if filters.keyword:
keyword = f"%{filters.keyword}%"
filter_conditions.append(
or_(Course.name.like(keyword), Course.description.like(keyword))
)
# 记录查询日志
logger.info(
"查询课程列表",
user_id=user_id,
filters=filters.model_dump(exclude_none=True),
page=page_params.page,
size=page_params.page_size,
)
# 执行分页查询
query = select(Course).where(Course.is_deleted == False)
# 添加筛选条件
if filter_conditions:
query = query.where(and_(*filter_conditions))
# 添加排序优先按sort_order升序其次按创建时间降序新课程优先
query = query.order_by(Course.sort_order.asc(), Course.created_at.desc())
# 获取总数
count_query = (
select(func.count()).select_from(Course).where(Course.is_deleted == False)
)
if filter_conditions:
count_query = count_query.where(and_(*filter_conditions))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 分页
query = query.offset(page_params.offset).limit(page_params.limit)
query = query.options(selectinload(Course.materials))
# 执行查询
result = await db.execute(query)
courses = result.scalars().all()
# 获取用户所属的岗位ID列表
user_position_ids = []
if user_id:
position_result = await db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id,
PositionMember.is_deleted == False
)
)
user_position_ids = [row[0] for row in position_result.fetchall()]
# 批量查询课程的岗位分配信息
course_ids = [c.id for c in courses]
course_type_map = {}
if course_ids and user_position_ids:
position_course_result = await db.execute(
select(PositionCourse.course_id, PositionCourse.course_type).where(
PositionCourse.course_id.in_(course_ids),
PositionCourse.position_id.in_(user_position_ids),
PositionCourse.is_deleted == False
)
)
# 构建课程类型映射如果有多个岗位优先取required
for course_id, course_type in position_course_result.fetchall():
if course_id not in course_type_map:
course_type_map[course_id] = course_type
elif course_type == 'required':
course_type_map[course_id] = 'required'
# 转换为 Pydantic 模型,并附加课程类型
course_list = []
for course in courses:
course_data = CourseInDB.model_validate(course)
# 设置课程类型如果用户有岗位分配则使用分配类型否则为None
course_data.course_type = course_type_map.get(course.id)
course_list.append(course_data)
# 计算总页数
pages = (total + page_params.page_size - 1) // page_params.page_size
return PaginatedResponse(
items=course_list,
total=total,
page=page_params.page,
page_size=page_params.page_size,
pages=pages,
)
async def create_course(
self, db: AsyncSession, *, course_in: CourseCreate, created_by: int
) -> Course:
"""
创建课程
Args:
db: 数据库会话
course_in: 课程创建数据
created_by: 创建人ID
Returns:
创建的课程
"""
# 检查名称是否重复
existing = await db.execute(
select(Course).where(
and_(Course.name == course_in.name, Course.is_deleted == False)
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"课程名称 '{course_in.name}' 已存在")
# 创建课程
course_data = course_in.model_dump()
course = await self.create(db, obj_in=course_data, created_by=created_by)
# 自动创建默认考试设置
default_exam_settings = CourseExamSettings(
course_id=course.id,
created_by=created_by,
updated_by=created_by
# 其他字段使用模型定义的默认值:
# single_choice_count=4, multiple_choice_count=2, true_false_count=1,
# fill_blank_count=2, essay_count=1, duration_minutes=10, 等
)
db.add(default_exam_settings)
await db.commit()
await db.refresh(course)
logger.info(
"创建课程", course_id=course.id, course_name=course.name, created_by=created_by
)
logger.info(
"自动创建默认考试设置", course_id=course.id, exam_settings_id=default_exam_settings.id
)
return course
async def update_course(
self,
db: AsyncSession,
*,
course_id: int,
course_in: CourseUpdate,
updated_by: int,
) -> Course:
"""
更新课程
Args:
db: 数据库会话
course_id: 课程ID
course_in: 课程更新数据
updated_by: 更新人ID
Returns:
更新后的课程
"""
# 获取课程
course = await self.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 检查名称是否重复(如果修改了名称)
if course_in.name and course_in.name != course.name:
existing = await db.execute(
select(Course).where(
and_(
Course.name == course_in.name,
Course.id != course_id,
Course.is_deleted == False,
)
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"课程名称 '{course_in.name}' 已存在")
# 记录状态变更
old_status = course.status
# 更新课程
update_data = course_in.model_dump(exclude_unset=True)
# 如果状态变为已发布,记录发布时间
if (
update_data.get("status") == CourseStatus.PUBLISHED
and old_status != CourseStatus.PUBLISHED
):
update_data["published_at"] = datetime.now()
update_data["publisher_id"] = updated_by
course = await self.update(
db, db_obj=course, obj_in=update_data, updated_by=updated_by
)
logger.info(
"更新课程",
course_id=course.id,
course_name=course.name,
old_status=old_status,
new_status=course.status,
updated_by=updated_by,
)
return course
async def delete_course(
self, db: AsyncSession, *, course_id: int, deleted_by: int
) -> bool:
"""
删除课程(软删除 + 删除相关文件)
Args:
db: 数据库会话
course_id: 课程ID
deleted_by: 删除人ID
Returns:
是否删除成功
"""
import shutil
from pathlib import Path
from app.core.config import settings
course = await self.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 放开删除限制:任意状态均可软删除,由业务方自行控制
# 执行软删除(标记 is_deleted记录删除时间由审计日志记录操作者
success = await self.soft_delete(db, id=course_id)
if success:
# 删除课程文件夹及其所有内容
course_folder = Path(settings.UPLOAD_PATH) / "courses" / str(course_id)
if course_folder.exists() and course_folder.is_dir():
try:
shutil.rmtree(course_folder)
logger.info(
"删除课程文件夹成功",
course_id=course_id,
folder_path=str(course_folder),
)
except Exception as e:
# 文件夹删除失败不影响业务流程,仅记录日志
logger.error(
"删除课程文件夹失败",
course_id=course_id,
folder_path=str(course_folder),
error=str(e),
)
logger.warning(
"删除课程",
course_id=course_id,
course_name=course.name,
deleted_by=deleted_by,
folder_deleted=course_folder.exists(),
)
return success
async def add_course_material(
self,
db: AsyncSession,
*,
course_id: int,
material_in: CourseMaterialCreate,
created_by: int,
) -> CourseMaterial:
"""
添加课程资料
Args:
db: 数据库会话
course_id: 课程ID
material_in: 资料创建数据
created_by: 创建人ID
Returns:
创建的课程资料
"""
# 检查课程是否存在
course = await self.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 创建资料
material_data = material_in.model_dump()
material_data.update({
"course_id": course_id,
"created_by": created_by,
"updated_by": created_by
})
material = CourseMaterial(**material_data)
db.add(material)
await db.commit()
await db.refresh(material)
logger.info(
"添加课程资料",
course_id=course_id,
material_id=material.id,
material_name=material.name,
file_type=material.file_type,
file_size=material.file_size,
created_by=created_by,
)
return material
async def get_course_materials(
self,
db: AsyncSession,
*,
course_id: int,
) -> List[CourseMaterial]:
"""
获取课程资料列表
Args:
db: 数据库会话
course_id: 课程ID
Returns:
课程资料列表
"""
# 确认课程存在
course = await self.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
stmt = (
select(CourseMaterial)
.where(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
.order_by(CourseMaterial.sort_order.asc(), CourseMaterial.id.asc())
)
result = await db.execute(stmt)
materials = result.scalars().all()
logger.info(
"查询课程资料列表", course_id=course_id, count=len(materials)
)
return materials
async def delete_course_material(
self,
db: AsyncSession,
*,
course_id: int,
material_id: int,
deleted_by: int,
) -> bool:
"""
删除课程资料(软删除 + 删除物理文件)
Args:
db: 数据库会话
course_id: 课程ID
material_id: 资料ID
deleted_by: 删除人ID
Returns:
是否删除成功
"""
import os
from pathlib import Path
from app.core.config import settings
# 先确认课程存在
course = await self.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 查找资料并校验归属
material_stmt = select(CourseMaterial).where(
CourseMaterial.id == material_id,
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
result = await db.execute(material_stmt)
material = result.scalar_one_or_none()
if not material:
raise NotFoundError(f"课程资料ID {material_id} 不存在或已删除")
# 获取文件路径信息用于删除物理文件
file_url = material.file_url
# 软删除数据库记录
material.is_deleted = True
material.deleted_at = datetime.now()
if hasattr(material, "deleted_by"):
# 兼容存在该字段的表
setattr(material, "deleted_by", deleted_by)
db.add(material)
await db.commit()
# 删除物理文件
if file_url and file_url.startswith("/static/uploads/"):
try:
# 从URL中提取相对路径
relative_path = file_url.replace("/static/uploads/", "")
file_path = Path(settings.UPLOAD_PATH) / relative_path
# 检查文件是否存在并删除
if file_path.exists() and file_path.is_file():
os.remove(file_path)
logger.info(
"删除物理文件成功",
file_path=str(file_path),
material_id=material_id,
)
except Exception as e:
# 物理文件删除失败不影响业务流程,仅记录日志
logger.error(
"删除物理文件失败",
file_url=file_url,
material_id=material_id,
error=str(e),
)
logger.warning(
"删除课程资料",
course_id=course_id,
material_id=material_id,
deleted_by=deleted_by,
file_deleted=file_url is not None,
)
return True
async def get_material_knowledge_points(
self, db: AsyncSession, material_id: int
) -> List[KnowledgePointInDB]:
"""获取资料关联的知识点列表"""
# 获取资料信息
result = await db.execute(
select(CourseMaterial).where(
CourseMaterial.id == material_id,
CourseMaterial.is_deleted == False
)
)
material = result.scalar_one_or_none()
if not material:
raise NotFoundError(f"资料ID {material_id} 不存在")
# 直接查询关联到该资料的知识点
query = select(KnowledgePoint).where(
KnowledgePoint.material_id == material_id,
KnowledgePoint.is_deleted == False
).order_by(KnowledgePoint.created_at.desc())
result = await db.execute(query)
knowledge_points = result.scalars().all()
from app.schemas.course import KnowledgePointInDB
return [KnowledgePointInDB.model_validate(kp) for kp in knowledge_points]
async def add_material_knowledge_points(
self, db: AsyncSession, material_id: int, knowledge_point_ids: List[int]
) -> List[KnowledgePointInDB]:
"""
为资料添加知识点关联
注意自2025-09-27起知识点直接通过material_id关联到资料
material_knowledge_points中间表已废弃。此方法将更新知识点的material_id字段。
"""
# 验证资料是否存在
result = await db.execute(
select(CourseMaterial).where(
CourseMaterial.id == material_id,
CourseMaterial.is_deleted == False
)
)
material = result.scalar_one_or_none()
if not material:
raise NotFoundError(f"资料ID {material_id} 不存在")
# 验证知识点是否存在且属于同一课程
result = await db.execute(
select(KnowledgePoint).where(
KnowledgePoint.id.in_(knowledge_point_ids),
KnowledgePoint.course_id == material.course_id,
KnowledgePoint.is_deleted == False
)
)
valid_knowledge_points = result.scalars().all()
if len(valid_knowledge_points) != len(knowledge_point_ids):
raise BadRequestError("部分知识点不存在或不属于同一课程")
# 更新知识点的material_id字段
added_knowledge_points = []
for kp in valid_knowledge_points:
# 更新知识点的资料关联
kp.material_id = material_id
added_knowledge_points.append(kp)
await db.commit()
# 刷新对象以获取更新后的数据
for kp in added_knowledge_points:
await db.refresh(kp)
from app.schemas.course import KnowledgePointInDB
return [KnowledgePointInDB.model_validate(kp) for kp in added_knowledge_points]
async def remove_material_knowledge_point(
self, db: AsyncSession, material_id: int, knowledge_point_id: int
) -> bool:
"""
移除资料的知识点关联(软删除知识点)
注意自2025-09-27起知识点直接通过material_id关联到资料
material_knowledge_points中间表已废弃。此方法将软删除知识点。
"""
# 查找知识点并验证归属
result = await db.execute(
select(KnowledgePoint).where(
KnowledgePoint.id == knowledge_point_id,
KnowledgePoint.material_id == material_id,
KnowledgePoint.is_deleted == False
)
)
knowledge_point = result.scalar_one_or_none()
if not knowledge_point:
raise NotFoundError(f"知识点ID {knowledge_point_id} 不存在或不属于该资料")
# 软删除知识点
knowledge_point.is_deleted = True
knowledge_point.deleted_at = datetime.now()
await db.commit()
logger.info(
"移除资料知识点关联",
material_id=material_id,
knowledge_point_id=knowledge_point_id,
)
return True
class KnowledgePointService(BaseService[KnowledgePoint]):
"""
知识点服务类
"""
def __init__(self):
super().__init__(KnowledgePoint)
async def get_knowledge_points_by_course(
self, db: AsyncSession, *, course_id: int, material_id: Optional[int] = None
) -> List[KnowledgePoint]:
"""
获取课程的知识点列表
Args:
db: 数据库会话
course_id: 课程ID
material_id: 资料ID可选用于筛选特定资料的知识点
Returns:
知识点列表
"""
query = select(KnowledgePoint).where(
and_(
KnowledgePoint.course_id == course_id,
KnowledgePoint.is_deleted == False,
)
)
if material_id is not None:
query = query.where(KnowledgePoint.material_id == material_id)
query = query.order_by(KnowledgePoint.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
async def create_knowledge_point(
self,
db: AsyncSession,
*,
course_id: int,
point_in: KnowledgePointCreate,
created_by: int,
) -> KnowledgePoint:
"""
创建知识点
Args:
db: 数据库会话
course_id: 课程ID
point_in: 知识点创建数据
created_by: 创建人ID
Returns:
创建的知识点
"""
# 检查课程是否存在
course_service = CourseService()
course = await course_service.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 创建知识点
point_data = point_in.model_dump()
point_data.update({"course_id": course_id})
knowledge_point = await self.create(
db, obj_in=point_data, created_by=created_by
)
logger.info(
"创建知识点",
course_id=course_id,
knowledge_point_id=knowledge_point.id,
knowledge_point_name=knowledge_point.name,
created_by=created_by,
)
return knowledge_point
async def update_knowledge_point(
self,
db: AsyncSession,
*,
point_id: int,
point_in: KnowledgePointUpdate,
updated_by: int,
) -> KnowledgePoint:
"""
更新知识点
Args:
db: 数据库会话
point_id: 知识点ID
point_in: 知识点更新数据
updated_by: 更新人ID
Returns:
更新后的知识点
"""
knowledge_point = await self.get_by_id(db, point_id)
if not knowledge_point:
raise NotFoundError(f"知识点ID {point_id} 不存在")
# 验证关联资料是否存在
if hasattr(point_in, 'material_id') and point_in.material_id:
result = await db.execute(
select(CourseMaterial).where(
CourseMaterial.id == point_in.material_id,
CourseMaterial.is_deleted == False
)
)
material = result.scalar_one_or_none()
if not material:
raise NotFoundError(f"资料ID {point_in.material_id} 不存在")
# 更新知识点
update_data = point_in.model_dump(exclude_unset=True)
knowledge_point = await self.update(
db, db_obj=knowledge_point, obj_in=update_data, updated_by=updated_by
)
logger.info(
"更新知识点",
knowledge_point_id=knowledge_point.id,
knowledge_point_name=knowledge_point.name,
updated_by=updated_by,
)
return knowledge_point
class GrowthPathService(BaseService[GrowthPath]):
"""
成长路径服务类
"""
def __init__(self):
super().__init__(GrowthPath)
async def create_growth_path(
self, db: AsyncSession, *, path_in: GrowthPathCreate, created_by: int
) -> GrowthPath:
"""
创建成长路径
Args:
db: 数据库会话
path_in: 成长路径创建数据
created_by: 创建人ID
Returns:
创建的成长路径
"""
# 检查名称是否重复
existing = await db.execute(
select(GrowthPath).where(
and_(GrowthPath.name == path_in.name, GrowthPath.is_deleted == False)
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"成长路径名称 '{path_in.name}' 已存在")
# 验证课程是否存在
if path_in.courses:
course_ids = [c.course_id for c in path_in.courses]
course_service = CourseService()
for course_id in course_ids:
course = await course_service.get_by_id(db, course_id)
if not course:
raise NotFoundError(f"课程ID {course_id} 不存在")
# 创建成长路径
path_data = path_in.model_dump()
# 转换课程列表为JSON格式
if path_data.get("courses"):
path_data["courses"] = [c.model_dump() for c in path_in.courses]
growth_path = await self.create(db, obj_in=path_data, created_by=created_by)
logger.info(
"创建成长路径",
growth_path_id=growth_path.id,
growth_path_name=growth_path.name,
course_count=len(path_in.courses) if path_in.courses else 0,
created_by=created_by,
)
return growth_path
# 创建服务实例
course_service = CourseService()
knowledge_point_service = KnowledgePointService()
growth_path_service = GrowthPathService()