合并培训:集成本期课程配置P0能力

This commit is contained in:
wangzhibiao committed 2026-09-14 13:16:17 +08:00
commit db217341f7
15 files changed
+1957 -8

No files matched your search

@@ -0,0 +1,75 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.service.PlanAppService;
/**
* 本期课程配置 API (ISSUE-02-02)。
*
* @author ruoyi
* @date 2026-09-14
*/
@RestController
@RequestMapping("/train/plans/{planId}/courses")
public class TrainPlanCourseController extends BaseController
{
private final PlanAppService planAppService;
@Autowired
public TrainPlanCourseController(PlanAppService planAppService)
{
this.planAppService = planAppService;
}
/**
* 查询本期已配置课程列表。
*/
@PreAuthorize("@ss.hasPermi('train:plan:query')")
@GetMapping
public AjaxResult list(@PathVariable String planId)
{
List<PlanCourseItemVO> list = planAppService.listPlanCourses(planId);
return success(list);
}
/**
* 配置本期课程 (UC-03)。
*/
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
@Log(title = "本期课程配置", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@PathVariable String planId, @Validated @RequestBody PlanCourseAddDTO dto)
{
planAppService.configureCourses(planId, dto, SecurityUtils.getUsername());
return success();
}
/**
* 移除本期课程。
*/
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
@Log(title = "移除本期课程", businessType = BusinessType.DELETE)
@DeleteMapping("/{planCourseId}")
public AjaxResult remove(@PathVariable String planId, @PathVariable Long planCourseId)
{
planAppService.removePlanCourse(planId, planCourseId, SecurityUtils.getUsername());
return success();
}
}
@@ -8,6 +8,9 @@ public final class PlanErrorCode
public static final int PLAN_STATUS_LOCKED = 40012;
public static final int PLAN_LESSON_OUT_OF_BOUNDS = 40013;
public static final int PLAN_ALREADY_COMPLETED = 40014;
public static final int PLAN_COURSE_DUPLICATED = 40020;
public static final int PLAN_COURSE_HAS_LESSONS = 40021;
public static final int PLAN_COURSE_HAS_SCORE_ITEMS = 40022;
public static final int PLAN_NOT_FOUND = 40410;
public static final String PLAN_TIME_INVALID_MESSAGE = "培训计划起止时间不合法 (结束时间必须晚于开始时间)";
@@ -15,7 +18,14 @@ public final class PlanErrorCode
public static final String PLAN_STATUS_LOCKED_MESSAGE = "当前培训计划已结训或取消,不允许修改";
public static final String PLAN_LESSON_OUT_OF_BOUNDS_MESSAGE = "调整培训时间导致现有已排课次超出计划周期";
public static final String PLAN_ALREADY_COMPLETED_MESSAGE = "培训计划已经结训,不可重复执行结训";
public static final String PLAN_COURSE_DUPLICATED_MESSAGE = "该课程已添加到本期培训计划中";
public static final String PLAN_COURSE_HAS_LESSONS_MESSAGE = "该课程已排定课次,请先删除关联课次后再移除课程";
public static final String PLAN_COURSE_HAS_SCORE_ITEMS_MESSAGE = "该课程已关联成绩考核项目,不可直接移除";
public static final String PLAN_NOT_FOUND_MESSAGE = "培训计划不存在";
public static final String COURSE_NOT_FOUND_MESSAGE = "课程不存在或已被删除";
public static final String COURSE_INACTIVE_MESSAGE = "课程已被停用,无法配置到本期培训计划中";
public static final String PLAN_COURSE_NOT_FOUND_MESSAGE = "本期课程配置不存在或不属于该培训计划";
public static final String PLAN_COURSE_HAS_STUDY_DURATIONS_MESSAGE = "该课程已产生学习记录,不可直接移除";
private PlanErrorCode()
{
@@ -0,0 +1,86 @@
package com.ruoyi.train.domain;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 培训班与课程关联对象 train_plan_course
*
* @author ruoyi
* @date 2026-09-14
*/
public class TrainPlanCourse extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 培训课程实例ID (核心ID之一) */
private Long planCourseId;
/** 培训班ID */
@Excel(name = "培训班ID")
private String planId;
/** 课程ID */
@Excel(name = "课程ID")
private String cId;
/** 排课先后顺序号 */
@Excel(name = "排课先后顺序号")
private Integer sortNo;
public Long getPlanCourseId()
{
return planCourseId;
}
public void setPlanCourseId(Long planCourseId)
{
this.planCourseId = planCourseId;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getCId()
{
return cId;
}
public void setCId(String cId)
{
this.cId = cId;
}
public Integer getSortNo()
{
return sortNo;
}
public void setSortNo(Integer sortNo)
{
this.sortNo = sortNo;
}
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("planCourseId", getPlanCourseId())
.append("planId", getPlanId())
.append("cId", getCId())
.append("sortNo", getSortNo())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("remark", getRemark())
.toString();
}
}
@@ -0,0 +1,48 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 本期课程配置请求 DTO (UC-03)。
*/
public class PlanCourseAddDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 课程ID列表 (Contract V2 标准字段) */
@JsonProperty("cIds")
@NotEmpty(message = "课程列表不能为空")
private List<String> cIds = new ArrayList<>();
/** 备注说明 */
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
@JsonProperty("cIds")
public List<String> getCIds()
{
return cIds;
}
@JsonProperty("cIds")
public void setCIds(List<String> cIds)
{
this.cIds = cIds;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,157 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 本期已配置课程视图 VO。
*/
public class PlanCourseItemVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 本期课程实例主键 */
private Long planCourseId;
/** 培训计划ID */
private String planId;
/** 课程ID */
private String cId;
/** 课程业务编码 */
private String cCode;
/** 课程名称 */
private String cName;
/** 课程类型 */
private String cType;
/** 课程学时 */
private Integer cDuration;
/** 教学目标 */
private String teachingObjective;
/** 排课先后顺序号 */
private Integer sortNo;
/** 备注说明 */
private String remark;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getPlanCourseId()
{
return planCourseId;
}
public void setPlanCourseId(Long planCourseId)
{
this.planCourseId = planCourseId;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getCId()
{
return cId;
}
public void setCId(String cId)
{
this.cId = cId;
}
public String getCCode()
{
return cCode;
}
public void setCCode(String cCode)
{
this.cCode = cCode;
}
public String getCName()
{
return cName;
}
public void setCName(String cName)
{
this.cName = cName;
}
public String getCType()
{
return cType;
}
public void setCType(String cType)
{
this.cType = cType;
}
public Integer getCDuration()
{
return cDuration;
}
public void setCDuration(Integer cDuration)
{
this.cDuration = cDuration;
}
public String getTeachingObjective()
{
return teachingObjective;
}
public void setTeachingObjective(String teachingObjective)
{
this.teachingObjective = teachingObjective;
}
public Integer getSortNo()
{
return sortNo;
}
public void setSortNo(Integer sortNo)
{
this.sortNo = sortNo;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
}
@@ -0,0 +1,88 @@
package com.ruoyi.train.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
/**
* 培训班与课程关联Mapper接口。
*
* @author ruoyi
* @date 2026-09-14
*/
public interface TrainPlanCourseMapper
{
/**
* 查询本期课程记录。
*
* @param planCourseId 本期课程ID
* @return 本期课程信息
*/
TrainPlanCourse selectTrainPlanCourseById(@Param("planCourseId") Long planCourseId);
/**
* 根据计划ID查询本期已配置课程列表(联查课程主数据)。
*
* @param planId 培训计划ID
* @return 课程列表VO
*/
List<PlanCourseItemVO> selectPlanCourseListByPlanId(@Param("planId") String planId);
/**
* 查询指定计划下已存在的课程ID集合。
*
* @param planId 培训计划ID
* @param cIds 候选课程ID集合
* @return 已存在的课程ID列表
*/
List<String> selectExistingCIdsByPlanId(@Param("planId") String planId, @Param("cIds") List<String> cIds);
/**
* 单条新增本期课程。
*
* @param trainPlanCourse 培训班与课程关联信息
* @return 结果
*/
int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse);
/**
* 批量新增本期课程。
*
* @param list 待插入列表
* @return 插入行数
*/
int batchInsertTrainPlanCourse(@Param("list") List<TrainPlanCourse> list);
/**
* 移除本期课程。
*
* @param planCourseId 本期课程ID
* @return 删除行数
*/
int deleteTrainPlanCourseById(@Param("planCourseId") Long planCourseId);
/**
* 统计指定本期课程排定的课次数(train_lesson 下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用课次数
*/
int countLessonsByPlanCourseId(@Param("planCourseId") Long planCourseId);
/**
* 统计指定本期课程关联的成绩项目数(train_score_item 下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用成绩项目数
*/
int countScoreItemsByPlanCourseId(@Param("planCourseId") Long planCourseId);
/**
* 统计指定本期课程关联的外部学习记录数(train_study_duration 下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用学习记录数
*/
int countStudyDurationsByPlanCourseId(@Param("planCourseId") Long planCourseId);
}
@@ -0,0 +1,87 @@
package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
/**
* 培训班与课程关联Service接口。
*
* @author ruoyi
* @date 2026-09-14
*/
public interface ITrainPlanCourseService
{
/**
* 查询本期课程记录。
*
* @param planCourseId 本期课程ID
* @return 本期课程
*/
TrainPlanCourse selectTrainPlanCourseById(Long planCourseId);
/**
* 查询指定培训班已配置的本期课程列表。
*
* @param planId 培训计划ID
* @return 课程列表VO
*/
List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId);
/**
* 查询指定计划下已存在的课程ID集合。
*
* @param planId 培训计划ID
* @param cIds 待校验课程ID集合
* @return 已存在的课程ID列表
*/
List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds);
/**
* 新增本期课程记录。
*
* @param trainPlanCourse 本期课程信息
* @return 结果
*/
int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse);
/**
* 批量新增本期课程。
*
* @param list 待新增列表
* @return 插入行数
*/
int batchInsertTrainPlanCourse(List<TrainPlanCourse> list);
/**
* 移除本期课程。
*
* @param planCourseId 本期课程ID
* @return 结果
*/
int deleteTrainPlanCourseById(Long planCourseId);
/**
* 统计指定本期课程排定的课次数(下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用课次数
*/
int countLessonsByPlanCourseId(Long planCourseId);
/**
* 统计指定本期课程关联的成绩项目数(下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用成绩项数
*/
int countScoreItemsByPlanCourseId(Long planCourseId);
/**
* 统计指定本期课程关联的外部学习记录数(下游引用)。
*
* @param planCourseId 本期课程ID
* @return 引用学习记录数
*/
int countStudyDurationsByPlanCourseId(Long planCourseId);
}
@@ -1,13 +1,15 @@
package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
import com.ruoyi.train.domain.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.domain.vo.PlanDetailVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
/** 培训计划生命周期应用服务。 */
/** 培训计划生命周期与课程配置应用服务。 */
public interface PlanAppService
{
List<PlanItemVO> listPlans(PlanQuery query);
@@ -19,4 +21,19 @@ public interface PlanAppService
void updatePlan(String planId, PlanUpdateDTO dto, String operator);
void completePlan(String planId, String operator);
/**
* 查询本期已配置课程列表。
*/
List<PlanCourseItemVO> listPlanCourses(String planId);
/**
* 配置本期课程 (UC-03)。
*/
void configureCourses(String planId, PlanCourseAddDTO dto, String operator);
/**
* 移除本期课程。
*/
void removePlanCourse(String planId, Long planCourseId, String operator);
}
@@ -1,8 +1,11 @@
package com.ruoyi.train.service.impl;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@@ -13,16 +16,22 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.constant.PlanErrorCode;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
import com.ruoyi.train.domain.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.domain.vo.PlanDetailVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.train.service.ITrainCourseService;
import com.ruoyi.train.service.ITrainPlanCourseService;
import com.ruoyi.train.service.PlanAppService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
/** 培训计划生命周期应用服务实现。 */
/** 培训计划生命周期与课程配置应用服务实现。 */
@Service
public class PlanAppServiceImpl implements PlanAppService
{
@@ -31,21 +40,36 @@ public class PlanAppServiceImpl implements PlanAppService
private static final int STATUS_CANCELLED = 3;
private final ITrainClassPlanService planService;
private final ITrainPlanCourseService planCourseService;
private final ITrainCourseService courseService;
private final List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts;
@Autowired
public PlanAppServiceImpl(ITrainClassPlanService planService,
ITrainPlanCourseService planCourseService,
ITrainCourseService courseService,
ObjectProvider<PlanEnrollmentCompletionPort> enrollmentCompletionPortProvider)
{
this(planService, enrollmentCompletionPortProvider.orderedStream()
this(planService, planCourseService, courseService, enrollmentCompletionPortProvider.orderedStream()
.collect(java.util.stream.Collectors.toList()));
}
/** Test seam; production has no fake/no-op Enrollment adapter before ISSUE-03-01. */
/** 兼容早期仅测试培训计划生命周期的测试构造方法 */
PlanAppServiceImpl(ITrainClassPlanService planService,
List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts)
{
this(planService, null, null, enrollmentCompletionPorts);
}
/** 完整业务编排构造方法 (支持 JVM Fake 单元测试注入) */
PlanAppServiceImpl(ITrainClassPlanService planService,
ITrainPlanCourseService planCourseService,
ITrainCourseService courseService,
List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts)
{
this.planService = planService;
this.planCourseService = planCourseService;
this.courseService = courseService;
this.enrollmentCompletionPorts = enrollmentCompletionPorts == null
? Collections.emptyList() : enrollmentCompletionPorts;
}
@@ -148,6 +172,153 @@ public class PlanAppServiceImpl implements PlanAppService
}
}
@Override
public List<PlanCourseItemVO> listPlanCourses(String planId)
{
requirePlan(planService.selectTrainClassPlanByPlanId(planId));
return planCourseService.selectPlanCourseListByPlanId(planId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void configureCourses(String planId, PlanCourseAddDTO dto, String operator)
{
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (isLocked(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
if (dto == null || dto.getCIds() == null || dto.getCIds().isEmpty())
{
throw new ServiceException("请至少选择一门课程");
}
// 校验输入列表内部是否有重复
Set<String> distinctCIds = new LinkedHashSet<>();
for (String cId : dto.getCIds())
{
if (StringUtils.isEmpty(cId))
{
continue;
}
String trimmed = cId.trim();
if (!distinctCIds.add(trimmed))
{
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
}
}
if (distinctCIds.isEmpty())
{
throw new ServiceException("请至少选择一门有效课程");
}
// 校验课程在公共课程库中的存在性与状态
for (String cId : distinctCIds)
{
TrainCourse course = courseService.selectTrainCourseByCId(cId);
if (course == null || "2".equals(course.getDelFlag()))
{
throw new ServiceException(PlanErrorCode.COURSE_NOT_FOUND_MESSAGE);
}
if (Integer.valueOf(0).equals(course.getStatus()))
{
throw new ServiceException(PlanErrorCode.COURSE_INACTIVE_MESSAGE);
}
}
// 校验是否已在当前计划中配置过
List<String> existingCIds = planCourseService.selectExistingCIdsByPlanId(planId, new ArrayList<>(distinctCIds));
if (existingCIds != null && !existingCIds.isEmpty())
{
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
}
// 批量新增,原子事务入库并捕获并发唯一约束冲突 (uq_plan_course)
Date now = DateUtils.getNowDate();
List<TrainPlanCourse> insertList = new ArrayList<>(distinctCIds.size());
int sort = 0;
for (String cId : distinctCIds)
{
TrainPlanCourse pc = new TrainPlanCourse();
pc.setPlanId(planId);
pc.setCId(cId);
pc.setSortNo(sort++);
pc.setCreateBy(operator);
pc.setCreateTime(now);
pc.setRemark(dto.getRemark());
insertList.add(pc);
}
try
{
planCourseService.batchInsertTrainPlanCourse(insertList);
}
catch (org.springframework.dao.DuplicateKeyException e)
{
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
}
catch (Exception e)
{
if (isUniqueConstraintViolation(e))
{
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
}
throw e;
}
}
private boolean isUniqueConstraintViolation(Throwable throwable)
{
Throwable current = throwable;
while (current != null)
{
String msg = current.getMessage();
if (msg != null && (msg.contains("uq_plan_course") || msg.contains("unique constraint") || msg.contains("Duplicate entry")))
{
return true;
}
current = current.getCause();
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removePlanCourse(String planId, Long planCourseId, String operator)
{
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (isLocked(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
if (planCourseId == null)
{
throw new ServiceException("本期课程ID不能为空");
}
TrainPlanCourse existing = planCourseService.selectTrainPlanCourseById(planCourseId);
if (existing == null || !planId.equals(existing.getPlanId()))
{
throw new ServiceException(PlanErrorCode.PLAN_COURSE_NOT_FOUND_MESSAGE);
}
// 检查下游课次引用 (train_lesson)
if (planCourseService.countLessonsByPlanCourseId(planCourseId) > 0)
{
throw error(PlanErrorCode.PLAN_COURSE_HAS_LESSONS_MESSAGE, PlanErrorCode.PLAN_COURSE_HAS_LESSONS);
}
// 检查下游成绩项引用 (train_score_item)
if (planCourseService.countScoreItemsByPlanCourseId(planCourseId) > 0)
{
throw error(PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS_MESSAGE, PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS);
}
// 检查下游学习时长记录引用 (train_study_duration)
if (planCourseService.countStudyDurationsByPlanCourseId(planCourseId) > 0)
{
throw new ServiceException(PlanErrorCode.PLAN_COURSE_HAS_STUDY_DURATIONS_MESSAGE);
}
planCourseService.deleteTrainPlanCourseById(planCourseId);
}
private List<PlanEnrollmentCompletionPort> requireEnrollmentCompletionPorts()
{
if (enrollmentCompletionPorts.isEmpty())
@@ -0,0 +1,90 @@
package com.ruoyi.train.service.impl;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.train.service.ITrainPlanCourseService;
/**
* 培训班与课程关联Service业务层处理。
*
* @author ruoyi
* @date 2026-09-14
*/
@Service
public class TrainPlanCourseServiceImpl implements ITrainPlanCourseService
{
private final TrainPlanCourseMapper trainPlanCourseMapper;
@Autowired
public TrainPlanCourseServiceImpl(TrainPlanCourseMapper trainPlanCourseMapper)
{
this.trainPlanCourseMapper = trainPlanCourseMapper;
}
@Override
public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId)
{
return trainPlanCourseMapper.selectTrainPlanCourseById(planCourseId);
}
@Override
public List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId)
{
return trainPlanCourseMapper.selectPlanCourseListByPlanId(planId);
}
@Override
public List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds)
{
if (cIds == null || cIds.isEmpty())
{
return Collections.emptyList();
}
return trainPlanCourseMapper.selectExistingCIdsByPlanId(planId, cIds);
}
@Override
public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse)
{
return trainPlanCourseMapper.insertTrainPlanCourse(trainPlanCourse);
}
@Override
public int batchInsertTrainPlanCourse(List<TrainPlanCourse> list)
{
if (list == null || list.isEmpty())
{
return 0;
}
return trainPlanCourseMapper.batchInsertTrainPlanCourse(list);
}
@Override
public int deleteTrainPlanCourseById(Long planCourseId)
{
return trainPlanCourseMapper.deleteTrainPlanCourseById(planCourseId);
}
@Override
public int countLessonsByPlanCourseId(Long planCourseId)
{
return trainPlanCourseMapper.countLessonsByPlanCourseId(planCourseId);
}
@Override
public int countScoreItemsByPlanCourseId(Long planCourseId)
{
return trainPlanCourseMapper.countScoreItemsByPlanCourseId(planCourseId);
}
@Override
public int countStudyDurationsByPlanCourseId(Long planCourseId)
{
return trainPlanCourseMapper.countStudyDurationsByPlanCourseId(planCourseId);
}
}
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainPlanCourseMapper">
<resultMap type="TrainPlanCourse" id="TrainPlanCourseResult">
<result property="planCourseId" column="plan_course_id" />
<result property="planId" column="plan_id" />
<result property="cId" column="c_id" />
<result property="sortNo" column="sort_no" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="remark" column="remark" />
</resultMap>
<resultMap type="PlanCourseItemVO" id="PlanCourseItemResult">
<result property="planCourseId" column="plan_course_id" />
<result property="planId" column="plan_id" />
<result property="cId" column="c_id" />
<result property="cCode" column="c_code" />
<result property="cName" column="c_name" />
<result property="cType" column="c_type" />
<result property="cDuration" column="c_duration" />
<result property="teachingObjective" column="teaching_objective" />
<result property="sortNo" column="sort_no" />
<result property="remark" column="remark" />
<result property="createTime" column="create_time" />
</resultMap>
<select id="selectTrainPlanCourseById" parameterType="Long" resultMap="TrainPlanCourseResult">
select plan_course_id, plan_id, c_id, sort_no, create_by, create_time, remark
from train_plan_course
where plan_course_id = #{planCourseId}
</select>
<select id="selectPlanCourseListByPlanId" parameterType="String" resultMap="PlanCourseItemResult">
select pc.plan_course_id,
pc.plan_id,
pc.c_id,
c.c_code,
c.c_name,
c.c_type,
c.c_duration,
c.teaching_objective,
pc.sort_no,
pc.remark,
pc.create_time
from train_plan_course pc
left join train_course c on pc.c_id = c.c_id
where pc.plan_id = #{planId}
order by pc.sort_no asc, pc.plan_course_id asc
</select>
<select id="selectExistingCIdsByPlanId" resultType="String">
select c_id from train_plan_course
where plan_id = #{planId}
<if test="cIds != null and cIds.size() > 0">
and c_id in
<foreach collection="cIds" item="cId" open="(" separator="," close=")">
#{cId}
</foreach>
</if>
</select>
<insert id="insertTrainPlanCourse" parameterType="TrainPlanCourse" useGeneratedKeys="true" keyProperty="planCourseId">
insert into train_plan_course(
plan_id,
c_id,
sort_no,
create_by,
create_time,
remark
)values(
#{planId},
#{cId},
coalesce(#{sortNo}, 0),
#{createBy},
coalesce(#{createTime}, CURRENT_TIMESTAMP),
#{remark}
)
</insert>
<insert id="batchInsertTrainPlanCourse" parameterType="java.util.List">
insert into train_plan_course(
plan_id,
c_id,
sort_no,
create_by,
create_time,
remark
) values
<foreach collection="list" item="item" separator=",">
(
#{item.planId},
#{item.cId},
coalesce(#{item.sortNo}, 0),
#{item.createBy},
coalesce(#{item.createTime}, CURRENT_TIMESTAMP),
#{item.remark}
)
</foreach>
</insert>
<delete id="deleteTrainPlanCourseById" parameterType="Long">
delete from train_plan_course where plan_course_id = #{planCourseId}
</delete>
<select id="countLessonsByPlanCourseId" parameterType="Long" resultType="int">
select count(*) from train_lesson where plan_course_id = #{planCourseId}
</select>
<select id="countScoreItemsByPlanCourseId" parameterType="Long" resultType="int">
select count(*) from train_score_item where plan_course_id = #{planCourseId}
</select>
<select id="countStudyDurationsByPlanCourseId" parameterType="Long" resultType="int">
select count(*) from train_study_duration where plan_course_id = #{planCourseId}
</select>
</mapper>
@@ -0,0 +1,644 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.Before;
import org.junit.Test;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.constant.PlanErrorCode;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.CourseDetailVO;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.train.service.ITrainCourseService;
import com.ruoyi.train.service.ITrainPlanCourseService;
/**
* ISSUE-02-02 本期培训课程引入与配置单元测试。
*/
public class PlanCourseTest
{
private InMemoryPlanService planRepository;
private InMemoryPlanCourseService planCourseRepository;
private InMemoryCourseService courseRepository;
private PlanAppServiceImpl appService;
@Before
public void setUp()
{
planRepository = new InMemoryPlanService();
planCourseRepository = new InMemoryPlanCourseService();
courseRepository = new InMemoryCourseService();
// 预置公共课程
TrainCourse c1 = createCourse("C001", "CRH380A-01", "CRH380A型动车组驾驶制动原理", "理论", 16, 1);
TrainCourse c2 = createCourse("C002", "SIG-ETCS-02", "CTCS-3级列控车载设备维护", "实训", 24, 1);
TrainCourse c3 = createCourse("C003", "SAFE-RULE-03", "铁路行车安全规则与典型事故案例", "理论", 8, 1);
TrainCourse disabledCourse = createCourse("C004", "OLD-SYS-04", "老式内燃机车基础(已停用)", "理论", 10, 0);
courseRepository.addCourse(c1);
courseRepository.addCourse(c2);
courseRepository.addCourse(c3);
courseRepository.addCourse(disabledCourse);
// 预置培训计划
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId("PLAN01");
plan.setPlanName("2026年第一期高铁司机提职培训班");
plan.setPlanStatus(0); // 未开始
plan.setDelFlag("0");
planRepository.insertTrainClassPlan(plan);
appService = new PlanAppServiceImpl(planRepository, planCourseRepository, courseRepository, Collections.emptyList());
}
@Test
public void testConfigure_Success()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Arrays.asList("C001", "C002"));
dto.setRemark("专业核心课");
appService.configureCourses("PLAN01", dto, "admin");
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
assertEquals(2, list.size());
assertEquals("PLAN01", list.get(0).getPlanId());
assertEquals("C001", list.get(0).getCId());
assertEquals("CRH380A型动车组驾驶制动原理", list.get(0).getCName());
assertNotNull(list.get(0).getPlanCourseId());
assertEquals("C002", list.get(1).getCId());
}
@Test
public void testConfigure_RejectDuplicateInRequest()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Arrays.asList("C001", "C001"));
dto.setRemark("重复选课");
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
() -> appService.configureCourses("PLAN01", dto, "admin"));
}
@Test
public void testConfigure_RejectAlreadyConfiguredCourse()
{
// 第一次成功添加 C001
PlanCourseAddDTO dto1 = new PlanCourseAddDTO();
dto1.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto1, "admin");
// 再次添加 C001,触发 AC-01
PlanCourseAddDTO dto2 = new PlanCourseAddDTO();
dto2.setCIds(Arrays.asList("C001", "C002"));
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
() -> appService.configureCourses("PLAN01", dto2, "admin"));
}
@Test
public void testConfigure_RejectMissingPlan()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
assertError(PlanErrorCode.PLAN_NOT_FOUND,
() -> appService.configureCourses("MISSING_PLAN", dto, "admin"));
}
@Test
public void testConfigure_RejectMissingCourse()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("NON_EXIST_COURSE"));
try
{
appService.configureCourses("PLAN01", dto, "admin");
fail("课程不存在时必须拦截");
}
catch (ServiceException e)
{
assertTrue("课程不存在不得占用40020错误码", e.getCode() == null || e.getCode() != PlanErrorCode.PLAN_COURSE_DUPLICATED);
assertEquals(PlanErrorCode.COURSE_NOT_FOUND_MESSAGE, e.getMessage());
}
}
@Test
public void testConfigure_RejectInactiveCourse()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C004")); // 状态为0停用
try
{
appService.configureCourses("PLAN01", dto, "admin");
fail("停用课程禁止引入");
}
catch (ServiceException e)
{
assertTrue("课程停用不得占用40020错误码", e.getCode() == null || e.getCode() != PlanErrorCode.PLAN_COURSE_DUPLICATED);
assertEquals(PlanErrorCode.COURSE_INACTIVE_MESSAGE, e.getMessage());
}
}
@Test
public void testPlanCourseAddDTO_SingleJsonFieldContract() throws Exception
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Arrays.asList("C001", "C002"));
dto.setRemark("仅暴露冻结契约字段");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String json = mapper.writeValueAsString(dto);
assertTrue("JSON 必须包含 cIds 字段", json.contains("\"cIds\""));
assertTrue("JSON 必须包含 remark 字段", json.contains("\"remark\""));
assertTrue("JSON 严禁包含 courseIds 字段", !json.contains("\"courseIds\""));
// 反射检查 getter/setter
for (java.lang.reflect.Method method : PlanCourseAddDTO.class.getDeclaredMethods())
{
assertTrue("DTO 严禁暴露 courseIds 相关方法", !method.getName().toLowerCase().contains("courseids"));
}
}
@Test
public void testConfigure_ConcurrentUniqueConstraintConflictMappedTo40020()
{
// 模拟并发场景:前置查询未拦截,底层数据库抛出唯一约束冲突异常
planCourseRepository.simulateDuplicateKeyException = true;
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C003"));
dto.setRemark("并发冲突模拟");
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
() -> appService.configureCourses("PLAN01", dto, "admin"));
}
@Test
public void testConfigure_RejectCompletedPlan()
{
planRepository.store.get("PLAN01").setPlanStatus(2); // 已结束
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.configureCourses("PLAN01", dto, "admin"));
}
@Test
public void testConfigure_RejectCancelledPlan()
{
planRepository.store.get("PLAN01").setPlanStatus(3); // 已取消
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.configureCourses("PLAN01", dto, "admin"));
}
@Test
public void testConfigure_TransactionalAnnotation() throws NoSuchMethodException
{
Transactional tx = PlanAppServiceImpl.class
.getMethod("configureCourses", String.class, PlanCourseAddDTO.class, String.class)
.getAnnotation(Transactional.class);
assertNotNull("批量选课必须声明 @Transactional", tx);
assertEquals(Exception.class, tx.rollbackFor()[0]);
}
@Test
public void testList_Success()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Arrays.asList("C001", "C002"));
appService.configureCourses("PLAN01", dto, "admin");
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
assertEquals(2, list.size());
assertEquals("CRH380A-01", list.get(0).getCCode());
assertEquals("CTCS-3级列控车载设备维护", list.get(1).getCName());
assertEquals(Integer.valueOf(24), list.get(1).getCDuration());
}
@Test
public void testList_RejectMissingPlan()
{
assertError(PlanErrorCode.PLAN_NOT_FOUND,
() -> appService.listPlanCourses("NOT_EXIST_PLAN"));
}
@Test
public void testRemove_Success()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto, "admin");
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
Long planCourseId = list.get(0).getPlanCourseId();
appService.removePlanCourse("PLAN01", planCourseId, "admin");
assertEquals(0, appService.listPlanCourses("PLAN01").size());
}
@Test
public void testRemove_RejectLockedPlan()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto, "admin");
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
Long planCourseId = list.get(0).getPlanCourseId();
planRepository.store.get("PLAN01").setPlanStatus(2); // 锁定状态
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
}
@Test
public void testRemove_RejectMissingPlanCourse()
{
try
{
appService.removePlanCourse("PLAN01", 999999L, "admin");
fail("删除不存在的 planCourseId 必须拒绝");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("不存在") || e.getMessage().contains("不属于"));
}
}
@Test
public void testRemove_RejectCrossPlanCourse()
{
// 创建第二个计划并配置 C001
TrainClassPlan plan2 = new TrainClassPlan();
plan2.setPlanId("PLAN02");
plan2.setPlanName("2026年第二期培训班");
plan2.setPlanStatus(0);
planRepository.insertTrainClassPlan(plan2);
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN02", dto, "admin");
Long plan2CourseId = appService.listPlanCourses("PLAN02").get(0).getPlanCourseId();
// 尝试用 PLAN01 越权删除 PLAN02 的本期课程记录
try
{
appService.removePlanCourse("PLAN01", plan2CourseId, "admin");
fail("跨计划删除必须拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("不属于") || e.getMessage().contains("不存在"));
}
}
@Test
public void testRemove_RejectWhenHasLessons()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto, "admin");
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
// 模拟已存在下游课次
planCourseRepository.lessonCountMap.put(planCourseId, 1);
assertError(PlanErrorCode.PLAN_COURSE_HAS_LESSONS,
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
}
@Test
public void testRemove_RejectWhenHasScoreItems()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto, "admin");
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
// 模拟已存在成绩考核项
planCourseRepository.scoreItemCountMap.put(planCourseId, 1);
assertError(PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS,
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
}
@Test
public void testRemove_RejectWhenHasStudyDurations()
{
PlanCourseAddDTO dto = new PlanCourseAddDTO();
dto.setCIds(Collections.singletonList("C001"));
appService.configureCourses("PLAN01", dto, "admin");
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
// 模拟已存在外部学习记录
planCourseRepository.studyDurationCountMap.put(planCourseId, 1);
try
{
appService.removePlanCourse("PLAN01", planCourseId, "admin");
fail("存在下游外部学习记录时必须拒绝删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("学习记录"));
}
}
@Test
public void testPlanDomainInvariants()
{
for (Field field : TrainClassPlan.class.getDeclaredFields())
{
assertTrue("Plan 实体严禁包含单课程字段 (cId / courseId)",
!"cId".equalsIgnoreCase(field.getName()) && !"courseId".equalsIgnoreCase(field.getName()));
}
}
private TrainCourse createCourse(String cId, String cCode, String cName, String cType, int duration, int status)
{
TrainCourse course = new TrainCourse();
course.setCId(cId);
course.setCCode(cCode);
course.setCName(cName);
course.setCType(cType);
course.setCDuration(duration);
course.setStatus(status);
course.setDelFlag("0");
return course;
}
private void assertError(int code, ThrowingAction action)
{
try
{
action.run();
fail("Expected ServiceException code " + code);
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(code), e.getCode());
assertTrue(e.getMessage() != null && !e.getMessage().isEmpty());
}
}
private interface ThrowingAction
{
void run();
}
private static class InMemoryPlanService implements ITrainClassPlanService
{
private final Map<String, TrainClassPlan> store = new LinkedHashMap<>();
@Override
public TrainClassPlan selectTrainClassPlanByPlanId(String planId)
{
return store.get(planId);
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return store.get(planId);
}
@Override
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return Collections.emptyList();
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
return false;
}
@Override
public int insertTrainClassPlan(TrainClassPlan trainClassPlan)
{
store.put(trainClassPlan.getPlanId(), trainClassPlan);
return 1;
}
@Override
public int updateTrainClassPlan(TrainClassPlan trainClassPlan)
{
return 1;
}
@Override
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan)
{
return 1;
}
}
private class InMemoryPlanCourseService implements ITrainPlanCourseService
{
private final AtomicLong idSeq = new AtomicLong(1000);
private final Map<Long, TrainPlanCourse> store = new LinkedHashMap<>();
private final Map<Long, Integer> lessonCountMap = new HashMap<>();
private final Map<Long, Integer> scoreItemCountMap = new HashMap<>();
private final Map<Long, Integer> studyDurationCountMap = new HashMap<>();
private boolean simulateDuplicateKeyException = false;
@Override
public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId)
{
return store.get(planCourseId);
}
@Override
public List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId)
{
List<PlanCourseItemVO> result = new ArrayList<>();
for (TrainPlanCourse pc : store.values())
{
if (planId.equals(pc.getPlanId()))
{
PlanCourseItemVO vo = new PlanCourseItemVO();
vo.setPlanCourseId(pc.getPlanCourseId());
vo.setPlanId(pc.getPlanId());
vo.setCId(pc.getCId());
vo.setSortNo(pc.getSortNo());
vo.setRemark(pc.getRemark());
vo.setCreateTime(pc.getCreateTime());
TrainCourse course = courseRepository.selectTrainCourseByCId(pc.getCId());
if (course != null)
{
vo.setCCode(course.getCCode());
vo.setCName(course.getCName());
vo.setCType(course.getCType());
vo.setCDuration(course.getCDuration());
vo.setTeachingObjective(course.getTeachingObjective());
}
result.add(vo);
}
}
return result;
}
@Override
public List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds)
{
List<String> exists = new ArrayList<>();
for (TrainPlanCourse pc : store.values())
{
if (planId.equals(pc.getPlanId()) && cIds.contains(pc.getCId()))
{
exists.add(pc.getCId());
}
}
return exists;
}
@Override
public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse)
{
long id = idSeq.incrementAndGet();
trainPlanCourse.setPlanCourseId(id);
store.put(id, trainPlanCourse);
return 1;
}
@Override
public int batchInsertTrainPlanCourse(List<TrainPlanCourse> list)
{
if (simulateDuplicateKeyException)
{
throw new org.springframework.dao.DuplicateKeyException("Unique constraint violation on uq_plan_course");
}
for (TrainPlanCourse item : list)
{
insertTrainPlanCourse(item);
}
return list.size();
}
@Override
public int deleteTrainPlanCourseById(Long planCourseId)
{
return store.remove(planCourseId) != null ? 1 : 0;
}
@Override
public int countLessonsByPlanCourseId(Long planCourseId)
{
return lessonCountMap.getOrDefault(planCourseId, 0);
}
@Override
public int countScoreItemsByPlanCourseId(Long planCourseId)
{
return scoreItemCountMap.getOrDefault(planCourseId, 0);
}
@Override
public int countStudyDurationsByPlanCourseId(Long planCourseId)
{
return studyDurationCountMap.getOrDefault(planCourseId, 0);
}
}
private static class InMemoryCourseService implements ITrainCourseService
{
private final Map<String, TrainCourse> courseMap = new HashMap<>();
public void addCourse(TrainCourse course)
{
courseMap.put(course.getCId(), course);
}
@Override
public CourseDetailVO selectCourseDetailByCId(String cId)
{
return null;
}
@Override
public TrainCourse selectTrainCourseByCId(String cId)
{
return courseMap.get(cId);
}
@Override
public List<TrainCourse> selectTrainCourseList(TrainCourse trainCourse)
{
return new ArrayList<>(courseMap.values());
}
@Override
public int insertTrainCourse(TrainCourse trainCourse)
{
courseMap.put(trainCourse.getCId(), trainCourse);
return 1;
}
@Override
public int updateTrainCourse(TrainCourse trainCourse)
{
return 1;
}
@Override
public int deleteTrainCourseByCIds(String[] cIds)
{
return 0;
}
@Override
public boolean checkCCodeUnique(TrainCourse trainCourse)
{
return true;
}
@Override
public TrainCourse selectTrainCourseByCCode(String cCode)
{
return null;
}
@Override
public int deleteTrainCourseByCId(String cId)
{
return 0;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/request'
// 查询本期已配置课程列表
export function listPlanCourses(planId) {
return request({
url: `/train/plans/${planId}/courses`,
method: 'get'
})
}
// 配置本期课程 (UC-03)
export function configurePlanCourses(planId, data) {
return request({
url: `/train/plans/${planId}/courses`,
method: 'post',
data: data
})
}
// 移除本期课程
export function removePlanCourse(planId, planCourseId) {
return request({
url: `/train/plans/${planId}/courses/${planCourseId}`,
method: 'delete'
})
}
+224 -4
View File
@@ -60,10 +60,25 @@
<el-table-column label="报到截止" align="center" prop="reportDeadline" width="165" />
<el-table-column label="培训地点" align="left" prop="trainAddress" min-width="180" show-overflow-tooltip />
<el-table-column label="计划人数" align="center" prop="recruitNum" width="90" />
<el-table-column label="课程数" align="center" prop="courseCount" width="80" />
<el-table-column label="参训人数" align="center" prop="enrolledCount" width="90" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="170" fixed="right">
<el-table-column label="课程数" align="center" prop="courseCount" width="90">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
type="text"
@click="handleCourseConfig(scope.row)"
>{{ scope.row.courseCount || 0 }}</el-button>
</template>
</el-table-column>
<el-table-column label="参训人数" align="center" prop="enrolledCount" width="90" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="230" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
size="mini"
type="text"
icon="el-icon-reading"
@click="handleCourseConfig(scope.row)"
>课程配置</el-button>
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
@@ -92,6 +107,7 @@
@pagination="getList"
/>
<!-- 创建/编辑培训计划对话框 -->
<el-dialog :title="title" :visible.sync="open" width="660px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="培训班名称" prop="planName">
@@ -136,11 +152,116 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 本期课程配置对话框 -->
<el-dialog :title="courseDialog.title" :visible.sync="courseDialog.open" width="900px" append-to-body>
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span>班级状态:</span>
<el-tag :type="statusTagType(courseDialog.planStatus)">{{ statusLabel(courseDialog.planStatus) }}</el-tag>
<span style="margin-left: 15px; color: #909399; font-size: 13px;">已配置课程:{{ courseDialog.list.length }} 门</span>
</div>
<div>
<el-button
v-hasPermi="['train:plan:edit']"
type="primary"
size="mini"
icon="el-icon-plus"
:disabled="isLocked(courseDialog.planStatus)"
@click="handleOpenCourseSelector"
>引入课程</el-button>
</div>
</div>
<el-table v-loading="courseDialog.loading" :data="courseDialog.list" border style="width: 100%">
<el-table-column label="序号" type="index" width="55" align="center" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="排序号" prop="sortNo" width="70" align="center" />
<el-table-column label="配置说明" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="80" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isLocked(courseDialog.planStatus)"
@click="handleRemoveCourse(scope.row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="courseDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 引入公共课程选择器弹窗 -->
<el-dialog title="引入公共课程" :visible.sync="selectorDialog.open" width="800px" append-to-body>
<el-form :model="selectorDialog.queryParams" size="small" :inline="true">
<el-form-item label="课程名称">
<el-input v-model="selectorDialog.queryParams.cName" placeholder="课程名称" clearable @keyup.enter.native="getCoursePool" />
</el-form-item>
<el-form-item label="课程编码">
<el-input v-model="selectorDialog.queryParams.cCode" placeholder="课程编码" clearable @keyup.enter.native="getCoursePool" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="getCoursePool">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetCoursePool">重置</el-button>
</el-form-item>
</el-form>
<el-table
ref="courseTable"
v-loading="selectorDialog.loading"
:data="selectorDialog.list"
height="320"
@selection-change="handleCourseSelectionChange"
>
<el-table-column type="selection" width="55" align="center" :selectable="checkCourseSelectable" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="课程类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="状态" align="center" width="90">
<template slot-scope="scope">
<el-tag v-if="isAlreadyAdded(scope.row.cId)" type="info" size="mini">已配置</el-tag>
<el-tag v-else-if="scope.row.status === 0" type="danger" size="mini">已停用</el-tag>
<el-tag v-else type="success" size="mini">可选</el-tag>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 15px;">
<el-form size="small" label-width="110px">
<el-form-item label="配置说明 / 备注">
<el-input v-model="selectorDialog.remark" placeholder="请输入本期课程配置备注(可选)" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button
type="primary"
:disabled="selectedCourses.length === 0"
:loading="selectorDialog.submitting"
@click="submitCourseSelection"
>确定引入 ({{ selectedCourses.length }})</el-button>
<el-button @click="selectorDialog.open = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { addPlan, completePlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
import { listPlanCourses, configurePlanCourses, removePlanCourse } from '@/api/train/planCourse'
import { listCourse } from '@/api/train/course'
export default {
name: 'Plan',
@@ -172,7 +293,31 @@ export default {
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
}
},
// 本期课程配置对话框
courseDialog: {
open: false,
loading: false,
title: '',
planId: null,
planName: '',
planStatus: null,
list: []
},
// 公共课程选择器
selectorDialog: {
open: false,
loading: false,
submitting: false,
remark: '',
queryParams: {
cName: null,
cCode: null,
status: 1
},
list: []
},
selectedCourses: []
}
},
created() {
@@ -283,6 +428,81 @@ export default {
this.$modal.msgSuccess('计划已结束')
this.getList()
}).catch(() => {})
},
// ---- ISSUE-02-02 本期课程配置方法 ----
handleCourseConfig(row) {
this.courseDialog.planId = row.planId
this.courseDialog.planName = row.planName
this.courseDialog.planStatus = row.planStatus
this.courseDialog.title = `本期课程配置 - ${row.planName}`
this.courseDialog.open = true
this.loadPlanCourses(row.planId)
},
loadPlanCourses(planId) {
this.courseDialog.loading = true
listPlanCourses(planId).then(response => {
this.courseDialog.list = response.data || []
}).finally(() => {
this.courseDialog.loading = false
})
},
handleOpenCourseSelector() {
this.selectorDialog.remark = ''
this.selectedCourses = []
this.selectorDialog.queryParams.cName = null
this.selectorDialog.queryParams.cCode = null
this.selectorDialog.open = true
this.getCoursePool()
},
getCoursePool() {
this.selectorDialog.loading = true
listCourse(this.selectorDialog.queryParams).then(response => {
this.selectorDialog.list = response.rows || []
}).finally(() => {
this.selectorDialog.loading = false
})
},
resetCoursePool() {
this.selectorDialog.queryParams.cName = null
this.selectorDialog.queryParams.cCode = null
this.getCoursePool()
},
isAlreadyAdded(cId) {
return this.courseDialog.list.some(item => item.cId === cId)
},
checkCourseSelectable(row) {
return !this.isAlreadyAdded(row.cId) && row.status === 1
},
handleCourseSelectionChange(selection) {
this.selectedCourses = selection
},
submitCourseSelection() {
if (this.selectedCourses.length === 0) {
this.$modal.msgWarning('请选择至少一门课程')
return
}
this.selectorDialog.submitting = true
const cIds = this.selectedCourses.map(item => item.cId)
configurePlanCourses(this.courseDialog.planId, {
cIds: cIds,
remark: this.selectorDialog.remark
}).then(() => {
this.$modal.msgSuccess('成功引入课程')
this.selectorDialog.open = false
this.loadPlanCourses(this.courseDialog.planId)
this.getList()
}).finally(() => {
this.selectorDialog.submitting = false
})
},
handleRemoveCourse(row) {
this.$modal.confirm(`确定要从本期培训计划中移除课程【${row.cName}】吗?`).then(() => {
return removePlanCourse(this.courseDialog.planId, row.planCourseId)
}).then(() => {
this.$modal.msgSuccess('移除成功')
this.loadPlanCourses(this.courseDialog.planId)
this.getList()
}).catch(() => {})
}
}
}
+109
View File
@@ -0,0 +1,109 @@
# ISSUE-02-02 本期培训课程引入与配置 开发与验收记录
## 1. 基线与分支信息
- **基线 Commit:** `4682df0399d86fa73105ea23ba2daee06cfb0fa0` (`integration/wave1a-foundation`)
- **开发分支:** `feat/issue-02-02-plan-course`
- **Worktree 路径:** `/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-02-02-plan-course`
---
## 2. 3 个正式公开 Endpoint
| 序号 | HTTP Method | Path | 权限标识 | 业务说明 |
|:---|:---|:---|:---|:---|
| 1 | `GET` | `/train/plans/{planId}/courses` | `train:plan:query` | 查询指定培训班本期已配置课程列表及课程元数据 |
| 2 | `POST` | `/train/plans/{planId}/courses` | `train:plan:edit` | 引入课程库公共课程配置至本期计划 (UC-03) |
| 3 | `DELETE` | `/train/plans/{planId}/courses/{planCourseId}` | `train:plan:edit` | 移除本期课程(受下游引用与生命周期守护) |
- **控制器归属:** `com.ruoyi.web.controller.train.TrainPlanCourseController`
- **应用编排归属:** `com.ruoyi.train.service.PlanAppService` (严格复用既有 11 个应用服务之一,未新增第 12 个 Application Service)。
---
## 3. 请求与响应模型 (DTO / VO)
### 3.1 请求对象 (`PlanCourseAddDTO`)
- 严格遵循 API Contract V1.0.2 契约,仅暴露以下两个 JSON 字段:
- `cIds`: `List<String>`, `@NotEmpty` (课程主键 ID 集合,绑定 `@JsonProperty("cIds")`)
- `remark`: `String`, `@Size(max = 500)` (配置说明)
- 经单元测试反射与 Jackson 序列化静态校验,确认不存在 `courseIds` 等多余或模糊属性。
### 3.2 响应对象 (`PlanCourseItemVO`)
- `planCourseId`: `Long` (本期课程实例唯一主键)
- `planId`: `String` (培训班编号)
- `cId`: `String` (课程主数据编号)
- `cCode`: `String` (课程业务编码)
- `cName`: `String` (课程名称)
- `cType`: `String` (课程类型)
- `cDuration`: `Integer` (标准学时)
- `teachingObjective`: `String` (教学目标)
- `sortNo`: `Integer` (排序号)
- `remark`: `String` (配置说明)
- `createTime`: `Date` (创建时间)
---
## 4. 生命周期规则与复用
- 严格复用 `TrainClassPlan` 冻结的生命周期与计算规则 (`effective_plan_status`):
- `status = 0` (未开始) 与 `status = 1` (进行中):允许配置课程与移除课程;
- `status = 2` (已结束) 与 `status = 3` (已取消):只读锁定,禁止任何增删改课程操作,抛出 `40012` (`PLAN_STATUS_LOCKED`)。
- 不在课程服务层自行发明独立的时间比对判断,确保前台视图与后台判断无状态分裂。
---
## 5. 唯一约束与并发边界 (UNIQUE & Concurrency)
- 数据库级约束:`CONSTRAINT uq_plan_course UNIQUE (plan_id, c_id)`。
- 应用层前置校验:
- 请求体内自包含查重(使用 `LinkedHashSet` 校验,重复抛 `40020` `PLAN_COURSE_DUPLICATED`);
- 已存在配置查重(`selectExistingCIdsByPlanId` 查重,存在交集抛 `40020` `PLAN_COURSE_DUPLICATED`)。
- 并发兜底机制:
- 当高并发下两个事务同时穿透前置检查并执行批量插入时,数据库触发 `uq_plan_course` 唯一约束异常;
- 应用层 `PlanAppServiceImpl` 捕获 `DuplicateKeyException` 及 `uq_plan_course` 冲突异常,精确转换为 `40020` `PLAN_COURSE_DUPLICATED`,防止裸 500 或数据库异常泄漏。
---
## 6. 下游引用保护与删除阻断
- **课次引用 (`train_lesson`):** 若存在已排定课次,阻断删除并返回 `40021` (`PLAN_COURSE_HAS_LESSONS`);
- **成绩项引用 (`train_score_item`):** 若已关联成绩考核项目,阻断删除并返回 `40022` (`PLAN_COURSE_HAS_SCORE_ITEMS`);
- **外部学习记录 (`train_study_duration`):** 若已产生学习时长记录,阻断删除并抛出业务异常;
- **级联删除:** 数据库外键定义为 `ON DELETE NO ACTION`,代码层严格拦截,严禁任何形式的 cascade delete。
---
## 7. Contract Gap 登记
1. **Course 不存在 / 停用错误码缺失:**
- API Contract `10_permission_and_error_codes.md` 仅针对开班计划定义了 `40410` (`PLAN_NOT_FOUND`),未给课程库分配 `COURSE_NOT_FOUND` 或 `COURSE_INACTIVE` 的专有 40xxx 错误码。
- 实现方案:抛出带明确错误信息的 `ServiceException`(消息为“课程不存在或已被删除”、“课程已被停用,无法配置到本期培训计划中”),坚决不复用或污染 `40020`。
2. **StudyDuration 下游引用错误码缺失:**
- 错误码 Catalog 仅定义了 `40021` (Lessons) 与 `40022` (ScoreItems),未定义 `PLAN_COURSE_HAS_STUDY_DURATIONS` 的专属错误码。
- 实现方案:fail closed 拦截,抛出提示“该课程已产生学习记录,不可直接移除”,禁止物理级联删除。
---
## 8. 自动化测试结果
- 单元测试套件:`com.ruoyi.train.service.impl.PlanCourseTest` 与 `PlanAppServiceImplTest`
- 测试总数:35 个测试用例全部通过(0 failures, 0 errors, 0 skipped)。
- 覆盖场景:
1. 正常批量配置课程与元数据返回;
2. 请求体内自重复课程拒绝 (`40020`);
3. 数据库已配置课程重复添加拦截 (`40020`, AC-01);
4. 计划不存在拦截 (`40410`);
5. 课程不存在拦截(独立异常语义,非 40020);
6. 课程停用拦截(独立异常语义,非 40020);
7. 已结束/已取消计划拒绝修改 (`40012`);
8. 批量选课 `@Transactional` 原子事务注解验证;
9. 本期课程列表查询与字段回显 (AC-02);
10. 正常移除本期课程;
11. 锁定状态计划禁止移除课程 (`40012`);
12. 移除不存在或跨计划的课程配置拦截;
13. 存在排定课次时拒绝删除 (`40021`);
14. 存在成绩考核项时拒绝删除 (`40022`);
15. 存在外部学习时长记录时拒绝删除;
16. `PlanCourseAddDTO` 严格单一 JSON 属性契约测试;
17. 并发插入数据库唯一键冲突转换映射为 `40020` 测试;
18. 实体不变量检验(确保 `TrainClassPlan` 不含有单课程字段)。
---
## 9. 规范与红线遵循
- **DB 真实写入:** 否(测试全部使用 JVM InMemory Fake Repository,未连接真实数据库)。
- **Generator 执行:** 否(未执行任何代码生成器覆写或额外 DDL)。
- **Commit 状态:** 未执行 `git commit` / `git merge` / `git rebase` / `git tag`。