合并培训:集成Wave 1A基础能力

# Conflicts:
#	docs/issues-v1/00_readme.md
#	docs/issues-v1/01_epic_overview.md
This commit is contained in:
wangzhibiao committed 2026-09-14 13:16:11 +08:00
commit e48097370b
86 files changed
+7301 -1676

No files matched your search

@@ -1,104 +0,0 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.train.domain.TrainClassPlan;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 开班计划Controller
*
* @author ruoyi
* @date 2026-09-08
*/
@RestController
@RequestMapping("/train/plan")
public class TrainClassPlanController extends BaseController
{
@Autowired
private ITrainClassPlanService trainClassPlanService;
/**
* 查询开班计划列表
*/
@PreAuthorize("@ss.hasPermi('system:plan:list')")
@GetMapping("/list")
public TableDataInfo list(TrainClassPlan trainClassPlan)
{
startPage();
List<TrainClassPlan> list = trainClassPlanService.selectTrainClassPlanList(trainClassPlan);
return getDataTable(list);
}
/**
* 导出开班计划列表
*/
@PreAuthorize("@ss.hasPermi('system:plan:export')")
@Log(title = "开班计划", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainClassPlan trainClassPlan)
{
List<TrainClassPlan> list = trainClassPlanService.selectTrainClassPlanList(trainClassPlan);
ExcelUtil<TrainClassPlan> util = new ExcelUtil<TrainClassPlan>(TrainClassPlan.class);
util.exportExcel(response, list, "开班计划数据");
}
/**
* 获取开班计划详细信息
*/
@PreAuthorize("@ss.hasPermi('system:plan:query')")
@GetMapping(value = "/{planId}")
public AjaxResult getInfo(@PathVariable("planId") String planId)
{
return success(trainClassPlanService.selectTrainClassPlanByPlanId(planId));
}
/**
* 新增开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:add')")
@Log(title = "开班计划", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainClassPlan trainClassPlan)
{
return toAjax(trainClassPlanService.insertTrainClassPlan(trainClassPlan));
}
/**
* 修改开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:edit')")
@Log(title = "开班计划", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainClassPlan trainClassPlan)
{
return toAjax(trainClassPlanService.updateTrainClassPlan(trainClassPlan));
}
/**
* 删除开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:remove')")
@Log(title = "开班计划", businessType = BusinessType.DELETE)
@DeleteMapping("/{planIds}")
public AjaxResult remove(@PathVariable String[] planIds)
{
return toAjax(trainClassPlanService.deleteTrainClassPlanByPlanIds(planIds));
}
}
@@ -1,34 +1,33 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainClassroomInfo;
import com.ruoyi.train.service.ITrainClassroomInfoService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 教室信息Controller
*
*
* @author ruoyi
* @date 2026-09-08
*/
@RestController
@RequestMapping({"/train/classroominfo", "/train/classroom", "/train/info"})
@RequestMapping("/train/classroom")
public class TrainClassroomInfoController extends BaseController
{
@Autowired
@@ -37,7 +36,7 @@ public class TrainClassroomInfoController extends BaseController
/**
* 查询教室信息列表
*/
@PreAuthorize("@ss.hasPermi('system:info:list')")
@PreAuthorize("@ss.hasPermi('train:classroom:list')")
@GetMapping("/list")
public TableDataInfo list(TrainClassroomInfo trainClassroomInfo)
{
@@ -47,58 +46,37 @@ public class TrainClassroomInfoController extends BaseController
}
/**
* 导出教室信息列表
* 获取教室信息详细信息(回显详情)
*/
@PreAuthorize("@ss.hasPermi('system:info:export')")
@Log(title = "教室信息", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainClassroomInfo trainClassroomInfo)
{
List<TrainClassroomInfo> list = trainClassroomInfoService.selectTrainClassroomInfoList(trainClassroomInfo);
ExcelUtil<TrainClassroomInfo> util = new ExcelUtil<TrainClassroomInfo>(TrainClassroomInfo.class);
util.exportExcel(response, list, "教室信息数据");
}
/**
* 获取教室信息详细信息
*/
@PreAuthorize("@ss.hasPermi('system:info:query')")
@PreAuthorize("@ss.hasPermi('train:classroom:list')")
@GetMapping(value = "/{classRoomId}")
public AjaxResult getInfo(@PathVariable("classRoomId") String classRoomId)
{
return success(trainClassroomInfoService.selectTrainClassroomInfoByClassRoomId(classRoomId));
return success(trainClassroomInfoService.selectClassroomDetailByClassRoomId(classRoomId));
}
/**
* 新增教室信息
*/
@PreAuthorize("@ss.hasPermi('system:info:add')")
@PreAuthorize("@ss.hasPermi('train:classroom:edit')")
@Log(title = "教室信息", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainClassroomInfo trainClassroomInfo)
public AjaxResult add(@Validated @RequestBody TrainClassroomInfo trainClassroomInfo)
{
trainClassroomInfo.setCreateBy(SecurityUtils.getUsername());
return toAjax(trainClassroomInfoService.insertTrainClassroomInfo(trainClassroomInfo));
}
/**
* 修改教室信息
* 修改教室信息 (RESTful PUT /train/classroom/{classRoomId})
*/
@PreAuthorize("@ss.hasPermi('system:info:edit')")
@PreAuthorize("@ss.hasPermi('train:classroom:edit')")
@Log(title = "教室信息", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainClassroomInfo trainClassroomInfo)
@PutMapping("/{classRoomId}")
public AjaxResult edit(@PathVariable("classRoomId") String classRoomId, @Validated @RequestBody TrainClassroomInfo trainClassroomInfo)
{
trainClassroomInfo.setClassRoomId(classRoomId);
trainClassroomInfo.setUpdateBy(SecurityUtils.getUsername());
return toAjax(trainClassroomInfoService.updateTrainClassroomInfo(trainClassroomInfo));
}
/**
* 删除教室信息
*/
@PreAuthorize("@ss.hasPermi('system:info:remove')")
@Log(title = "教室信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{classRoomIds}")
public AjaxResult remove(@PathVariable String[] classRoomIds)
{
return toAjax(trainClassroomInfoService.deleteTrainClassroomInfoByClassRoomIds(classRoomIds));
}
}
@@ -1,13 +1,11 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -16,16 +14,17 @@ 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.StringUtils;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.vo.CourseDetailVO;
import com.ruoyi.train.service.ITrainCourseService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 课程信息Controller
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
@RestController
@RequestMapping("/train/course")
@@ -35,9 +34,9 @@ public class TrainCourseController extends BaseController
private ITrainCourseService trainCourseService;
/**
* 查询课程信息列表
* 查询课程信息列表 【P0】
*/
@PreAuthorize("@ss.hasPermi('system:course:list')")
@PreAuthorize("@ss.hasPermi('train:course:list')")
@GetMapping("/list")
public TableDataInfo list(TrainCourse trainCourse)
{
@@ -47,58 +46,61 @@ public class TrainCourseController extends BaseController
}
/**
* 导出课程信息列表
* 获取课程信息详细信息 【P0】
* 返回结构:CourseDetailVO (包含课程基础字段与契约预留resources列表)
*/
@PreAuthorize("@ss.hasPermi('system:course:export')")
@Log(title = "课程信息", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainCourse trainCourse)
{
List<TrainCourse> list = trainCourseService.selectTrainCourseList(trainCourse);
ExcelUtil<TrainCourse> util = new ExcelUtil<TrainCourse>(TrainCourse.class);
util.exportExcel(response, list, "课程信息数据");
}
/**
* 获取课程信息详细信息
*/
@PreAuthorize("@ss.hasPermi('system:course:query')")
@PreAuthorize("@ss.hasPermi('train:course:query')")
@GetMapping(value = "/{cId}")
public AjaxResult getInfo(@PathVariable("cId") String cId)
{
return success(trainCourseService.selectTrainCourseByCId(cId));
CourseDetailVO detailVO = trainCourseService.selectCourseDetailByCId(cId);
if (detailVO == null)
{
return AjaxResult.error(404, "课程不存在");
}
return success(detailVO);
}
/**
* 新增课程信息
* 新增课程信息 【P0】
*/
@PreAuthorize("@ss.hasPermi('system:course:add')")
@PreAuthorize("@ss.hasPermi('train:course:add')")
@Log(title = "课程信息", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainCourse trainCourse)
{
if (StringUtils.isEmpty(trainCourse.getcName()))
{
return AjaxResult.error(400, "课程名称不能为空");
}
if (StringUtils.isNotEmpty(trainCourse.getcCode()) && !trainCourseService.checkCCodeUnique(trainCourse))
{
return AjaxResult.error(40003, "课程编码已存在");
}
if (trainCourse.getcDuration() != null && trainCourse.getcDuration() <= 0)
{
return AjaxResult.error(400, "课程学时必须为正整数");
}
return toAjax(trainCourseService.insertTrainCourse(trainCourse));
}
/**
* 修改课程信息
* 修改课程信息 (API Contract 3.4 【P1】: PUT /train/course/{cId})
*/
@PreAuthorize("@ss.hasPermi('system:course:edit')")
@PreAuthorize("@ss.hasPermi('train:course:edit')")
@Log(title = "课程信息", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainCourse trainCourse)
@PutMapping("/{cId}")
public AjaxResult edit(@PathVariable("cId") String cId, @RequestBody TrainCourse trainCourse)
{
trainCourse.setcId(cId);
if (StringUtils.isNotEmpty(trainCourse.getcCode()) && !trainCourseService.checkCCodeUnique(trainCourse))
{
return AjaxResult.error(40003, "课程编码已存在");
}
if (trainCourse.getcDuration() != null && trainCourse.getcDuration() <= 0)
{
return AjaxResult.error(400, "课程学时必须为正整数");
}
return toAjax(trainCourseService.updateTrainCourse(trainCourse));
}
/**
* 删除课程信息
*/
@PreAuthorize("@ss.hasPermi('system:course:remove')")
@Log(title = "课程信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{cIds}")
public AjaxResult remove(@PathVariable String[] cIds)
{
return toAjax(trainCourseService.deleteTrainCourseByCIds(cIds));
}
}
@@ -0,0 +1,85 @@
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
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.PlanItemVO;
import com.ruoyi.train.service.PlanAppService;
/** 培训计划生命周期 API。 */
@RestController
@RequestMapping("/train/plans")
public class TrainPlanController extends BaseController
{
private final PlanAppService planAppService;
@Autowired
public TrainPlanController(PlanAppService planAppService)
{
this.planAppService = planAppService;
}
/** 分页查询培训计划列表。 */
@PreAuthorize("@ss.hasPermi('train:plan:list')")
@GetMapping
public TableDataInfo list(PlanQuery query)
{
startPage();
List<PlanItemVO> list = planAppService.listPlans(query);
return getDataTable(list);
}
/** 获取培训计划基本信息。 */
@PreAuthorize("@ss.hasPermi('train:plan:query')")
@GetMapping("/{planId}")
public AjaxResult getInfo(@PathVariable String planId)
{
return success(planAppService.getPlan(planId));
}
/** 创建培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:add')")
@Log(title = "培训计划", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody PlanCreateDTO dto)
{
return success(planAppService.createPlan(dto, SecurityUtils.getUsername()));
}
/** 编辑培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
@Log(title = "培训计划", businessType = BusinessType.UPDATE)
@PutMapping("/{planId}")
public AjaxResult edit(@PathVariable String planId, @Validated @RequestBody PlanUpdateDTO dto)
{
planAppService.updatePlan(planId, dto, SecurityUtils.getUsername());
return success();
}
/** 结束培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:complete')")
@Log(title = "培训计划结训", businessType = BusinessType.UPDATE)
@PostMapping("/{planId}/complete")
public AjaxResult complete(@PathVariable String planId)
{
planAppService.completePlan(planId, SecurityUtils.getUsername());
return success();
}
}
@@ -1,13 +1,12 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import java.util.regex.Pattern;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -15,90 +14,108 @@ 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.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.service.ITrainTeacherService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 教师信息Controller
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
@RestController
@RequestMapping("/train/teacher")
public class TrainTeacherController extends BaseController
{
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
@Autowired
private ITrainTeacherService trainTeacherService;
/**
* 查询教师信息列表
* 查询教师信息列表 (GET /train/teacher/list)
*/
@PreAuthorize("@ss.hasPermi('system:teacher:list')")
@PreAuthorize("@ss.hasPermi('train:teacher:list')")
@GetMapping("/list")
public TableDataInfo list(TrainTeacher trainTeacher)
{
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageSize = pageDomain.getPageSize();
if (pageSize != null && pageSize > 100)
{
throw new ServiceException("分页大小不能超过100");
}
startPage();
List<TrainTeacher> list = trainTeacherService.selectTrainTeacherList(trainTeacher);
return getDataTable(list);
}
/**
* 导出教师信息列表
* 获取教师信息详细信息 (GET /train/teacher/{tId})
*/
@PreAuthorize("@ss.hasPermi('system:teacher:export')")
@Log(title = "教师信息", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainTeacher trainTeacher)
{
List<TrainTeacher> list = trainTeacherService.selectTrainTeacherList(trainTeacher);
ExcelUtil<TrainTeacher> util = new ExcelUtil<TrainTeacher>(TrainTeacher.class);
util.exportExcel(response, list, "教师信息数据");
}
/**
* 获取教师信息详细信息
*/
@PreAuthorize("@ss.hasPermi('system:teacher:query')")
@PreAuthorize("@ss.hasPermi('train:teacher:query')")
@GetMapping(value = "/{tId}")
public AjaxResult getInfo(@PathVariable("tId") String tId)
{
return success(trainTeacherService.selectTrainTeacherByTId(tId));
TrainTeacher teacher = trainTeacherService.selectTrainTeacherByTId(tId);
if (teacher == null)
{
return AjaxResult.error(40401, "教师不存在");
}
return success(teacher);
}
/**
* 新增教师信息
* 新增教师信息 (POST /train/teacher)
*/
@PreAuthorize("@ss.hasPermi('system:teacher:add')")
@Log(title = "教师信息", businessType = BusinessType.INSERT)
@PreAuthorize("@ss.hasPermi('train:teacher:add')")
@Log(title = "教师管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainTeacher trainTeacher)
{
if (StringUtils.isEmpty(trainTeacher.gettName()))
{
return AjaxResult.error("教师姓名不能为空");
}
if (trainTeacher.gettName().trim().length() > 50)
{
return AjaxResult.error("教师姓名长度不能超过50个字符");
}
if (StringUtils.isNotEmpty(trainTeacher.gettPhone()) && !isValidPhone(trainTeacher.gettPhone()))
{
return AjaxResult.error("手机号码格式不正确");
}
return toAjax(trainTeacherService.insertTrainTeacher(trainTeacher));
}
/**
* 修改教师信息
* 修改教师信息 (PUT /train/teacher/{tId})
*/
@PreAuthorize("@ss.hasPermi('system:teacher:edit')")
@Log(title = "教师信息", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainTeacher trainTeacher)
@PreAuthorize("@ss.hasPermi('train:teacher:edit')")
@Log(title = "教师管理", businessType = BusinessType.UPDATE)
@PutMapping(value = "/{tId}")
public AjaxResult edit(@PathVariable("tId") String tId, @RequestBody TrainTeacher trainTeacher)
{
trainTeacher.settId(tId);
if (StringUtils.isNotEmpty(trainTeacher.gettName()) && trainTeacher.gettName().trim().length() > 50)
{
return AjaxResult.error("教师姓名长度不能超过50个字符");
}
if (StringUtils.isNotEmpty(trainTeacher.gettPhone()) && !isValidPhone(trainTeacher.gettPhone()))
{
return AjaxResult.error("手机号码格式不正确");
}
return toAjax(trainTeacherService.updateTrainTeacher(trainTeacher));
}
/**
* 删除教师信息
*/
@PreAuthorize("@ss.hasPermi('system:teacher:remove')")
@Log(title = "教师信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{tIds}")
public AjaxResult remove(@PathVariable String[] tIds)
private boolean isValidPhone(String phone)
{
return toAjax(trainTeacherService.deleteTrainTeacherByTIds(tIds));
return PHONE_PATTERN.matcher(phone).matches();
}
}
@@ -1,34 +1,33 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainTrainingSite;
import com.ruoyi.train.service.ITrainTrainingSiteService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 实训场地Controller
*
*
* @author ruoyi
* @date 2026-09-08
*/
@RestController
@RequestMapping("/train/site")
@RequestMapping("/train/training-site")
public class TrainTrainingSiteController extends BaseController
{
@Autowired
@@ -37,7 +36,7 @@ public class TrainTrainingSiteController extends BaseController
/**
* 查询实训场地列表
*/
@PreAuthorize("@ss.hasPermi('system:site:list')")
@PreAuthorize("@ss.hasPermi('train:site:list')")
@GetMapping("/list")
public TableDataInfo list(TrainTrainingSite trainTrainingSite)
{
@@ -46,23 +45,10 @@ public class TrainTrainingSiteController extends BaseController
return getDataTable(list);
}
/**
* 导出实训场地列表
*/
@PreAuthorize("@ss.hasPermi('system:site:export')")
@Log(title = "实训场地", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainTrainingSite trainTrainingSite)
{
List<TrainTrainingSite> list = trainTrainingSiteService.selectTrainTrainingSiteList(trainTrainingSite);
ExcelUtil<TrainTrainingSite> util = new ExcelUtil<TrainTrainingSite>(TrainTrainingSite.class);
util.exportExcel(response, list, "实训场地数据");
}
/**
* 获取实训场地详细信息
*/
@PreAuthorize("@ss.hasPermi('system:site:query')")
@PreAuthorize("@ss.hasPermi('train:site:list')")
@GetMapping(value = "/{siteId}")
public AjaxResult getInfo(@PathVariable("siteId") String siteId)
{
@@ -72,33 +58,25 @@ public class TrainTrainingSiteController extends BaseController
/**
* 新增实训场地
*/
@PreAuthorize("@ss.hasPermi('system:site:add')")
@PreAuthorize("@ss.hasPermi('train:site:edit')")
@Log(title = "实训场地", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainTrainingSite trainTrainingSite)
public AjaxResult add(@Validated @RequestBody TrainTrainingSite trainTrainingSite)
{
trainTrainingSite.setCreateBy(SecurityUtils.getUsername());
return toAjax(trainTrainingSiteService.insertTrainTrainingSite(trainTrainingSite));
}
/**
* 修改实训场地
* 修改实训场地 (RESTful PUT /train/training-site/{siteId})
*/
@PreAuthorize("@ss.hasPermi('system:site:edit')")
@PreAuthorize("@ss.hasPermi('train:site:edit')")
@Log(title = "实训场地", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainTrainingSite trainTrainingSite)
@PutMapping("/{siteId}")
public AjaxResult edit(@PathVariable("siteId") String siteId, @Validated @RequestBody TrainTrainingSite trainTrainingSite)
{
trainTrainingSite.setSiteId(siteId);
trainTrainingSite.setUpdateBy(SecurityUtils.getUsername());
return toAjax(trainTrainingSiteService.updateTrainTrainingSite(trainTrainingSite));
}
/**
* 删除实训场地
*/
@PreAuthorize("@ss.hasPermi('system:site:remove')")
@Log(title = "实训场地", businessType = BusinessType.DELETE)
@DeleteMapping("/{siteIds}")
public AjaxResult remove(@PathVariable String[] siteIds)
{
return toAjax(trainTrainingSiteService.deleteTrainTrainingSiteBySiteIds(siteIds));
}
}
+8 -1
View File
@@ -23,6 +23,13 @@
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>
@@ -0,0 +1,23 @@
package com.ruoyi.train.constant;
/** 培训计划正式业务错误码(API Contract V1.0.1)。 */
public final class PlanErrorCode
{
public static final int PLAN_TIME_INVALID = 40010;
public static final int PLAN_NAME_DUPLICATED = 40011;
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_NOT_FOUND = 40410;
public static final String PLAN_TIME_INVALID_MESSAGE = "培训计划起止时间不合法 (结束时间必须晚于开始时间)";
public static final String PLAN_NAME_DUPLICATED_MESSAGE = "同名培训计划已存在";
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_NOT_FOUND_MESSAGE = "培训计划不存在";
private PlanErrorCode()
{
}
}
@@ -24,28 +24,25 @@ public class TrainClassPlan extends BaseEntity
@Excel(name = "开班名称")
private String planName;
/** 所属课程编号,关联train_course.c_id */
@Excel(name = "所属课程编号,关联train_course.c_id")
private String cId;
/** 开班时间 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "开班时间", width = 30, dateFormat = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "开班时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
/** 结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 报到截止时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "报到截止时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
/** 培训地点 */
@Excel(name = "培训地点")
private String trainAddress;
/** 授课教师工号,关联train_teacher.t_id */
@Excel(name = "授课教师工号,关联train_teacher.t_id")
private String tId;
/** 招生人数 */
@Excel(name = "招生人数")
private Long recruitNum;
@@ -58,9 +55,8 @@ public class TrainClassPlan extends BaseEntity
@Excel(name = "0 未开始 1 进行中 2 已结束 3 已取消")
private Integer planStatus;
/** 创建管理员账号 */
@Excel(name = "创建管理员账号")
private String createUser;
/** 删除标志(0代表存在 2代表删除) */
private String delFlag;
public void setPlanId(String planId)
{
@@ -82,16 +78,6 @@ public class TrainClassPlan extends BaseEntity
return planName;
}
public void setcId(String cId)
{
this.cId = cId;
}
public String getcId()
{
return cId;
}
public void setStartTime(Date startTime)
{
this.startTime = startTime;
@@ -112,26 +98,26 @@ public class TrainClassPlan extends BaseEntity
return endTime;
}
public void setTrainAddress(String trainAddress)
public void setReportDeadline(Date reportDeadline)
{
this.reportDeadline = reportDeadline;
}
public Date getReportDeadline()
{
return reportDeadline;
}
public void setTrainAddress(String trainAddress)
{
this.trainAddress = trainAddress;
}
public String getTrainAddress()
public String getTrainAddress()
{
return trainAddress;
}
public void settId(String tId)
{
this.tId = tId;
}
public String gettId()
{
return tId;
}
public void setRecruitNum(Long recruitNum)
{
this.recruitNum = recruitNum;
@@ -162,14 +148,14 @@ public class TrainClassPlan extends BaseEntity
return planStatus;
}
public void setCreateUser(String createUser)
public void setDelFlag(String delFlag)
{
this.createUser = createUser;
this.delFlag = delFlag;
}
public String getCreateUser()
public String getDelFlag()
{
return createUser;
return delFlag;
}
@Override
@@ -177,16 +163,17 @@ public class TrainClassPlan extends BaseEntity
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("planId", getPlanId())
.append("planName", getPlanName())
.append("cId", getcId())
.append("startTime", getStartTime())
.append("endTime", getEndTime())
.append("reportDeadline", getReportDeadline())
.append("trainAddress", getTrainAddress())
.append("tId", gettId())
.append("recruitNum", getRecruitNum())
.append("applyCondition", getApplyCondition())
.append("planStatus", getPlanStatus())
.append("createUser", getCreateUser())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -1,5 +1,9 @@
package com.ruoyi.train.domain;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
@@ -7,7 +11,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/**
* 教室信息对象 train_classroom_info
*
*
* @author ruoyi
* @date 2026-09-08
*/
@@ -19,83 +23,87 @@ public class TrainClassroomInfo extends BaseEntity
private String classRoomId;
/** 教室名称 */
@NotBlank(message = "教室名称不能为空")
@Size(max = 60, message = "教室名称长度不能超过60个字符")
@Excel(name = "教室名称")
private String roomName;
/** 教室位置 */
@Size(max = 100, message = "教室位置长度不能超过100个字符")
@Excel(name = "教室位置")
private String roomLocation;
/** 容纳人数 */
@NotNull(message = "容纳人数不能为空")
@Min(value = 1, message = "容纳人数必须大于0")
@Excel(name = "容纳人数")
private Long capacity;
/** 0 正在使用 1 可用,空闲 2 其他 */
@Excel(name = "0 正在使用 1 可用,空闲 2 其他")
@Excel(name = "教室状态", readConverterExp = "0=正在使用,1=可用空闲,2=其他")
private Integer roomStatus;
/** 创建管理员账号 */
@Excel(name = "创建管理员账号")
private String createUser;
/** 删除标志 (0代表存在 2代表删除) */
private String delFlag;
public void setClassRoomId(String classRoomId)
public void setClassRoomId(String classRoomId)
{
this.classRoomId = classRoomId;
}
public String getClassRoomId()
public String getClassRoomId()
{
return classRoomId;
}
public void setRoomName(String roomName)
public void setRoomName(String roomName)
{
this.roomName = roomName;
}
public String getRoomName()
public String getRoomName()
{
return roomName;
}
public void setRoomLocation(String roomLocation)
public void setRoomLocation(String roomLocation)
{
this.roomLocation = roomLocation;
}
public String getRoomLocation()
public String getRoomLocation()
{
return roomLocation;
}
public void setCapacity(Long capacity)
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public Long getCapacity()
public Long getCapacity()
{
return capacity;
}
public void setRoomStatus(Integer roomStatus)
public void setRoomStatus(Integer roomStatus)
{
this.roomStatus = roomStatus;
}
public Integer getRoomStatus()
public Integer getRoomStatus()
{
return roomStatus;
}
public void setCreateUser(String createUser)
public void setDelFlag(String delFlag)
{
this.createUser = createUser;
this.delFlag = delFlag;
}
public String getCreateUser()
public String getDelFlag()
{
return createUser;
return delFlag;
}
@Override
@@ -106,8 +114,10 @@ public class TrainClassroomInfo extends BaseEntity
.append("roomLocation", getRoomLocation())
.append("capacity", getCapacity())
.append("roomStatus", getRoomStatus())
.append("createUser", getCreateUser())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -2,14 +2,15 @@ package com.ruoyi.train.domain;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 课程信息对象 train_course
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
public class TrainCourse extends BaseEntity
{
@@ -18,6 +19,10 @@ public class TrainCourse extends BaseEntity
/** 课程编号主键 */
private String cId;
/** 课程统一业务编码 */
@Excel(name = "课程统一业务编码")
private String cCode;
/** 课程名称 */
@Excel(name = "课程名称")
private String cName;
@@ -26,163 +31,190 @@ public class TrainCourse extends BaseEntity
@Excel(name = "课程类型")
private String cType;
/** 课程时长 (分钟) */
@Excel(name = "课程时长 (分钟)")
private Long cDuration;
/** 课程学时 */
@Excel(name = "课程学时")
private Integer cDuration;
/** 教学目标 */
@Excel(name = "教学目标")
private String teachingObjective;
/** 课程内容 */
/** 课程内容/摘要 */
@Excel(name = "课程内容")
private String courseContent;
/** 配套文档,多个逗号分隔 */
@Excel(name = "配套文档,多个逗号分隔")
private String attachDoc;
/** 课程状态 (1:正常, 0:停用) */
@Excel(name = "课程状态", readConverterExp = "1=正常,0=停用")
private Integer status;
/** 配套视频地址 */
@Excel(name = "配套视频地址")
private String attachVideo;
/** 删除标志 (0:存在, 2:删除) */
private String delFlag;
/** 配套模型链接 */
@Excel(name = "配套模型链接")
private String attachModelLink;
/** 关联3D设备拆解模型、实训场景资源ID,多ID逗号分隔 */
@Excel(name = "关联3D设备拆解模型、实训场景资源ID,多ID逗号分隔")
private String modelResourceIds;
/** 创建人 (管理员 / 教师) */
@Excel(name = "创建人 (管理员 / 教师)")
private String createUser;
public void setcId(String cId)
@JsonProperty("cId")
public void setcId(String cId)
{
this.cId = cId;
}
public String getcId()
@JsonProperty("cId")
public String getcId()
{
return cId;
}
public void setcName(String cName)
public void setCId(String cId)
{
this.cId = cId;
}
public String getCId()
{
return cId;
}
@JsonProperty("cCode")
public void setcCode(String cCode)
{
this.cCode = cCode;
}
@JsonProperty("cCode")
public String getcCode()
{
return cCode;
}
public void setCCode(String cCode)
{
this.cCode = cCode;
}
public String getCCode()
{
return cCode;
}
@JsonProperty("cName")
public void setcName(String cName)
{
this.cName = cName;
}
public String getcName()
@JsonProperty("cName")
public String getcName()
{
return cName;
}
public void setcType(String cType)
public void setCName(String cName)
{
this.cName = cName;
}
public String getCName()
{
return cName;
}
@JsonProperty("cType")
public void setcType(String cType)
{
this.cType = cType;
}
public String getcType()
@JsonProperty("cType")
public String getcType()
{
return cType;
}
public void setcDuration(Long cDuration)
public void setCType(String cType)
{
this.cType = cType;
}
public String getCType()
{
return cType;
}
@JsonProperty("cDuration")
public void setcDuration(Integer cDuration)
{
this.cDuration = cDuration;
}
public Long getcDuration()
@JsonProperty("cDuration")
public Integer getcDuration()
{
return cDuration;
}
public void setTeachingObjective(String teachingObjective)
public void setCDuration(Integer cDuration)
{
this.cDuration = cDuration;
}
public Integer getCDuration()
{
return cDuration;
}
public void setTeachingObjective(String teachingObjective)
{
this.teachingObjective = teachingObjective;
}
public String getTeachingObjective()
public String getTeachingObjective()
{
return teachingObjective;
}
public void setCourseContent(String courseContent)
public void setCourseContent(String courseContent)
{
this.courseContent = courseContent;
}
public String getCourseContent()
public String getCourseContent()
{
return courseContent;
}
public void setAttachDoc(String attachDoc)
public void setStatus(Integer status)
{
this.attachDoc = attachDoc;
this.status = status;
}
public String getAttachDoc()
public Integer getStatus()
{
return attachDoc;
return status;
}
public void setAttachVideo(String attachVideo)
public void setDelFlag(String delFlag)
{
this.attachVideo = attachVideo;
this.delFlag = delFlag;
}
public String getAttachVideo()
public String getDelFlag()
{
return attachVideo;
}
public void setAttachModelLink(String attachModelLink)
{
this.attachModelLink = attachModelLink;
}
public String getAttachModelLink()
{
return attachModelLink;
}
public void setModelResourceIds(String modelResourceIds)
{
this.modelResourceIds = modelResourceIds;
}
public String getModelResourceIds()
{
return modelResourceIds;
}
public void setCreateUser(String createUser)
{
this.createUser = createUser;
}
public String getCreateUser()
{
return createUser;
return delFlag;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("cId", getcId())
.append("cCode", getcCode())
.append("cName", getcName())
.append("cType", getcType())
.append("cDuration", getcDuration())
.append("teachingObjective", getTeachingObjective())
.append("courseContent", getCourseContent())
.append("attachDoc", getAttachDoc())
.append("attachVideo", getAttachVideo())
.append("attachModelLink", getAttachModelLink())
.append("modelResourceIds", getModelResourceIds())
.append("createUser", getCreateUser())
.append("status", getStatus())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -9,15 +9,15 @@ import com.ruoyi.common.core.domain.BaseEntity;
/**
* 教师信息对象 train_teacher
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
public class TrainTeacher extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 教师工号 */
/** 教师工号/标识 */
private String tId;
/** 教师姓名 */
@@ -25,7 +25,7 @@ public class TrainTeacher extends BaseEntity
private String tName;
/** 性别:男 / 女 */
@Excel(name = "性别:男 / 女")
@Excel(name = "性别")
private String tGender;
/** 出生日期 */
@@ -33,13 +33,16 @@ public class TrainTeacher extends BaseEntity
@Excel(name = "出生日期", width = 30, dateFormat = "yyyy-MM-dd")
private Date tBirth;
/** 所属院系 */
@Excel(name = "所属院系")
private String tDepartment;
/** sys_user登录账号逻辑关联ID */
private Long userId;
/** 职称:讲师 / 副教授 / 教授 */
@Excel(name = "职称:讲师 / 副教授 / 教授")
private String tTitle;
/** 所属单位/部门名称 (由旧 t_department 迁移) */
@Excel(name = "所属单位")
private String orgName;
/** 职务/职称 (由旧 t_title 迁移) */
@Excel(name = "职务/职称")
private String jobTitle;
/** 联系电话 */
@Excel(name = "联系电话")
@@ -49,98 +52,121 @@ public class TrainTeacher extends BaseEntity
@Excel(name = "邮箱")
private String tEmail;
/** 创建人 (管理员 / 教师) */
@Excel(name = "创建人 (管理员 / 教师)")
private String createUser;
/** 状态 (1:正常, 0:停用) */
@Excel(name = "状态", readConverterExp = "1=正常,0=停用")
private Integer status;
public void settId(String tId)
/** 删除标志 (0:存在, 2:删除) */
private String delFlag;
public void settId(String tId)
{
this.tId = tId;
}
public String gettId()
public String gettId()
{
return tId;
}
public void settName(String tName)
public void settName(String tName)
{
this.tName = tName;
}
public String gettName()
public String gettName()
{
return tName;
}
public void settGender(String tGender)
public void settGender(String tGender)
{
this.tGender = tGender;
}
public String gettGender()
public String gettGender()
{
return tGender;
}
public void settBirth(Date tBirth)
public void settBirth(Date tBirth)
{
this.tBirth = tBirth;
}
public Date gettBirth()
public Date gettBirth()
{
return tBirth;
}
public void settDepartment(String tDepartment)
public void setUserId(Long userId)
{
this.tDepartment = tDepartment;
this.userId = userId;
}
public String gettDepartment()
public Long getUserId()
{
return tDepartment;
return userId;
}
public void settTitle(String tTitle)
public void setOrgName(String orgName)
{
this.tTitle = tTitle;
this.orgName = orgName;
}
public String gettTitle()
public String getOrgName()
{
return tTitle;
return orgName;
}
public void settPhone(String tPhone)
public void setJobTitle(String jobTitle)
{
this.jobTitle = jobTitle;
}
public String getJobTitle()
{
return jobTitle;
}
public void settPhone(String tPhone)
{
this.tPhone = tPhone;
}
public String gettPhone()
public String gettPhone()
{
return tPhone;
}
public void settEmail(String tEmail)
public void settEmail(String tEmail)
{
this.tEmail = tEmail;
}
public String gettEmail()
public String gettEmail()
{
return tEmail;
}
public void setCreateUser(String createUser)
public void setStatus(Integer status)
{
this.createUser = createUser;
this.status = status;
}
public String getCreateUser()
public Integer getStatus()
{
return createUser;
return status;
}
public void setDelFlag(String delFlag)
{
this.delFlag = delFlag;
}
public String getDelFlag()
{
return delFlag;
}
@Override
@@ -150,12 +176,16 @@ public class TrainTeacher extends BaseEntity
.append("tName", gettName())
.append("tGender", gettGender())
.append("tBirth", gettBirth())
.append("tDepartment", gettDepartment())
.append("tTitle", gettTitle())
.append("userId", getUserId())
.append("orgName", getOrgName())
.append("jobTitle", getJobTitle())
.append("tPhone", gettPhone())
.append("tEmail", gettEmail())
.append("createUser", getCreateUser())
.append("status", getStatus())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -1,5 +1,9 @@
package com.ruoyi.train.domain;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
@@ -7,7 +11,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/**
* 实训场地对象 train_training_site
*
*
* @author ruoyi
* @date 2026-09-08
*/
@@ -19,14 +23,19 @@ public class TrainTrainingSite extends BaseEntity
private String siteId;
/** 实训场地名称 */
@NotBlank(message = "实训场地名称不能为空")
@Size(max = 80, message = "实训场地名称长度不能超过80个字符")
@Excel(name = "实训场地名称")
private String siteName;
/** 场地位置 */
@Size(max = 100, message = "场地位置长度不能超过100个字符")
@Excel(name = "场地位置")
private String siteLocation;
/** 最大容纳人数 */
@NotNull(message = "最大容纳人数不能为空")
@Min(value = 1, message = "最大容纳人数必须大于0")
@Excel(name = "最大容纳人数")
private Long capacity;
@@ -35,81 +44,80 @@ public class TrainTrainingSite extends BaseEntity
private String siteDevice;
/** 0 停用 1 可用 */
@Excel(name = "0 停用 1 可用")
@Excel(name = "场地状态", readConverterExp = "0=停用,1=可用")
private Integer siteStatus;
/** 创建管理员账号 */
@Excel(name = "创建管理员账号")
private String createUser;
/** 删除标志 (0代表存在 2代表删除) */
private String delFlag;
public void setSiteId(String siteId)
public void setSiteId(String siteId)
{
this.siteId = siteId;
}
public String getSiteId()
public String getSiteId()
{
return siteId;
}
public void setSiteName(String siteName)
public void setSiteName(String siteName)
{
this.siteName = siteName;
}
public String getSiteName()
public String getSiteName()
{
return siteName;
}
public void setSiteLocation(String siteLocation)
public void setSiteLocation(String siteLocation)
{
this.siteLocation = siteLocation;
}
public String getSiteLocation()
public String getSiteLocation()
{
return siteLocation;
}
public void setCapacity(Long capacity)
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public Long getCapacity()
public Long getCapacity()
{
return capacity;
}
public void setSiteDevice(String siteDevice)
public void setSiteDevice(String siteDevice)
{
this.siteDevice = siteDevice;
}
public String getSiteDevice()
public String getSiteDevice()
{
return siteDevice;
}
public void setSiteStatus(Integer siteStatus)
public void setSiteStatus(Integer siteStatus)
{
this.siteStatus = siteStatus;
}
public Integer getSiteStatus()
public Integer getSiteStatus()
{
return siteStatus;
}
public void setCreateUser(String createUser)
public void setDelFlag(String delFlag)
{
this.createUser = createUser;
this.delFlag = delFlag;
}
public String getCreateUser()
public String getDelFlag()
{
return createUser;
return delFlag;
}
@Override
@@ -121,8 +129,10 @@ public class TrainTrainingSite extends BaseEntity
.append("capacity", getCapacity())
.append("siteDevice", getSiteDevice())
.append("siteStatus", getSiteStatus())
.append("createUser", getCreateUser())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -0,0 +1,88 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 教室创建 DTO (ISSUE-01-04 / API Contract V1.0.1)
*
* @author ruoyi
*/
public class ClassroomCreateDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 教室名称 */
@NotBlank(message = "教室名称不能为空")
@Size(max = 60, message = "教室名称长度不能超过60个字符")
private String roomName;
/** 教室位置 */
@Size(max = 100, message = "教室位置长度不能超过100个字符")
private String roomLocation;
/** 核定容纳人数 */
@NotNull(message = "容纳人数不能为空")
@Min(value = 1, message = "容纳人数必须大于0")
private Long capacity;
/** 教室状态 (0: 正在使用, 1: 可用空闲, 2: 维护中) */
private Integer roomStatus;
/** 备注 */
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
public String getRoomName()
{
return roomName;
}
public void setRoomName(String roomName)
{
this.roomName = roomName;
}
public String getRoomLocation()
{
return roomLocation;
}
public void setRoomLocation(String roomLocation)
{
this.roomLocation = roomLocation;
}
public Long getCapacity()
{
return capacity;
}
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public Integer getRoomStatus()
{
return roomStatus;
}
public void setRoomStatus(Integer roomStatus)
{
this.roomStatus = roomStatus;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.dto;
import java.util.Date;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 创建培训计划请求。 */
public class PlanCreateDTO
{
@NotBlank(message = "培训班名称不能为空")
@Size(max = 150, message = "培训班名称不能超过150个字符")
private String planName;
@NotNull(message = "开班时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@NotNull(message = "结束时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@NotNull(message = "报到截止时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
@NotNull(message = "招生人数不能为空")
@Min(value = 1, message = "招生人数必须大于0")
private Long recruitNum;
@Size(max = 200, message = "培训地点不能超过200个字符")
private String trainAddress;
private String applyCondition;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.dto;
import java.util.Date;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 编辑培训计划请求。生命周期状态不接受客户端直接修改。 */
public class PlanUpdateDTO
{
@NotBlank(message = "培训班名称不能为空")
@Size(max = 150, message = "培训班名称不能超过150个字符")
private String planName;
@NotNull(message = "开班时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@NotNull(message = "结束时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@NotNull(message = "报到截止时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
@NotNull(message = "招生人数不能为空")
@Min(value = 1, message = "招生人数必须大于0")
private Long recruitNum;
@Size(max = 200, message = "培训地点不能超过200个字符")
private String trainAddress;
private String applyCondition;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
}
@@ -0,0 +1,101 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 实训场地创建 DTO (ISSUE-01-04 / API Contract V1.0.1)
*
* @author ruoyi
*/
public class SiteCreateDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 实训场地名称 */
@NotBlank(message = "实训场地名称不能为空")
@Size(max = 80, message = "实训场地名称长度不能超过80个字符")
private String siteName;
/** 场地位置 */
@Size(max = 100, message = "场地位置长度不能超过100个字符")
private String siteLocation;
/** 最大容纳人数 */
@NotNull(message = "最大容纳人数不能为空")
@Min(value = 1, message = "最大容纳人数必须大于0")
private Long capacity;
/** 场地配套设备:VR、3D拆解、3D巡检设备等 */
private String siteDevice;
/** 场地状态 (0: 停用, 1: 可用) */
private Integer siteStatus;
/** 备注 */
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
public String getSiteName()
{
return siteName;
}
public void setSiteName(String siteName)
{
this.siteName = siteName;
}
public String getSiteLocation()
{
return siteLocation;
}
public void setSiteLocation(String siteLocation)
{
this.siteLocation = siteLocation;
}
public Long getCapacity()
{
return capacity;
}
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public String getSiteDevice()
{
return siteDevice;
}
public void setSiteDevice(String siteDevice)
{
this.siteDevice = siteDevice;
}
public Integer getSiteStatus()
{
return siteStatus;
}
public void setSiteStatus(Integer siteStatus)
{
this.siteStatus = siteStatus;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,26 @@
package com.ruoyi.train.domain.query;
import java.util.Date;
import org.springframework.format.annotation.DateTimeFormat;
/** 培训计划分页筛选条件。分页参数由 RuoYi BaseController 接管。 */
public class PlanQuery
{
private String planName;
private Integer planStatus;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTimeBegin;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTimeEnd;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTimeBegin() { return startTimeBegin; }
public void setStartTimeBegin(Date startTimeBegin) { this.startTimeBegin = startTimeBegin; }
public Date getStartTimeEnd() { return startTimeEnd; }
public void setStartTimeEnd(Date startTimeEnd) { this.startTimeEnd = startTimeEnd; }
}
@@ -0,0 +1,63 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
/**
* 教室绑定的电子班牌轻量摘要视图 (API Contract V1.0.2 / CG-05 闭环)
*
* @author ruoyi
*/
public class ClassroomBoardSummaryVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 班牌设备唯一标识 */
private String boardId;
/** 设备硬件序列号 */
private String boardSn;
/** 班牌业务状态 (0: 离线, 1: 在线正常) */
private Integer boardStatus;
public ClassroomBoardSummaryVO()
{
}
public ClassroomBoardSummaryVO(String boardId, String boardSn, Integer boardStatus)
{
this.boardId = boardId;
this.boardSn = boardSn;
this.boardStatus = boardStatus;
}
public String getBoardId()
{
return boardId;
}
public void setBoardId(String boardId)
{
this.boardId = boardId;
}
public String getBoardSn()
{
return boardSn;
}
public void setBoardSn(String boardSn)
{
this.boardSn = boardSn;
}
public Integer getBoardStatus()
{
return boardStatus;
}
public void setBoardStatus(Integer boardStatus)
{
this.boardStatus = boardStatus;
}
}
@@ -0,0 +1,122 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 教室详情 VO (API Contract V1.0.2 / CG-05 闭环)
*
* @author ruoyi
*/
public class ClassroomDetailVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 教室ID主键 */
private String classRoomId;
/** 教室名称 */
private String roomName;
/** 教室位置 */
private String roomLocation;
/** 核定容纳人数 */
private Long capacity;
/** 教室状态 (0: 正在使用, 1: 可用空闲, 2: 维护中) */
private Integer roomStatus;
/** 绑定的班牌摘要列表。无绑定班牌时返回 [],不得返回 null */
private List<ClassroomBoardSummaryVO> boards = new ArrayList<>();
/** 备注 */
private String remark;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public String getClassRoomId()
{
return classRoomId;
}
public void setClassRoomId(String classRoomId)
{
this.classRoomId = classRoomId;
}
public String getRoomName()
{
return roomName;
}
public void setRoomName(String roomName)
{
this.roomName = roomName;
}
public String getRoomLocation()
{
return roomLocation;
}
public void setRoomLocation(String roomLocation)
{
this.roomLocation = roomLocation;
}
public Long getCapacity()
{
return capacity;
}
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public Integer getRoomStatus()
{
return roomStatus;
}
public void setRoomStatus(Integer roomStatus)
{
this.roomStatus = roomStatus;
}
public List<ClassroomBoardSummaryVO> getBoards()
{
return boards == null ? new ArrayList<>() : boards;
}
public void setBoards(List<ClassroomBoardSummaryVO> boards)
{
this.boards = (boards == null) ? new ArrayList<>() : boards;
}
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,298 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.train.domain.TrainCourse;
/**
* 课程详情视图对象 (API Contract: CourseDetailVO)
* 聚合返回课程基础信息及已绑定的教学资源列表
*
* @author ruoyi
* @date 2026-09-14
*/
public class CourseDetailVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 课程编号主键 */
private String cId;
/** 课程统一业务编码 */
private String cCode;
/** 课程名称 */
private String cName;
/** 课程类型 */
private String cType;
/** 课程学时 */
private Integer cDuration;
/** 教学目标 */
private String teachingObjective;
/** 课程内容/摘要 */
private String courseContent;
/** 课程状态 (1:正常, 0:停用) */
private Integer status;
/** 备注 */
private String remark;
/** 创建者 */
private String createBy;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 更新者 */
private String updateBy;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/**
* 已关联的教学资源列表
* 注:当前 ISSUE-01-03 未实现真实 Course<->Resource 查询;
* 绑定/解绑及真实关联能力属于 P1。为保证集合字段结构稳定,默认返回空列表 (empty list)。
*/
private List<Object> resources;
public CourseDetailVO()
{
}
public static CourseDetailVO from(TrainCourse course)
{
if (course == null)
{
return null;
}
CourseDetailVO vo = new CourseDetailVO();
vo.setcId(course.getcId());
vo.setcCode(course.getcCode());
vo.setcName(course.getcName());
vo.setcType(course.getcType());
vo.setcDuration(course.getcDuration());
vo.setTeachingObjective(course.getTeachingObjective());
vo.setCourseContent(course.getCourseContent());
vo.setStatus(course.getStatus());
vo.setRemark(course.getRemark());
vo.setCreateBy(course.getCreateBy());
vo.setCreateTime(course.getCreateTime());
vo.setUpdateBy(course.getUpdateBy());
vo.setUpdateTime(course.getUpdateTime());
// 资源绑定/解绑能力属于 P1,当前返回空列表保持响应结构稳定
vo.setResources(Collections.emptyList());
return vo;
}
@JsonProperty("cId")
public String getcId()
{
return cId;
}
@JsonProperty("cId")
public void setcId(String cId)
{
this.cId = cId;
}
public String getCId()
{
return cId;
}
public void setCId(String cId)
{
this.cId = cId;
}
@JsonProperty("cCode")
public String getcCode()
{
return cCode;
}
@JsonProperty("cCode")
public void setcCode(String cCode)
{
this.cCode = cCode;
}
public String getCCode()
{
return cCode;
}
public void setCCode(String cCode)
{
this.cCode = cCode;
}
@JsonProperty("cName")
public String getcName()
{
return cName;
}
@JsonProperty("cName")
public void setcName(String cName)
{
this.cName = cName;
}
public String getCName()
{
return cName;
}
public void setCName(String cName)
{
this.cName = cName;
}
@JsonProperty("cType")
public String getcType()
{
return cType;
}
@JsonProperty("cType")
public void setcType(String cType)
{
this.cType = cType;
}
public String getCType()
{
return cType;
}
public void setCType(String cType)
{
this.cType = cType;
}
@JsonProperty("cDuration")
public Integer getcDuration()
{
return cDuration;
}
@JsonProperty("cDuration")
public void setcDuration(Integer cDuration)
{
this.cDuration = cDuration;
}
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 String getCourseContent()
{
return courseContent;
}
public void setCourseContent(String courseContent)
{
this.courseContent = courseContent;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
public String getCreateBy()
{
return createBy;
}
public void setCreateBy(String createBy)
{
this.createBy = createBy;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
public String getUpdateBy()
{
return updateBy;
}
public void setUpdateBy(String updateBy)
{
this.updateBy = updateBy;
}
public Date getUpdateTime()
{
return updateTime;
}
public void setUpdateTime(Date updateTime)
{
this.updateTime = updateTime;
}
public List<Object> getResources()
{
return resources;
}
public void setResources(List<Object> resources)
{
this.resources = resources;
}
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 培训计划基本信息视图。 */
public class PlanDetailVO
{
private String planId;
private String planName;
private Integer planStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
private String trainAddress;
private Long recruitNum;
private String applyCondition;
private String remark;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getUpdateBy() { return updateBy; }
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}
@@ -0,0 +1,43 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 培训计划列表项。 */
public class PlanItemVO
{
private String planId;
private String planName;
private Integer planStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
private String trainAddress;
private Long recruitNum;
private Long courseCount;
private Long enrolledCount;
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public Long getCourseCount() { return courseCount; }
public void setCourseCount(Long courseCount) { this.courseCount = courseCount; }
public Long getEnrolledCount() { return enrolledCount; }
public void setEnrolledCount(Long enrolledCount) { this.enrolledCount = enrolledCount; }
}
@@ -0,0 +1,120 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 实训场地详情 VO (ISSUE-01-04 / API Contract V1.0.1)
*
* @author ruoyi
*/
public class SiteDetailVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 实训场地ID主键 */
private String siteId;
/** 实训场地名称 */
private String siteName;
/** 场地位置 */
private String siteLocation;
/** 最大容纳人数 */
private Long capacity;
/** 场地配套设备:VR、3D拆解、3D巡检设备等 */
private String siteDevice;
/** 场地状态 (0: 停用, 1: 可用) */
private Integer siteStatus;
/** 备注 */
private String remark;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public String getSiteId()
{
return siteId;
}
public void setSiteId(String siteId)
{
this.siteId = siteId;
}
public String getSiteName()
{
return siteName;
}
public void setSiteName(String siteName)
{
this.siteName = siteName;
}
public String getSiteLocation()
{
return siteLocation;
}
public void setSiteLocation(String siteLocation)
{
this.siteLocation = siteLocation;
}
public Long getCapacity()
{
return capacity;
}
public void setCapacity(Long capacity)
{
this.capacity = capacity;
}
public String getSiteDevice()
{
return siteDevice;
}
public void setSiteDevice(String siteDevice)
{
this.siteDevice = siteDevice;
}
public Integer getSiteStatus()
{
return siteStatus;
}
public void setSiteStatus(Integer siteStatus)
{
this.siteStatus = siteStatus;
}
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;
}
}
@@ -2,6 +2,9 @@ package com.ruoyi.train.mapper;
import java.util.List;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
import org.apache.ibatis.annotations.Param;
/**
* 开班计划Mapper接口
@@ -19,13 +22,24 @@ public interface TrainClassPlanMapper
*/
public TrainClassPlan selectTrainClassPlanByPlanId(String planId);
/**
* 查询并锁定培训计划,供生命周期动作使用。
*/
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId);
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划集合
*/
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan);
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query);
/**
* 按规范化名称查询重复计划。
*/
public int countTrainClassPlanByName(@Param("planName") String planName,
@Param("excludePlanId") String excludePlanId);
/**
* 新增开班计划
@@ -44,18 +58,7 @@ public interface TrainClassPlanMapper
public int updateTrainClassPlan(TrainClassPlan trainClassPlan);
/**
* 删除开班计划
*
* @param planId 开班计划主键
* @return 结果
* 更新生命周期状态。
*/
public int deleteTrainClassPlanByPlanId(String planId);
/**
* 批量删除开班计划
*
* @param planIds 需要删除的数据主键集合
* @return 结果
*/
public int deleteTrainClassPlanByPlanIds(String[] planIds);
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan);
}
@@ -1,19 +1,20 @@
package com.ruoyi.train.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainClassroomInfo;
/**
* 教室信息Mapper接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface TrainClassroomInfoMapper
public interface TrainClassroomInfoMapper
{
/**
* 查询教室信息
*
*
* @param classRoomId 教室信息主键
* @return 教室信息
*/
@@ -21,15 +22,24 @@ public interface TrainClassroomInfoMapper
/**
* 查询教室信息列表
*
*
* @param trainClassroomInfo 教室信息
* @return 教室信息集合
*/
public List<TrainClassroomInfo> selectTrainClassroomInfoList(TrainClassroomInfo trainClassroomInfo);
/**
* 校验教室名称是否唯一
*
* @param roomName 教室名称
* @param excludeClassRoomId 排除的教室ID (修改时传,新增时传null)
* @return 教室信息
*/
public TrainClassroomInfo checkRoomNameUnique(@Param("roomName") String roomName, @Param("excludeClassRoomId") String excludeClassRoomId);
/**
* 新增教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@@ -37,7 +47,7 @@ public interface TrainClassroomInfoMapper
/**
* 修改教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@@ -45,7 +55,7 @@ public interface TrainClassroomInfoMapper
/**
* 删除教室信息
*
*
* @param classRoomId 教室信息主键
* @return 结果
*/
@@ -53,7 +63,7 @@ public interface TrainClassroomInfoMapper
/**
* 批量删除教室信息
*
*
* @param classRoomIds 需要删除的数据主键集合
* @return 结果
*/
@@ -5,15 +5,15 @@ import com.ruoyi.train.domain.TrainCourse;
/**
* 课程信息Mapper接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface TrainCourseMapper
public interface TrainCourseMapper
{
/**
* 查询课程信息
*
*
* @param cId 课程信息主键
* @return 课程信息
*/
@@ -21,7 +21,7 @@ public interface TrainCourseMapper
/**
* 查询课程信息列表
*
*
* @param trainCourse 课程信息
* @return 课程信息集合
*/
@@ -29,7 +29,7 @@ public interface TrainCourseMapper
/**
* 新增课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@@ -37,7 +37,7 @@ public interface TrainCourseMapper
/**
* 修改课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@@ -45,15 +45,31 @@ public interface TrainCourseMapper
/**
* 删除课程信息
*
*
* @param cId 课程信息主键
* @return 结果
*/
public int deleteTrainCourseByCId(String cId);
/**
* 校验课程统一业务编码是否唯一
*
* @param cCode 课程统一业务编码
* @return 课程信息
*/
public TrainCourse checkCCodeUnique(String cCode);
/**
* 根据课程统一业务编码查询课程信息
*
* @param cCode 课程统一业务编码
* @return 课程信息
*/
public TrainCourse selectTrainCourseByCCode(String cCode);
/**
* 批量删除课程信息
*
*
* @param cIds 需要删除的数据主键集合
* @return 结果
*/
@@ -5,15 +5,15 @@ import com.ruoyi.train.domain.TrainTeacher;
/**
* 教师信息Mapper接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface TrainTeacherMapper
public interface TrainTeacherMapper
{
/**
* 查询教师信息
*
*
* @param tId 教师信息主键
* @return 教师信息
*/
@@ -21,7 +21,7 @@ public interface TrainTeacherMapper
/**
* 查询教师信息列表
*
*
* @param trainTeacher 教师信息
* @return 教师信息集合
*/
@@ -29,7 +29,7 @@ public interface TrainTeacherMapper
/**
* 新增教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
@@ -37,15 +37,23 @@ public interface TrainTeacherMapper
/**
* 修改教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
public int updateTrainTeacher(TrainTeacher trainTeacher);
/**
* 根据手机号码查询教师(用于全局查重)
*
* @param tPhone 手机号码
* @return 教师信息
*/
public TrainTeacher checkPhoneUnique(String tPhone);
/**
* 删除教师信息
*
*
* @param tId 教师信息主键
* @return 结果
*/
@@ -53,7 +61,7 @@ public interface TrainTeacherMapper
/**
* 批量删除教师信息
*
*
* @param tIds 需要删除的数据主键集合
* @return 结果
*/
@@ -1,19 +1,20 @@
package com.ruoyi.train.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainTrainingSite;
/**
* 实训场地Mapper接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface TrainTrainingSiteMapper
public interface TrainTrainingSiteMapper
{
/**
* 查询实训场地
*
*
* @param siteId 实训场地主键
* @return 实训场地
*/
@@ -21,15 +22,24 @@ public interface TrainTrainingSiteMapper
/**
* 查询实训场地列表
*
*
* @param trainTrainingSite 实训场地
* @return 实训场地集合
*/
public List<TrainTrainingSite> selectTrainTrainingSiteList(TrainTrainingSite trainTrainingSite);
/**
* 校验实训场地名称是否唯一
*
* @param siteName 实训场地名称
* @param excludeSiteId 排除的场地ID (修改时传,新增时传null)
* @return 实训场地信息
*/
public TrainTrainingSite checkSiteNameUnique(@Param("siteName") String siteName, @Param("excludeSiteId") String excludeSiteId);
/**
* 新增实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@@ -37,7 +47,7 @@ public interface TrainTrainingSiteMapper
/**
* 修改实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@@ -45,7 +55,7 @@ public interface TrainTrainingSiteMapper
/**
* 删除实训场地
*
*
* @param siteId 实训场地主键
* @return 结果
*/
@@ -53,7 +63,7 @@ public interface TrainTrainingSiteMapper
/**
* 批量删除实训场地
*
*
* @param siteIds 需要删除的数据主键集合
* @return 结果
*/
@@ -2,6 +2,8 @@ package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
/**
* 开班计划Service接口
@@ -19,13 +21,17 @@ public interface ITrainClassPlanService
*/
public TrainClassPlan selectTrainClassPlanByPlanId(String planId);
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId);
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划集合
*/
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan);
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query);
public boolean existsByPlanName(String planName, String excludePlanId);
/**
* 新增开班计划
@@ -43,19 +49,5 @@ public interface ITrainClassPlanService
*/
public int updateTrainClassPlan(TrainClassPlan trainClassPlan);
/**
* 批量删除开班计划
*
* @param planIds 需要删除的开班计划主键集合
* @return 结果
*/
public int deleteTrainClassPlanByPlanIds(String[] planIds);
/**
* 删除开班计划信息
*
* @param planId 开班计划主键
* @return 结果
*/
public int deleteTrainClassPlanByPlanId(String planId);
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan);
}
@@ -2,18 +2,27 @@ package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainClassroomInfo;
import com.ruoyi.train.domain.vo.ClassroomDetailVO;
/**
* 教室信息Service接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface ITrainClassroomInfoService
public interface ITrainClassroomInfoService
{
/**
* 查询教室信息
*
* 查询教室详情(聚合电子班牌信息,供API详情接口使用)
*
* @param classRoomId 教室信息主键
* @return 教室详情VO
*/
public ClassroomDetailVO selectClassroomDetailByClassRoomId(String classRoomId);
/**
* 查询教室基础信息
*
* @param classRoomId 教室信息主键
* @return 教室信息
*/
@@ -21,15 +30,23 @@ public interface ITrainClassroomInfoService
/**
* 查询教室信息列表
*
*
* @param trainClassroomInfo 教室信息
* @return 教室信息集合
*/
public List<TrainClassroomInfo> selectTrainClassroomInfoList(TrainClassroomInfo trainClassroomInfo);
/**
* 校验教室名称是否唯一
*
* @param trainClassroomInfo 教室信息
* @return 结果 (true 唯一, false 不唯一)
*/
public boolean checkRoomNameUnique(TrainClassroomInfo trainClassroomInfo);
/**
* 新增教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@@ -37,7 +54,7 @@ public interface ITrainClassroomInfoService
/**
* 修改教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@@ -45,7 +62,7 @@ public interface ITrainClassroomInfoService
/**
* 批量删除教室信息
*
*
* @param classRoomIds 需要删除的教室信息主键集合
* @return 结果
*/
@@ -53,7 +70,7 @@ public interface ITrainClassroomInfoService
/**
* 删除教室信息信息
*
*
* @param classRoomId 教室信息主键
* @return 结果
*/
@@ -2,18 +2,27 @@ package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.vo.CourseDetailVO;
/**
* 课程信息Service接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface ITrainCourseService
public interface ITrainCourseService
{
/**
* 查询课程详情视图 (CourseDetailVO)
*
* @param cId 课程信息主键
* @return 课程详情VO
*/
public CourseDetailVO selectCourseDetailByCId(String cId);
/**
* 查询课程信息
*
*
* @param cId 课程信息主键
* @return 课程信息
*/
@@ -21,7 +30,7 @@ public interface ITrainCourseService
/**
* 查询课程信息列表
*
*
* @param trainCourse 课程信息
* @return 课程信息集合
*/
@@ -29,7 +38,7 @@ public interface ITrainCourseService
/**
* 新增课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@@ -37,7 +46,7 @@ public interface ITrainCourseService
/**
* 修改课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@@ -45,15 +54,31 @@ public interface ITrainCourseService
/**
* 批量删除课程信息
*
*
* @param cIds 需要删除的课程信息主键集合
* @return 结果
*/
public int deleteTrainCourseByCIds(String[] cIds);
/**
* 校验课程统一业务编码是否唯一
*
* @param trainCourse 课程信息
* @return 结果
*/
public boolean checkCCodeUnique(TrainCourse trainCourse);
/**
* 根据课程统一业务编码查询课程信息
*
* @param cCode 课程统一业务编码
* @return 课程信息
*/
public TrainCourse selectTrainCourseByCCode(String cCode);
/**
* 删除课程信息信息
*
*
* @param cId 课程信息主键
* @return 结果
*/
@@ -5,15 +5,15 @@ import com.ruoyi.train.domain.TrainTeacher;
/**
* 教师信息Service接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface ITrainTeacherService
public interface ITrainTeacherService
{
/**
* 查询教师信息
*
*
* @param tId 教师信息主键
* @return 教师信息
*/
@@ -21,7 +21,7 @@ public interface ITrainTeacherService
/**
* 查询教师信息列表
*
*
* @param trainTeacher 教师信息
* @return 教师信息集合
*/
@@ -29,7 +29,7 @@ public interface ITrainTeacherService
/**
* 新增教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
@@ -37,15 +37,23 @@ public interface ITrainTeacherService
/**
* 修改教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
public int updateTrainTeacher(TrainTeacher trainTeacher);
/**
* 校验教师手机号码是否唯一
*
* @param trainTeacher 教师信息
* @return 结果
*/
public boolean checkPhoneUnique(TrainTeacher trainTeacher);
/**
* 批量删除教师信息
*
*
* @param tIds 需要删除的教师信息主键集合
* @return 结果
*/
@@ -53,7 +61,7 @@ public interface ITrainTeacherService
/**
* 删除教师信息信息
*
*
* @param tId 教师信息主键
* @return 结果
*/
@@ -5,15 +5,15 @@ import com.ruoyi.train.domain.TrainTrainingSite;
/**
* 实训场地Service接口
*
*
* @author ruoyi
* @date 2026-09-08
*/
public interface ITrainTrainingSiteService
public interface ITrainTrainingSiteService
{
/**
* 查询实训场地
*
*
* @param siteId 实训场地主键
* @return 实训场地
*/
@@ -21,15 +21,23 @@ public interface ITrainTrainingSiteService
/**
* 查询实训场地列表
*
*
* @param trainTrainingSite 实训场地
* @return 实训场地集合
*/
public List<TrainTrainingSite> selectTrainTrainingSiteList(TrainTrainingSite trainTrainingSite);
/**
* 校验实训场地名称是否唯一
*
* @param trainTrainingSite 实训场地
* @return 结果 (true 唯一, false 不唯一)
*/
public boolean checkSiteNameUnique(TrainTrainingSite trainTrainingSite);
/**
* 新增实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@@ -37,7 +45,7 @@ public interface ITrainTrainingSiteService
/**
* 修改实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@@ -45,7 +53,7 @@ public interface ITrainTrainingSiteService
/**
* 批量删除实训场地
*
*
* @param siteIds 需要删除的实训场地主键集合
* @return 结果
*/
@@ -53,7 +61,7 @@ public interface ITrainTrainingSiteService
/**
* 删除实训场地信息
*
*
* @param siteId 实训场地主键
* @return 结果
*/
@@ -0,0 +1,22 @@
package com.ruoyi.train.service;
import java.util.List;
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.PlanDetailVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
/** 培训计划生命周期应用服务。 */
public interface PlanAppService
{
List<PlanItemVO> listPlans(PlanQuery query);
PlanDetailVO getPlan(String planId);
String createPlan(PlanCreateDTO dto, String operator);
void updatePlan(String planId, PlanUpdateDTO dto, String operator);
void completePlan(String planId, String operator);
}
@@ -0,0 +1,230 @@
package com.ruoyi.train.service.impl;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
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.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
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.PlanAppService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
/** 培训计划生命周期应用服务实现。 */
@Service
public class PlanAppServiceImpl implements PlanAppService
{
private static final int STATUS_NOT_STARTED = 0;
private static final int STATUS_COMPLETED = 2;
private static final int STATUS_CANCELLED = 3;
private final ITrainClassPlanService planService;
private final List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts;
@Autowired
public PlanAppServiceImpl(ITrainClassPlanService planService,
ObjectProvider<PlanEnrollmentCompletionPort> enrollmentCompletionPortProvider)
{
this(planService, 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 = planService;
this.enrollmentCompletionPorts = enrollmentCompletionPorts == null
? Collections.emptyList() : enrollmentCompletionPorts;
}
@Override
public List<PlanItemVO> listPlans(PlanQuery query)
{
return planService.selectTrainClassPlanList(query == null ? new PlanQuery() : query);
}
@Override
public PlanDetailVO getPlan(String planId)
{
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanId(planId));
return toDetailVO(plan);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String createPlan(PlanCreateDTO dto, String operator)
{
validatePlan(dto.getStartTime(), dto.getEndTime(), dto.getReportDeadline(), dto.getRecruitNum());
String planName = normalizePlanName(dto.getPlanName());
ensureUniqueName(planName, null);
Date now = DateUtils.getNowDate();
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(IdUtils.fastSimpleUUID());
plan.setPlanName(planName);
plan.setStartTime(dto.getStartTime());
plan.setEndTime(dto.getEndTime());
plan.setReportDeadline(dto.getReportDeadline());
plan.setTrainAddress(dto.getTrainAddress());
plan.setRecruitNum(dto.getRecruitNum());
plan.setApplyCondition(dto.getApplyCondition());
plan.setPlanStatus(STATUS_NOT_STARTED);
plan.setDelFlag("0");
plan.setCreateBy(operator);
plan.setCreateTime(now);
plan.setRemark(dto.getRemark());
planService.insertTrainClassPlan(plan);
return plan.getPlanId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updatePlan(String planId, PlanUpdateDTO dto, String operator)
{
TrainClassPlan existing = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (isLocked(existing.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
validatePlan(dto.getStartTime(), dto.getEndTime(), dto.getReportDeadline(), dto.getRecruitNum());
String planName = normalizePlanName(dto.getPlanName());
ensureUniqueName(planName, planId);
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(planId);
plan.setPlanName(planName);
plan.setStartTime(dto.getStartTime());
plan.setEndTime(dto.getEndTime());
plan.setReportDeadline(dto.getReportDeadline());
plan.setTrainAddress(dto.getTrainAddress());
plan.setRecruitNum(dto.getRecruitNum());
plan.setApplyCondition(dto.getApplyCondition());
plan.setUpdateBy(operator);
plan.setUpdateTime(DateUtils.getNowDate());
plan.setRemark(dto.getRemark());
planService.updateTrainClassPlan(plan);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void completePlan(String planId, String operator)
{
List<PlanEnrollmentCompletionPort> completionPorts = requireEnrollmentCompletionPorts();
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (Integer.valueOf(STATUS_COMPLETED).equals(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_ALREADY_COMPLETED_MESSAGE,
PlanErrorCode.PLAN_ALREADY_COMPLETED);
}
if (Integer.valueOf(STATUS_CANCELLED).equals(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
Date completeTime = DateUtils.getNowDate();
TrainClassPlan statusUpdate = new TrainClassPlan();
statusUpdate.setPlanId(planId);
statusUpdate.setPlanStatus(STATUS_COMPLETED);
statusUpdate.setUpdateBy(operator);
statusUpdate.setUpdateTime(completeTime);
planService.updateTrainClassPlanStatus(statusUpdate);
for (PlanEnrollmentCompletionPort port : completionPorts)
{
port.completeEnrollments(planId, operator, completeTime);
}
}
private List<PlanEnrollmentCompletionPort> requireEnrollmentCompletionPorts()
{
if (enrollmentCompletionPorts.isEmpty())
{
throw new IllegalStateException(
"PlanEnrollmentCompletionPort is not configured; plan completion is disabled");
}
return enrollmentCompletionPorts;
}
private TrainClassPlan requirePlan(TrainClassPlan plan)
{
if (plan == null)
{
throw error(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
return plan;
}
private void ensureUniqueName(String planName, String excludePlanId)
{
if (planService.existsByPlanName(planName, excludePlanId))
{
throw error(PlanErrorCode.PLAN_NAME_DUPLICATED_MESSAGE,
PlanErrorCode.PLAN_NAME_DUPLICATED);
}
}
private String normalizePlanName(String planName)
{
return StringUtils.trim(planName);
}
private void validatePlan(Date startTime, Date endTime, Date reportDeadline, Long recruitNum)
{
if (startTime == null || endTime == null || !startTime.before(endTime))
{
throw error(PlanErrorCode.PLAN_TIME_INVALID_MESSAGE, PlanErrorCode.PLAN_TIME_INVALID);
}
if (reportDeadline == null || reportDeadline.after(startTime))
{
throw error("报到截止时间不得晚于开班时间", PlanErrorCode.PLAN_TIME_INVALID);
}
if (recruitNum == null || recruitNum.longValue() <= 0)
{
throw new ServiceException("计划招生人数必须大于0");
}
}
private boolean isLocked(Integer status)
{
return Integer.valueOf(STATUS_COMPLETED).equals(status)
|| Integer.valueOf(STATUS_CANCELLED).equals(status);
}
private PlanDetailVO toDetailVO(TrainClassPlan plan)
{
PlanDetailVO vo = new PlanDetailVO();
vo.setPlanId(plan.getPlanId());
vo.setPlanName(plan.getPlanName());
vo.setPlanStatus(plan.getPlanStatus());
vo.setStartTime(plan.getStartTime());
vo.setEndTime(plan.getEndTime());
vo.setReportDeadline(plan.getReportDeadline());
vo.setTrainAddress(plan.getTrainAddress());
vo.setRecruitNum(plan.getRecruitNum());
vo.setApplyCondition(plan.getApplyCondition());
vo.setRemark(plan.getRemark());
vo.setCreateBy(plan.getCreateBy());
vo.setCreateTime(plan.getCreateTime());
vo.setUpdateBy(plan.getUpdateBy());
vo.setUpdateTime(plan.getUpdateTime());
return vo;
}
private ServiceException error(String message, int code)
{
return new ServiceException(message, code);
}
}
@@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.service.ITrainClassPlanService;
/**
@@ -32,16 +34,28 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService
return trainClassPlanMapper.selectTrainClassPlanByPlanId(planId);
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return trainClassPlanMapper.selectTrainClassPlanByPlanIdForUpdate(planId);
}
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划
*/
@Override
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan)
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return trainClassPlanMapper.selectTrainClassPlanList(trainClassPlan);
return trainClassPlanMapper.selectTrainClassPlanList(query);
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
return trainClassPlanMapper.countTrainClassPlanByName(planName, excludePlanId) > 0;
}
/**
@@ -70,27 +84,9 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService
return trainClassPlanMapper.updateTrainClassPlan(trainClassPlan);
}
/**
* 批量删除开班计划
*
* @param planIds 需要删除的开班计划主键
* @return 结果
*/
@Override
public int deleteTrainClassPlanByPlanIds(String[] planIds)
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan)
{
return trainClassPlanMapper.deleteTrainClassPlanByPlanIds(planIds);
}
/**
* 删除开班计划信息
*
* @param planId 开班计划主键
* @return 结果
*/
@Override
public int deleteTrainClassPlanByPlanId(String planId)
{
return trainClassPlanMapper.deleteTrainClassPlanByPlanId(planId);
return trainClassPlanMapper.updateTrainClassPlanStatus(trainClassPlan);
}
}
@@ -1,28 +1,82 @@
package com.ruoyi.train.service.impl;
import java.util.ArrayList;
import java.util.List;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainClassroomInfoMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.domain.TrainClassroomInfo;
import com.ruoyi.train.domain.TrainElectronicBoard;
import com.ruoyi.train.domain.vo.ClassroomBoardSummaryVO;
import com.ruoyi.train.domain.vo.ClassroomDetailVO;
import com.ruoyi.train.mapper.TrainClassroomInfoMapper;
import com.ruoyi.train.mapper.TrainElectronicBoardMapper;
import com.ruoyi.train.service.ITrainClassroomInfoService;
/**
* 教室信息Service业务层处理
*
*
* @author ruoyi
* @date 2026-09-08
*/
@Service
public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService
public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService
{
@Autowired
private TrainClassroomInfoMapper trainClassroomInfoMapper;
@Autowired
private TrainElectronicBoardMapper trainElectronicBoardMapper;
/**
* 查询教室信息
*
* 查询教室详情(供API详情接口使用,聚合班牌轻量摘要列表,0..N语义)
*
* @param classRoomId 教室信息主键
* @return 教室详情VO
*/
@Override
public ClassroomDetailVO selectClassroomDetailByClassRoomId(String classRoomId)
{
TrainClassroomInfo classroom = trainClassroomInfoMapper.selectTrainClassroomInfoByClassRoomId(classRoomId);
if (classroom == null)
{
return null;
}
ClassroomDetailVO vo = new ClassroomDetailVO();
vo.setClassRoomId(classroom.getClassRoomId());
vo.setRoomName(classroom.getRoomName());
vo.setRoomLocation(classroom.getRoomLocation());
vo.setCapacity(classroom.getCapacity());
vo.setRoomStatus(classroom.getRoomStatus());
vo.setRemark(classroom.getRemark());
vo.setCreateTime(classroom.getCreateTime());
// 按 bind_classroom_id 查询绑定的电子班牌(0..N),映射为轻量摘要 VO,无绑定时返回空列表 []
TrainElectronicBoard boardQuery = new TrainElectronicBoard();
boardQuery.setBindClassroomId(classRoomId);
List<TrainElectronicBoard> boardList = trainElectronicBoardMapper.selectTrainElectronicBoardList(boardQuery);
List<ClassroomBoardSummaryVO> summaryList = new ArrayList<>();
if (boardList != null && !boardList.isEmpty())
{
for (TrainElectronicBoard board : boardList)
{
summaryList.add(new ClassroomBoardSummaryVO(
board.getBoardId(),
board.getBoardSn(),
board.getBoardStatus()
));
}
}
vo.setBoards(summaryList);
return vo;
}
/**
* 查询教室基础信息 (仅持久化Domain)
*
* @param classRoomId 教室信息主键
* @return 教室信息
*/
@@ -34,7 +88,7 @@ public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService
/**
* 查询教室信息列表
*
*
* @param trainClassroomInfo 教室信息
* @return 教室信息
*/
@@ -44,35 +98,86 @@ public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService
return trainClassroomInfoMapper.selectTrainClassroomInfoList(trainClassroomInfo);
}
/**
* 校验教室名称是否唯一
*
* @param trainClassroomInfo 教室信息
* @return 结果 (true 唯一, false 不唯一)
*/
@Override
public boolean checkRoomNameUnique(TrainClassroomInfo trainClassroomInfo)
{
if (StringUtils.isEmpty(trainClassroomInfo.getRoomName()))
{
return true;
}
TrainClassroomInfo info = trainClassroomInfoMapper.checkRoomNameUnique(
trainClassroomInfo.getRoomName(),
trainClassroomInfo.getClassRoomId()
);
return info == null;
}
/**
* 新增教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@Override
public int insertTrainClassroomInfo(TrainClassroomInfo trainClassroomInfo)
{
// 1. 容纳人数必须大于0
if (trainClassroomInfo.getCapacity() == null || trainClassroomInfo.getCapacity() <= 0)
{
throw new ServiceException("教室容纳人数必须大于0");
}
// 2. 教室名称全局/基地内唯一校验
if (!checkRoomNameUnique(trainClassroomInfo))
{
throw new ServiceException("教室名称已存在", 40008);
}
// 3. 自动生成32位主键UUID(若未指定)
if (StringUtils.isEmpty(trainClassroomInfo.getClassRoomId()))
{
trainClassroomInfo.setClassRoomId(IdUtils.fastSimpleUUID());
}
// 4. 默认值处理
if (trainClassroomInfo.getRoomStatus() == null)
{
trainClassroomInfo.setRoomStatus(1); // 默认可用空闲
}
trainClassroomInfo.setDelFlag("0");
trainClassroomInfo.setCreateTime(DateUtils.getNowDate());
return trainClassroomInfoMapper.insertTrainClassroomInfo(trainClassroomInfo);
}
/**
* 修改教室信息
*
*
* @param trainClassroomInfo 教室信息
* @return 结果
*/
@Override
public int updateTrainClassroomInfo(TrainClassroomInfo trainClassroomInfo)
{
// 1. 容纳人数校验
if (trainClassroomInfo.getCapacity() != null && trainClassroomInfo.getCapacity() <= 0)
{
throw new ServiceException("教室容纳人数必须大于0");
}
// 2. 教室名称唯一性校验
if (!checkRoomNameUnique(trainClassroomInfo))
{
throw new ServiceException("教室名称已存在", 40008);
}
trainClassroomInfo.setUpdateTime(DateUtils.getNowDate());
return trainClassroomInfoMapper.updateTrainClassroomInfo(trainClassroomInfo);
}
/**
* 批量删除教室信息
*
*
* @param classRoomIds 需要删除的教室信息主键
* @return 结果
*/
@@ -83,8 +188,8 @@ public class TrainClassroomInfoServiceImpl implements ITrainClassroomInfoService
}
/**
* 删除教室信息信息
*
* 删除教室信息
*
* @param classRoomId 教室信息主键
* @return 结果
*/
@@ -1,28 +1,46 @@
package com.ruoyi.train.service.impl;
import java.util.List;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.vo.CourseDetailVO;
import com.ruoyi.train.service.ITrainCourseService;
/**
* 课程信息Service业务层处理
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
@Service
public class TrainCourseServiceImpl implements ITrainCourseService
public class TrainCourseServiceImpl implements ITrainCourseService
{
@Autowired
private TrainCourseMapper trainCourseMapper;
/**
* 查询课程详情视图 (CourseDetailVO)
*
* @param cId 课程信息主键
* @return 课程详情VO
*/
@Override
public CourseDetailVO selectCourseDetailByCId(String cId)
{
TrainCourse course = trainCourseMapper.selectTrainCourseByCId(cId);
return CourseDetailVO.from(course);
}
/**
* 查询课程信息
*
*
* @param cId 课程信息主键
* @return 课程信息
*/
@@ -34,7 +52,7 @@ public class TrainCourseServiceImpl implements ITrainCourseService
/**
* 查询课程信息列表
*
*
* @param trainCourse 课程信息
* @return 课程信息
*/
@@ -46,33 +64,150 @@ public class TrainCourseServiceImpl implements ITrainCourseService
/**
* 新增课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@Override
public int insertTrainCourse(TrainCourse trainCourse)
{
// 1. 课程名称校验
if (StringUtils.isEmpty(trainCourse.getcName()))
{
throw new ServiceException("课程名称不能为空", 400);
}
if (trainCourse.getcName().length() > 100)
{
throw new ServiceException("课程名称长度不能超过100个字符", 400);
}
// 2. 课程统一编码唯一性校验
if (StringUtils.isNotEmpty(trainCourse.getcCode()))
{
if (trainCourse.getcCode().length() > 50)
{
throw new ServiceException("课程编码长度不能超过50个字符", 400);
}
if (!checkCCodeUnique(trainCourse))
{
throw new ServiceException("课程编码已存在", 40003);
}
}
// 3. 课程学时正整数校验
if (trainCourse.getcDuration() != null && trainCourse.getcDuration() <= 0)
{
throw new ServiceException("课程学时必须为正整数", 400);
}
// 4. 生成 32 位无短横线 UUID 主键
if (StringUtils.isEmpty(trainCourse.getcId()))
{
trainCourse.setcId(IdUtils.fastSimpleUUID());
}
// 5. 初始默认状态与逻辑删除标志
if (trainCourse.getStatus() == null)
{
trainCourse.setStatus(1);
}
trainCourse.setDelFlag("0");
trainCourse.setCreateTime(DateUtils.getNowDate());
try
{
trainCourse.setCreateBy(SecurityUtils.getUsername());
}
catch (Exception e)
{
// 兜底处理 (如无上下文或测试环境)
}
return trainCourseMapper.insertTrainCourse(trainCourse);
}
/**
* 修改课程信息
*
*
* @param trainCourse 课程信息
* @return 结果
*/
@Override
public int updateTrainCourse(TrainCourse trainCourse)
{
// 1. 课程名称校验
if (StringUtils.isNotEmpty(trainCourse.getcName()) && trainCourse.getcName().length() > 100)
{
throw new ServiceException("课程名称长度不能超过100个字符", 400);
}
// 2. 课程统一编码唯一性校验
if (StringUtils.isNotEmpty(trainCourse.getcCode()))
{
if (trainCourse.getcCode().length() > 50)
{
throw new ServiceException("课程编码长度不能超过50个字符", 400);
}
if (!checkCCodeUnique(trainCourse))
{
throw new ServiceException("课程编码已存在", 40003);
}
}
// 3. 课程学时正整数校验
if (trainCourse.getcDuration() != null && trainCourse.getcDuration() <= 0)
{
throw new ServiceException("课程学时必须为正整数", 400);
}
trainCourse.setUpdateTime(DateUtils.getNowDate());
try
{
trainCourse.setUpdateBy(SecurityUtils.getUsername());
}
catch (Exception e)
{
// 兜底处理
}
return trainCourseMapper.updateTrainCourse(trainCourse);
}
/**
* 校验课程统一业务编码是否唯一
*
* @param trainCourse 课程信息
* @return 结果 (true: 唯一可用, false: 已存在重复)
*/
@Override
public boolean checkCCodeUnique(TrainCourse trainCourse)
{
if (StringUtils.isEmpty(trainCourse.getcCode()))
{
return true;
}
TrainCourse info = trainCourseMapper.checkCCodeUnique(trainCourse.getcCode());
if (info != null && (StringUtils.isEmpty(trainCourse.getcId()) || !info.getcId().equals(trainCourse.getcId())))
{
return false;
}
return true;
}
/**
* 根据课程统一业务编码查询课程信息
*
* @param cCode 课程统一业务编码
* @return 课程信息
*/
@Override
public TrainCourse selectTrainCourseByCCode(String cCode)
{
return trainCourseMapper.selectTrainCourseByCCode(cCode);
}
/**
* 批量删除课程信息
*
*
* @param cIds 需要删除的课程信息主键
* @return 结果
*/
@@ -84,7 +219,7 @@ public class TrainCourseServiceImpl implements ITrainCourseService
/**
* 删除课程信息信息
*
*
* @param cId 课程信息主键
* @return 结果
*/
@@ -1,7 +1,11 @@
package com.ruoyi.train.service.impl;
import java.util.List;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainTeacherMapper;
@@ -10,19 +14,19 @@ import com.ruoyi.train.service.ITrainTeacherService;
/**
* 教师信息Service业务层处理
*
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-14
*/
@Service
public class TrainTeacherServiceImpl implements ITrainTeacherService
public class TrainTeacherServiceImpl implements ITrainTeacherService
{
@Autowired
private TrainTeacherMapper trainTeacherMapper;
/**
* 查询教师信息
*
*
* @param tId 教师信息主键
* @return 教师信息
*/
@@ -34,7 +38,7 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService
/**
* 查询教师信息列表
*
*
* @param trainTeacher 教师信息
* @return 教师信息
*/
@@ -44,35 +48,125 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService
return trainTeacherMapper.selectTrainTeacherList(trainTeacher);
}
/**
* 校验教师手机号码是否唯一
*
* @param trainTeacher 教师信息
* @return true 唯一,false 重复
*/
@Override
public boolean checkPhoneUnique(TrainTeacher trainTeacher)
{
if (StringUtils.isEmpty(trainTeacher.gettPhone()))
{
return true;
}
TrainTeacher info = trainTeacherMapper.checkPhoneUnique(trainTeacher.gettPhone());
if (StringUtils.isNotNull(info) && !StringUtils.equals(info.gettId(), trainTeacher.gettId()))
{
return false;
}
return true;
}
/**
* 新增教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
@Override
public int insertTrainTeacher(TrainTeacher trainTeacher)
{
// 1. 姓名校验
if (StringUtils.isEmpty(trainTeacher.gettName()))
{
throw new ServiceException("教师姓名不能为空");
}
if (trainTeacher.gettName().trim().length() > 50)
{
throw new ServiceException("教师姓名长度不能超过50个字符");
}
// 2. 手机号系统全局查重 (AC-02: 40001 TEACHER_PHONE_DUPLICATED)
if (StringUtils.isNotEmpty(trainTeacher.gettPhone()) && !checkPhoneUnique(trainTeacher))
{
throw new ServiceException("教师手机号码已存在", 40001);
}
// 3. 主键生成:支持自定义工号或自动生成32位UUID
if (StringUtils.isEmpty(trainTeacher.gettId()))
{
trainTeacher.settId(IdUtils.fastSimpleUUID());
}
// 4. 默认值与审计字段
if (trainTeacher.getStatus() == null)
{
trainTeacher.setStatus(1);
}
trainTeacher.setDelFlag("0");
trainTeacher.setCreateTime(DateUtils.getNowDate());
try
{
trainTeacher.setCreateBy(SecurityUtils.getUsername());
}
catch (Exception e)
{
// 非登录上下文防崩溃
}
return trainTeacherMapper.insertTrainTeacher(trainTeacher);
}
/**
* 修改教师信息
*
*
* @param trainTeacher 教师信息
* @return 结果
*/
@Override
public int updateTrainTeacher(TrainTeacher trainTeacher)
{
// 1. 检查教师是否存在 (40401 TEACHER_NOT_FOUND)
if (StringUtils.isEmpty(trainTeacher.gettId()))
{
throw new ServiceException("教师工号不能为空");
}
TrainTeacher existing = trainTeacherMapper.selectTrainTeacherByTId(trainTeacher.gettId());
if (existing == null)
{
throw new ServiceException("教师不存在", 40401);
}
// 2. 姓名校验
if (StringUtils.isNotEmpty(trainTeacher.gettName()) && trainTeacher.gettName().trim().length() > 50)
{
throw new ServiceException("教师姓名长度不能超过50个字符");
}
// 3. 手机号查重 (AC-02: 40001 TEACHER_PHONE_DUPLICATED)
if (StringUtils.isNotEmpty(trainTeacher.gettPhone()) && !checkPhoneUnique(trainTeacher))
{
throw new ServiceException("教师手机号码已存在", 40001);
}
trainTeacher.setUpdateTime(DateUtils.getNowDate());
try
{
trainTeacher.setUpdateBy(SecurityUtils.getUsername());
}
catch (Exception e)
{
// 非登录上下文防崩溃
}
return trainTeacherMapper.updateTrainTeacher(trainTeacher);
}
/**
* 批量删除教师信息
*
* 批量删除教师信息(逻辑删除)
*
* @param tIds 需要删除的教师信息主键
* @return 结果
*/
@@ -83,8 +177,8 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService
}
/**
* 删除教师信息信息
*
* 删除教师信息(逻辑删除)
*
* @param tId 教师信息主键
* @return 结果
*/
@@ -1,28 +1,31 @@
package com.ruoyi.train.service.impl;
import java.util.List;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainTrainingSiteMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.domain.TrainTrainingSite;
import com.ruoyi.train.mapper.TrainTrainingSiteMapper;
import com.ruoyi.train.service.ITrainTrainingSiteService;
/**
* 实训场地Service业务层处理
*
*
* @author ruoyi
* @date 2026-09-08
*/
@Service
public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService
public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService
{
@Autowired
private TrainTrainingSiteMapper trainTrainingSiteMapper;
/**
* 查询实训场地
*
*
* @param siteId 实训场地主键
* @return 实训场地
*/
@@ -34,7 +37,7 @@ public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService
/**
* 查询实训场地列表
*
*
* @param trainTrainingSite 实训场地
* @return 实训场地
*/
@@ -44,35 +47,86 @@ public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService
return trainTrainingSiteMapper.selectTrainTrainingSiteList(trainTrainingSite);
}
/**
* 校验实训场地名称是否唯一
*
* @param trainTrainingSite 实训场地
* @return 结果 (true 唯一, false 不唯一)
*/
@Override
public boolean checkSiteNameUnique(TrainTrainingSite trainTrainingSite)
{
if (StringUtils.isEmpty(trainTrainingSite.getSiteName()))
{
return true;
}
TrainTrainingSite site = trainTrainingSiteMapper.checkSiteNameUnique(
trainTrainingSite.getSiteName(),
trainTrainingSite.getSiteId()
);
return site == null;
}
/**
* 新增实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@Override
public int insertTrainTrainingSite(TrainTrainingSite trainTrainingSite)
{
// 1. 容纳人数必须大于0
if (trainTrainingSite.getCapacity() == null || trainTrainingSite.getCapacity() <= 0)
{
throw new ServiceException("场地最大容纳人数必须大于0");
}
// 2. 场地名称唯一性校验
if (!checkSiteNameUnique(trainTrainingSite))
{
throw new ServiceException("实训场地名称已存在", 40009);
}
// 3. 自动生成32位主键UUID(若未指定)
if (StringUtils.isEmpty(trainTrainingSite.getSiteId()))
{
trainTrainingSite.setSiteId(IdUtils.fastSimpleUUID());
}
// 4. 默认值处理
if (trainTrainingSite.getSiteStatus() == null)
{
trainTrainingSite.setSiteStatus(1); // 默认可用
}
trainTrainingSite.setDelFlag("0");
trainTrainingSite.setCreateTime(DateUtils.getNowDate());
return trainTrainingSiteMapper.insertTrainTrainingSite(trainTrainingSite);
}
/**
* 修改实训场地
*
*
* @param trainTrainingSite 实训场地
* @return 结果
*/
@Override
public int updateTrainTrainingSite(TrainTrainingSite trainTrainingSite)
{
// 1. 容纳人数校验
if (trainTrainingSite.getCapacity() != null && trainTrainingSite.getCapacity() <= 0)
{
throw new ServiceException("场地最大容纳人数必须大于0");
}
// 2. 场地名称唯一性校验
if (!checkSiteNameUnique(trainTrainingSite))
{
throw new ServiceException("实训场地名称已存在", 40009);
}
trainTrainingSite.setUpdateTime(DateUtils.getNowDate());
return trainTrainingSiteMapper.updateTrainTrainingSite(trainTrainingSite);
}
/**
* 批量删除实训场地
*
*
* @param siteIds 需要删除的实训场地主键
* @return 结果
*/
@@ -84,7 +138,7 @@ public class TrainTrainingSiteServiceImpl implements ITrainTrainingSiteService
/**
* 删除实训场地信息
*
*
* @param siteId 实训场地主键
* @return 结果
*/
@@ -0,0 +1,12 @@
package com.ruoyi.train.service.port;
import java.util.Date;
/**
* 计划结训时的参训档案归档边界。
* ISSUE-03-01 实现该端口后,调用将与计划状态变更处于同一事务。
*/
public interface PlanEnrollmentCompletionPort
{
void completeEnrollments(String planId, String operator, Date completeTime);
}
@@ -7,43 +7,101 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<resultMap type="TrainClassPlan" id="TrainClassPlanResult">
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="cId" column="c_id" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="reportDeadline" column="report_deadline" />
<result property="trainAddress" column="train_address" />
<result property="tId" column="t_id" />
<result property="recruitNum" column="recruit_num" />
<result property="applyCondition" column="apply_condition" />
<result property="planStatus" column="plan_status" />
<result property="createUser" column="create_user" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainClassPlanVo">
select plan_id, plan_name, c_id, start_time, end_time, train_address, t_id, recruit_num, apply_condition, plan_status, create_user, create_time, update_time, remark from train_class_plan
<resultMap type="PlanItemVO" id="PlanItemResult">
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="reportDeadline" column="report_deadline" />
<result property="trainAddress" column="train_address" />
<result property="recruitNum" column="recruit_num" />
<result property="planStatus" column="effective_plan_status" />
<result property="courseCount" column="course_count" />
<result property="enrolledCount" column="enrolled_count" />
</resultMap>
<!-- 有效状态的唯一生产规则:持久化状态 0 在开始时间到达后只读呈现为 1。 -->
<sql id="effectivePlanStatusPolicy">
case when plan_status = 0 and start_time &lt;= CURRENT_TIMESTAMP then 1
else plan_status end
</sql>
<select id="selectTrainClassPlanList" parameterType="TrainClassPlan" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
<where>
<if test="planName != null and planName != ''"> and plan_name like concat('%', #{planName}, '%')</if>
<if test="cId != null and cId != ''"> and c_id = #{cId}</if>
<if test="startTime != null "> and start_time = #{startTime}</if>
<if test="endTime != null "> and end_time = #{endTime}</if>
<if test="trainAddress != null and trainAddress != ''"> and train_address = #{trainAddress}</if>
<if test="tId != null and tId != ''"> and t_id = #{tId}</if>
<if test="recruitNum != null "> and recruit_num = #{recruitNum}</if>
<if test="applyCondition != null and applyCondition != ''"> and apply_condition = #{applyCondition}</if>
<if test="planStatus != null "> and plan_status = #{planStatus}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
<sql id="selectTrainClassPlanVo">
select plan_id, plan_name, start_time, end_time, report_deadline, train_address,
recruit_num, apply_condition,
<include refid="effectivePlanStatusPolicy"/> as plan_status,
del_flag, create_by, create_time,
update_by, update_time, remark
from train_class_plan
</sql>
<select id="selectTrainClassPlanList" parameterType="PlanQuery" resultMap="PlanItemResult">
select p.plan_id,
p.plan_name,
p.start_time,
p.end_time,
p.report_deadline,
p.train_address,
p.recruit_num,
p.plan_status as effective_plan_status,
(select count(*) from train_plan_course pc where pc.plan_id = p.plan_id) as course_count,
(select count(*) from train_enrollment e
where e.plan_id = p.plan_id and e.enrollment_status != 3) as enrolled_count
from (
<include refid="selectTrainClassPlanVo"/>
) p
<where>
p.del_flag = '0'
<if test="planName != null and planName != ''">
and p.plan_name like concat('%', #{planName}, '%')
</if>
<if test="planStatus != null">
and p.plan_status = #{planStatus}
</if>
<if test="startTimeBegin != null">
and p.start_time &gt;= #{startTimeBegin}
</if>
<if test="startTimeEnd != null">
and p.start_time &lt;= #{startTimeEnd}
</if>
</where>
order by p.start_time desc, p.create_time desc
</select>
<select id="selectTrainClassPlanByPlanId" parameterType="String" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
where plan_id = #{planId}
where plan_id = #{planId} and del_flag = '0'
</select>
<select id="selectTrainClassPlanByPlanIdForUpdate" parameterType="String" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
where plan_id = #{planId} and del_flag = '0'
for update
</select>
<select id="countTrainClassPlanByName" resultType="int">
select count(*)
from train_class_plan
where del_flag = '0'
and lower(trim(plan_name)) = lower(trim(#{planName}))
<if test="excludePlanId != null and excludePlanId != ''">
and plan_id != #{excludePlanId}
</if>
</select>
<insert id="insertTrainClassPlan" parameterType="TrainClassPlan">
@@ -51,32 +109,34 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="planId != null">plan_id,</if>
<if test="planName != null and planName != ''">plan_name,</if>
<if test="cId != null and cId != ''">c_id,</if>
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
<if test="reportDeadline != null">report_deadline,</if>
<if test="trainAddress != null">train_address,</if>
<if test="tId != null and tId != ''">t_id,</if>
<if test="recruitNum != null">recruit_num,</if>
<if test="applyCondition != null">apply_condition,</if>
<if test="planStatus != null">plan_status,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null">#{planId},</if>
<if test="planName != null and planName != ''">#{planName},</if>
<if test="cId != null and cId != ''">#{cId},</if>
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
<if test="reportDeadline != null">#{reportDeadline},</if>
<if test="trainAddress != null">#{trainAddress},</if>
<if test="tId != null and tId != ''">#{tId},</if>
<if test="recruitNum != null">#{recruitNum},</if>
<if test="applyCondition != null">#{applyCondition},</if>
<if test="planStatus != null">#{planStatus},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
@@ -84,32 +144,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="updateTrainClassPlan" parameterType="TrainClassPlan">
update train_class_plan
<trim prefix="SET" suffixOverrides=",">
<if test="planName != null and planName != ''">plan_name = #{planName},</if>
<if test="cId != null and cId != ''">c_id = #{cId},</if>
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="trainAddress != null">train_address = #{trainAddress},</if>
<if test="tId != null and tId != ''">t_id = #{tId},</if>
<if test="recruitNum != null">recruit_num = #{recruitNum},</if>
<if test="applyCondition != null">apply_condition = #{applyCondition},</if>
<if test="planStatus != null">plan_status = #{planStatus},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where plan_id = #{planId}
set plan_name = #{planName},
start_time = #{startTime},
end_time = #{endTime},
report_deadline = #{reportDeadline},
train_address = #{trainAddress},
recruit_num = #{recruitNum},
apply_condition = #{applyCondition},
update_by = #{updateBy},
update_time = #{updateTime},
remark = #{remark}
where plan_id = #{planId} and del_flag = '0'
</update>
<delete id="deleteTrainClassPlanByPlanId" parameterType="String">
delete from train_class_plan where plan_id = #{planId}
</delete>
<delete id="deleteTrainClassPlanByPlanIds" parameterType="String">
delete from train_class_plan where plan_id in
<foreach item="planId" collection="array" open="(" separator="," close=")">
#{planId}
</foreach>
</delete>
</mapper>
<update id="updateTrainClassPlanStatus" parameterType="TrainClassPlan">
update train_class_plan
set plan_status = #{planStatus},
update_by = #{updateBy},
update_time = #{updateTime}
where plan_id = #{planId} and del_flag = '0'
</update>
</mapper>
@@ -3,37 +3,48 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainClassroomInfoMapper">
<resultMap type="TrainClassroomInfo" id="TrainClassroomInfoResult">
<result property="classRoomId" column="class_room_id" />
<result property="roomName" column="room_name" />
<result property="roomLocation" column="room_location" />
<result property="capacity" column="capacity" />
<result property="roomStatus" column="room_status" />
<result property="createUser" column="create_user" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainClassroomInfoVo">
select class_room_id, room_name, room_location, capacity, room_status, create_user, create_time, update_time, remark from train_classroom_info
select class_room_id, room_name, room_location, capacity, room_status, del_flag, create_by, create_time, update_by, update_time, remark from train_classroom_info
</sql>
<select id="selectTrainClassroomInfoList" parameterType="TrainClassroomInfo" resultMap="TrainClassroomInfoResult">
<include refid="selectTrainClassroomInfoVo"/>
<where>
<if test="roomName != null and roomName != ''"> and room_name like concat('%', #{roomName}, '%')</if>
<if test="roomLocation != null and roomLocation != ''"> and room_location = #{roomLocation}</if>
<if test="capacity != null "> and capacity = #{capacity}</if>
<if test="roomStatus != null "> and room_status = #{roomStatus}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
</where>
where del_flag = '0'
<if test="roomName != null and roomName != ''"> and room_name like concat('%', #{roomName}, '%')</if>
<if test="roomLocation != null and roomLocation != ''"> and room_location = #{roomLocation}</if>
<if test="capacity != null "> and capacity = #{capacity}</if>
<if test="roomStatus != null "> and room_status = #{roomStatus}</if>
<if test="createBy != null and createBy != ''"> and create_by = #{createBy}</if>
order by create_time desc
</select>
<select id="selectTrainClassroomInfoByClassRoomId" parameterType="String" resultMap="TrainClassroomInfoResult">
<include refid="selectTrainClassroomInfoVo"/>
where class_room_id = #{classRoomId}
where class_room_id = #{classRoomId} and del_flag = '0'
</select>
<select id="checkRoomNameUnique" resultMap="TrainClassroomInfoResult">
<include refid="selectTrainClassroomInfoVo"/>
where room_name = #{roomName} and del_flag = '0'
<if test="excludeClassRoomId != null and excludeClassRoomId != ''">
and class_room_id != #{excludeClassRoomId}
</if>
limit 1
</select>
<insert id="insertTrainClassroomInfo" parameterType="TrainClassroomInfo">
@@ -44,8 +55,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="roomLocation != null">room_location,</if>
<if test="capacity != null">capacity,</if>
<if test="roomStatus != null">room_status,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
@@ -55,8 +68,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="roomLocation != null">#{roomLocation},</if>
<if test="capacity != null">#{capacity},</if>
<if test="roomStatus != null">#{roomStatus},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
@@ -69,22 +84,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="roomLocation != null">room_location = #{roomLocation},</if>
<if test="capacity != null">capacity = #{capacity},</if>
<if test="roomStatus != null">room_status = #{roomStatus},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="delFlag != null and delFlag != ''">del_flag = #{delFlag},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where class_room_id = #{classRoomId}
</update>
<delete id="deleteTrainClassroomInfoByClassRoomId" parameterType="String">
delete from train_classroom_info where class_room_id = #{classRoomId}
</delete>
<update id="deleteTrainClassroomInfoByClassRoomId" parameterType="String">
update train_classroom_info set del_flag = '2' where class_room_id = #{classRoomId}
</update>
<delete id="deleteTrainClassroomInfoByClassRoomIds" parameterType="String">
delete from train_classroom_info where class_room_id in
<update id="deleteTrainClassroomInfoByClassRoomIds" parameterType="String">
update train_classroom_info set del_flag = '2' where class_room_id in
<foreach item="classRoomId" collection="array" open="(" separator="," close=")">
#{classRoomId}
</foreach>
</delete>
</mapper>
</update>
</mapper>
@@ -3,99 +3,103 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainCourseMapper">
<resultMap type="TrainCourse" id="TrainCourseResult">
<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="courseContent" column="course_content" />
<result property="attachDoc" column="attach_doc" />
<result property="attachVideo" column="attach_video" />
<result property="attachModelLink" column="attach_model_link" />
<result property="modelResourceIds" column="model_resource_ids" />
<result property="createUser" column="create_user" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainCourseVo">
select c_id, c_name, c_type, c_duration, teaching_objective, course_content, attach_doc, attach_video, attach_model_link, model_resource_ids, create_user, create_time, update_time, remark from train_course
select c_id, c_code, c_name, c_type, c_duration, teaching_objective, course_content, status, del_flag, create_by, create_time, update_by, update_time, remark from train_course
</sql>
<select id="selectTrainCourseList" parameterType="TrainCourse" resultMap="TrainCourseResult">
<include refid="selectTrainCourseVo"/>
<where>
<if test="cName != null and cName != ''"> and c_name like concat('%', #{cName}, '%')</if>
<if test="cType != null and cType != ''"> and c_type = #{cType}</if>
<if test="cDuration != null "> and c_duration = #{cDuration}</if>
<if test="teachingObjective != null and teachingObjective != ''"> and teaching_objective = #{teachingObjective}</if>
<if test="courseContent != null and courseContent != ''"> and course_content = #{courseContent}</if>
<if test="attachDoc != null and attachDoc != ''"> and attach_doc = #{attachDoc}</if>
<if test="attachVideo != null and attachVideo != ''"> and attach_video = #{attachVideo}</if>
<if test="attachModelLink != null and attachModelLink != ''"> and attach_model_link = #{attachModelLink}</if>
<if test="modelResourceIds != null and modelResourceIds != ''"> and model_resource_ids = #{modelResourceIds}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
<where>
del_flag = '0'
<if test="cCode != null and cCode != ''"> and c_code like concat('%', #{cCode}, '%')</if>
<if test="cName != null and cName != ''"> and c_name like concat('%', #{cName}, '%')</if>
<if test="cType != null and cType != ''"> and c_type = #{cType}</if>
<if test="cDuration != null"> and c_duration = #{cDuration}</if>
<if test="status != null"> and status = #{status}</if>
</where>
order by create_time desc
</select>
<select id="selectTrainCourseByCId" parameterType="String" resultMap="TrainCourseResult">
<include refid="selectTrainCourseVo"/>
where c_id = #{cId}
where c_id = #{cId} and del_flag = '0'
</select>
<select id="checkCCodeUnique" parameterType="String" resultMap="TrainCourseResult">
<include refid="selectTrainCourseVo"/>
where c_code = #{cCode} and del_flag = '0' limit 1
</select>
<select id="selectTrainCourseByCCode" parameterType="String" resultMap="TrainCourseResult">
<include refid="selectTrainCourseVo"/>
where c_code = #{cCode} and del_flag = '0' limit 1
</select>
<insert id="insertTrainCourse" parameterType="TrainCourse">
insert into train_course
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="cId != null">c_id,</if>
<if test="cId != null and cId != ''">c_id,</if>
<if test="cCode != null and cCode != ''">c_code,</if>
<if test="cName != null and cName != ''">c_name,</if>
<if test="cType != null">c_type,</if>
<if test="cType != null and cType != ''">c_type,</if>
<if test="cDuration != null">c_duration,</if>
<if test="teachingObjective != null">teaching_objective,</if>
<if test="courseContent != null">course_content,</if>
<if test="attachDoc != null">attach_doc,</if>
<if test="attachVideo != null">attach_video,</if>
<if test="attachModelLink != null">attach_model_link,</if>
<if test="modelResourceIds != null">model_resource_ids,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="teachingObjective != null and teachingObjective != ''">teaching_objective,</if>
<if test="courseContent != null and courseContent != ''">course_content,</if>
<if test="status != null">status,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
<if test="remark != null and remark != ''">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="cId != null">#{cId},</if>
<if test="cId != null and cId != ''">#{cId},</if>
<if test="cCode != null and cCode != ''">#{cCode},</if>
<if test="cName != null and cName != ''">#{cName},</if>
<if test="cType != null">#{cType},</if>
<if test="cType != null and cType != ''">#{cType},</if>
<if test="cDuration != null">#{cDuration},</if>
<if test="teachingObjective != null">#{teachingObjective},</if>
<if test="courseContent != null">#{courseContent},</if>
<if test="attachDoc != null">#{attachDoc},</if>
<if test="attachVideo != null">#{attachVideo},</if>
<if test="attachModelLink != null">#{attachModelLink},</if>
<if test="modelResourceIds != null">#{modelResourceIds},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="teachingObjective != null and teachingObjective != ''">#{teachingObjective},</if>
<if test="courseContent != null and courseContent != ''">#{courseContent},</if>
<if test="status != null">#{status},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
<if test="remark != null and remark != ''">#{remark},</if>
</trim>
</insert>
<update id="updateTrainCourse" parameterType="TrainCourse">
update train_course
<trim prefix="SET" suffixOverrides=",">
<if test="cCode != null and cCode != ''">c_code = #{cCode},</if>
<if test="cName != null and cName != ''">c_name = #{cName},</if>
<if test="cType != null">c_type = #{cType},</if>
<if test="cDuration != null">c_duration = #{cDuration},</if>
<if test="teachingObjective != null">teaching_objective = #{teachingObjective},</if>
<if test="courseContent != null">course_content = #{courseContent},</if>
<if test="attachDoc != null">attach_doc = #{attachDoc},</if>
<if test="attachVideo != null">attach_video = #{attachVideo},</if>
<if test="attachModelLink != null">attach_model_link = #{attachModelLink},</if>
<if test="modelResourceIds != null">model_resource_ids = #{modelResourceIds},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="status != null">status = #{status},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
@@ -103,13 +107,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</update>
<delete id="deleteTrainCourseByCId" parameterType="String">
delete from train_course where c_id = #{cId}
update train_course set del_flag = '2' where c_id = #{cId}
</delete>
<delete id="deleteTrainCourseByCIds" parameterType="String">
delete from train_course where c_id in
update train_course set del_flag = '2' where c_id in
<foreach item="cId" collection="array" open="(" separator="," close=")">
#{cId}
</foreach>
</delete>
</mapper>
</mapper>
@@ -3,43 +3,54 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainTeacherMapper">
<resultMap type="TrainTeacher" id="TrainTeacherResult">
<result property="tId" column="t_id" />
<result property="tName" column="t_name" />
<result property="tGender" column="t_gender" />
<result property="tBirth" column="t_birth" />
<result property="tDepartment" column="t_department" />
<result property="tTitle" column="t_title" />
<result property="userId" column="user_id" />
<result property="orgName" column="org_name" />
<result property="jobTitle" column="job_title" />
<result property="tPhone" column="t_phone" />
<result property="tEmail" column="t_email" />
<result property="createUser" column="create_user" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainTeacherVo">
select t_id, t_name, t_gender, t_birth, t_department, t_title, t_phone, t_email, create_user, create_time, update_time, remark from train_teacher
select t_id, t_name, t_gender, t_birth, user_id, org_name, job_title, t_phone, t_email, status, del_flag, create_by, create_time, update_by, update_time, remark from train_teacher
</sql>
<select id="selectTrainTeacherList" parameterType="TrainTeacher" resultMap="TrainTeacherResult">
<include refid="selectTrainTeacherVo"/>
<where>
<where>
and del_flag = '0'
<if test="tName != null and tName != ''"> and t_name like concat('%', #{tName}, '%')</if>
<if test="tGender != null and tGender != ''"> and t_gender = #{tGender}</if>
<if test="tBirth != null "> and t_birth = #{tBirth}</if>
<if test="tDepartment != null and tDepartment != ''"> and t_department = #{tDepartment}</if>
<if test="tTitle != null and tTitle != ''"> and t_title = #{tTitle}</if>
<if test="tPhone != null and tPhone != ''"> and t_phone = #{tPhone}</if>
<if test="orgName != null and orgName != ''"> and org_name like concat('%', #{orgName}, '%')</if>
<if test="jobTitle != null and jobTitle != ''"> and job_title like concat('%', #{jobTitle}, '%')</if>
<if test="tPhone != null and tPhone != ''"> and t_phone like concat('%', #{tPhone}, '%')</if>
<if test="tEmail != null and tEmail != ''"> and t_email = #{tEmail}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
<if test="status != null"> and status = #{status}</if>
</where>
order by create_time desc
</select>
<select id="selectTrainTeacherByTId" parameterType="String" resultMap="TrainTeacherResult">
<include refid="selectTrainTeacherVo"/>
where t_id = #{tId}
where t_id = #{tId} and del_flag = '0'
</select>
<select id="checkPhoneUnique" parameterType="String" resultMap="TrainTeacherResult">
<include refid="selectTrainTeacherVo"/>
where t_phone = #{tPhone} and del_flag = '0' limit 1
</select>
<insert id="insertTrainTeacher" parameterType="TrainTeacher">
@@ -49,12 +60,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="tName != null and tName != ''">t_name,</if>
<if test="tGender != null">t_gender,</if>
<if test="tBirth != null">t_birth,</if>
<if test="tDepartment != null and tDepartment != ''">t_department,</if>
<if test="tTitle != null">t_title,</if>
<if test="tPhone != null">t_phone,</if>
<if test="userId != null">user_id,</if>
<if test="orgName != null and orgName != ''">org_name,</if>
<if test="jobTitle != null and jobTitle != ''">job_title,</if>
<if test="tPhone != null and tPhone != ''">t_phone,</if>
<if test="tEmail != null">t_email,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="status != null">status,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
@@ -63,12 +78,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="tName != null and tName != ''">#{tName},</if>
<if test="tGender != null">#{tGender},</if>
<if test="tBirth != null">#{tBirth},</if>
<if test="tDepartment != null and tDepartment != ''">#{tDepartment},</if>
<if test="tTitle != null">#{tTitle},</if>
<if test="tPhone != null">#{tPhone},</if>
<if test="userId != null">#{userId},</if>
<if test="orgName != null and orgName != ''">#{orgName},</if>
<if test="jobTitle != null and jobTitle != ''">#{jobTitle},</if>
<if test="tPhone != null and tPhone != ''">#{tPhone},</if>
<if test="tEmail != null">#{tEmail},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="status != null">#{status},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
@@ -80,26 +99,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="tName != null and tName != ''">t_name = #{tName},</if>
<if test="tGender != null">t_gender = #{tGender},</if>
<if test="tBirth != null">t_birth = #{tBirth},</if>
<if test="tDepartment != null and tDepartment != ''">t_department = #{tDepartment},</if>
<if test="tTitle != null">t_title = #{tTitle},</if>
<if test="userId != null">user_id = #{userId},</if>
<if test="orgName != null">org_name = #{orgName},</if>
<if test="jobTitle != null">job_title = #{jobTitle},</if>
<if test="tPhone != null">t_phone = #{tPhone},</if>
<if test="tEmail != null">t_email = #{tEmail},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="status != null">status = #{status},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where t_id = #{tId}
where t_id = #{tId} and del_flag = '0'
</update>
<delete id="deleteTrainTeacherByTId" parameterType="String">
delete from train_teacher where t_id = #{tId}
</delete>
<!-- 逻辑删除:将 del_flag 置为 '2' -->
<update id="deleteTrainTeacherByTId" parameterType="String">
update train_teacher set del_flag = '2' where t_id = #{tId}
</update>
<delete id="deleteTrainTeacherByTIds" parameterType="String">
delete from train_teacher where t_id in
<!-- 批量逻辑删除:将 del_flag 置为 '2' -->
<update id="deleteTrainTeacherByTIds" parameterType="String">
update train_teacher set del_flag = '2' where t_id in
<foreach item="tId" collection="array" open="(" separator="," close=")">
#{tId}
</foreach>
</delete>
</mapper>
</update>
</mapper>
@@ -3,7 +3,7 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainTrainingSiteMapper">
<resultMap type="TrainTrainingSite" id="TrainTrainingSiteResult">
<result property="siteId" column="site_id" />
<result property="siteName" column="site_name" />
@@ -11,31 +11,42 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="capacity" column="capacity" />
<result property="siteDevice" column="site_device" />
<result property="siteStatus" column="site_status" />
<result property="createUser" column="create_user" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainTrainingSiteVo">
select site_id, site_name, site_location, capacity, site_device, site_status, create_user, create_time, update_time, remark from train_training_site
select site_id, site_name, site_location, capacity, site_device, site_status, del_flag, create_by, create_time, update_by, update_time, remark from train_training_site
</sql>
<select id="selectTrainTrainingSiteList" parameterType="TrainTrainingSite" resultMap="TrainTrainingSiteResult">
<include refid="selectTrainTrainingSiteVo"/>
<where>
<if test="siteName != null and siteName != ''"> and site_name like concat('%', #{siteName}, '%')</if>
<if test="siteLocation != null and siteLocation != ''"> and site_location = #{siteLocation}</if>
<if test="capacity != null "> and capacity = #{capacity}</if>
<if test="siteDevice != null and siteDevice != ''"> and site_device = #{siteDevice}</if>
<if test="siteStatus != null "> and site_status = #{siteStatus}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
</where>
where del_flag = '0'
<if test="siteName != null and siteName != ''"> and site_name like concat('%', #{siteName}, '%')</if>
<if test="siteLocation != null and siteLocation != ''"> and site_location = #{siteLocation}</if>
<if test="capacity != null "> and capacity = #{capacity}</if>
<if test="siteDevice != null and siteDevice != ''"> and site_device = #{siteDevice}</if>
<if test="siteStatus != null "> and site_status = #{siteStatus}</if>
<if test="createBy != null and createBy != ''"> and create_by = #{createBy}</if>
order by create_time desc
</select>
<select id="selectTrainTrainingSiteBySiteId" parameterType="String" resultMap="TrainTrainingSiteResult">
<include refid="selectTrainTrainingSiteVo"/>
where site_id = #{siteId}
where site_id = #{siteId} and del_flag = '0'
</select>
<select id="checkSiteNameUnique" resultMap="TrainTrainingSiteResult">
<include refid="selectTrainTrainingSiteVo"/>
where site_name = #{siteName} and del_flag = '0'
<if test="excludeSiteId != null and excludeSiteId != ''">
and site_id != #{excludeSiteId}
</if>
limit 1
</select>
<insert id="insertTrainTrainingSite" parameterType="TrainTrainingSite">
@@ -47,8 +58,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="capacity != null">capacity,</if>
<if test="siteDevice != null">site_device,</if>
<if test="siteStatus != null">site_status,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
@@ -59,8 +72,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="capacity != null">#{capacity},</if>
<if test="siteDevice != null">#{siteDevice},</if>
<if test="siteStatus != null">#{siteStatus},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
@@ -74,22 +89,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="capacity != null">capacity = #{capacity},</if>
<if test="siteDevice != null">site_device = #{siteDevice},</if>
<if test="siteStatus != null">site_status = #{siteStatus},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="delFlag != null and delFlag != ''">del_flag = #{delFlag},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where site_id = #{siteId}
</update>
<delete id="deleteTrainTrainingSiteBySiteId" parameterType="String">
delete from train_training_site where site_id = #{siteId}
</delete>
<update id="deleteTrainTrainingSiteBySiteId" parameterType="String">
update train_training_site set del_flag = '2' where site_id = #{siteId}
</update>
<delete id="deleteTrainTrainingSiteBySiteIds" parameterType="String">
delete from train_training_site where site_id in
<update id="deleteTrainTrainingSiteBySiteIds" parameterType="String">
update train_training_site set del_flag = '2' where site_id in
<foreach item="siteId" collection="array" open="(" separator="," close=")">
#{siteId}
</foreach>
</delete>
</mapper>
</update>
</mapper>
@@ -0,0 +1,429 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
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.port.PlanEnrollmentCompletionPort;
/** ISSUE-02-01 培训计划生命周期单元测试。 */
public class PlanAppServiceImplTest
{
private InMemoryPlanService repository;
private RecordingEnrollmentPort enrollmentPort;
private PlanAppServiceImpl appService;
@Before
public void setUp()
{
repository = new InMemoryPlanService();
enrollmentPort = new RecordingEnrollmentPort();
appService = new PlanAppServiceImpl(repository, Collections.singletonList(enrollmentPort));
}
@Test
public void createPlanShouldGenerateIdAndForceInitialStatus()
{
String planId = appService.createPlan(validCreate(" 2026年信号培训班 "), "admin");
TrainClassPlan saved = repository.selectTrainClassPlanByPlanId(planId);
assertNotNull(planId);
assertEquals(32, planId.length());
assertEquals("2026年信号培训班", saved.getPlanName());
assertEquals(Integer.valueOf(0), saved.getPlanStatus());
assertEquals("admin", saved.getCreateBy());
for (Field field : TrainClassPlan.class.getDeclaredFields())
{
assertTrue("Plan Domain 不得恢复单课程/单教师字段",
!"cId".equals(field.getName()) && !"tId".equals(field.getName()));
}
}
@Test
public void createPlanShouldRejectReversedOrEqualTime()
{
PlanCreateDTO reversed = validCreate("倒序计划");
reversed.setStartTime(date("2026-09-15 08:00:00"));
reversed.setEndTime(date("2026-09-10 08:00:00"));
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(reversed, "admin"));
PlanCreateDTO equal = validCreate("同一时刻计划");
equal.setEndTime(equal.getStartTime());
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(equal, "admin"));
}
@Test
public void createPlanShouldRejectReportDeadlineAfterStart()
{
PlanCreateDTO dto = validCreate("超时报到计划");
dto.setReportDeadline(date("2030-10-10 09:00:00"));
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(dto, "admin"));
}
@Test
public void createPlanShouldRejectDuplicatedName()
{
appService.createPlan(validCreate("重复计划"), "admin");
assertError(PlanErrorCode.PLAN_NAME_DUPLICATED,
() -> appService.createPlan(validCreate(" 重复计划 "), "admin"));
}
@Test
public void getPlanShouldReturnDetailAndRejectMissingPlan()
{
String planId = appService.createPlan(validCreate("查询计划"), "admin");
PlanDetailVO detail = appService.getPlan(planId);
assertEquals(planId, detail.getPlanId());
assertEquals("查询计划", detail.getPlanName());
assertError(PlanErrorCode.PLAN_NOT_FOUND, () -> appService.getPlan("missing"));
}
@Test
public void getPlanShouldExposeInProgressStatusAfterStartTime()
{
PlanCreateDTO dto = validCreate("已开班计划");
dto.setReportDeadline(date("2020-01-01 08:00:00"));
dto.setStartTime(date("2020-01-02 08:00:00"));
dto.setEndTime(date("2030-01-03 08:00:00"));
String planId = appService.createPlan(dto, "admin");
assertEquals(Integer.valueOf(1), appService.getPlan(planId).getPlanStatus());
assertEquals("有效状态查询不得回写持久化状态", Integer.valueOf(0),
repository.store.get(planId).getPlanStatus());
}
@Test
public void updatePlanShouldUpdateEditablePlan()
{
String planId = appService.createPlan(validCreate("原计划"), "admin");
PlanUpdateDTO update = validUpdate("新计划");
update.setTrainAddress("新基地");
appService.updatePlan(planId, update, "editor");
TrainClassPlan saved = repository.selectTrainClassPlanByPlanId(planId);
assertEquals("新计划", saved.getPlanName());
assertEquals("新基地", saved.getTrainAddress());
assertEquals("editor", saved.getUpdateBy());
}
@Test
public void updatePlanShouldRejectCompletedOrCancelledPlan()
{
String completedId = appService.createPlan(validCreate("已结束计划"), "admin");
repository.store.get(completedId).setPlanStatus(2);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.updatePlan(completedId, validUpdate("修改已结束"), "admin"));
String cancelledId = appService.createPlan(validCreate("已取消计划"), "admin");
repository.store.get(cancelledId).setPlanStatus(3);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.updatePlan(cancelledId, validUpdate("修改已取消"), "admin"));
}
@Test
public void completePlanShouldUpdateStatusAndInvokeEnrollmentBoundary() throws NoSuchMethodException
{
String planId = appService.createPlan(validCreate("待结训计划"), "admin");
appService.completePlan(planId, "operator");
assertEquals(Integer.valueOf(2), repository.store.get(planId).getPlanStatus());
assertEquals(1, enrollmentPort.invocationCount);
assertEquals(planId, enrollmentPort.lastPlanId);
assertEquals("operator", enrollmentPort.lastOperator);
assertNotNull(enrollmentPort.lastCompleteTime);
Transactional transaction = PlanAppServiceImpl.class
.getMethod("completePlan", String.class, String.class)
.getAnnotation(Transactional.class);
assertNotNull(transaction);
assertEquals(Exception.class, transaction.rollbackFor()[0]);
}
@Test
public void completePlanShouldPropagateEnrollmentPortFailure()
{
InMemoryPlanService failingRepository = new InMemoryPlanService();
RuntimeException boundaryFailure = new IllegalStateException("Enrollment completion failed");
PlanEnrollmentCompletionPort failingPort = (id, user, time) ->
{
throw boundaryFailure;
};
PlanAppServiceImpl failingService = new PlanAppServiceImpl(failingRepository,
Collections.singletonList(failingPort));
String failingPlanId = failingService.createPlan(validCreate("事务异常计划"), "admin");
try
{
failingService.completePlan(failingPlanId, "operator");
fail("Enrollment 端口异常必须向事务代理传播");
}
catch (RuntimeException e)
{
assertSame(boundaryFailure, e);
}
assertEquals(1, failingRepository.statusUpdateInvocationCount);
}
@Test
public void completePlanShouldFailClosedWhenEnrollmentPortMissing()
{
InMemoryPlanService noPortRepository = new InMemoryPlanService();
PlanAppServiceImpl noPortService = new PlanAppServiceImpl(noPortRepository,
Collections.emptyList());
String planId = noPortService.createPlan(validCreate("缺少联动适配器计划"), "admin");
try
{
noPortService.completePlan(planId, "operator");
fail("缺少 Enrollment Completion Port 时必须 fail closed");
}
catch (IllegalStateException e)
{
assertTrue(e.getMessage().contains("PlanEnrollmentCompletionPort"));
}
assertEquals(0, noPortRepository.statusUpdateInvocationCount);
assertEquals(Integer.valueOf(0), noPortRepository.store.get(planId).getPlanStatus());
}
@Test
public void completePlanShouldRejectRepeatedCompletion()
{
String planId = appService.createPlan(validCreate("幂等计划"), "admin");
appService.completePlan(planId, "admin");
assertError(PlanErrorCode.PLAN_ALREADY_COMPLETED,
() -> appService.completePlan(planId, "admin"));
assertEquals(1, enrollmentPort.invocationCount);
}
@Test
public void completePlanShouldRejectMissingOrCancelledPlan()
{
assertError(PlanErrorCode.PLAN_NOT_FOUND,
() -> appService.completePlan("missing", "admin"));
String planId = appService.createPlan(validCreate("取消计划"), "admin");
repository.store.get(planId).setPlanStatus(3);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.completePlan(planId, "admin"));
}
@Test
public void listPlansShouldDelegatePlanQuery()
{
PlanItemVO item = new PlanItemVO();
item.setPlanId("P001");
repository.listResult = Collections.singletonList(item);
List<PlanItemVO> result = appService.listPlans(new PlanQuery());
assertEquals(1, result.size());
assertEquals("P001", result.get(0).getPlanId());
}
private PlanCreateDTO validCreate(String name)
{
PlanCreateDTO dto = new PlanCreateDTO();
dto.setPlanName(name);
dto.setReportDeadline(date("2030-10-09 18:00:00"));
dto.setStartTime(date("2030-10-10 08:30:00"));
dto.setEndTime(date("2030-10-24 17:30:00"));
dto.setTrainAddress("培训基地");
dto.setRecruitNum(40L);
dto.setApplyCondition("在岗职工");
dto.setRemark("测试计划");
return dto;
}
private PlanUpdateDTO validUpdate(String name)
{
PlanUpdateDTO dto = new PlanUpdateDTO();
dto.setPlanName(name);
dto.setReportDeadline(date("2030-10-09 18:00:00"));
dto.setStartTime(date("2030-10-10 08:30:00"));
dto.setEndTime(date("2030-10-24 17:30:00"));
dto.setTrainAddress("培训基地");
dto.setRecruitNum(40L);
dto.setApplyCondition("在岗职工");
dto.setRemark("更新测试");
return dto;
}
private Date date(String value)
{
try
{
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(value);
}
catch (ParseException e)
{
throw new IllegalArgumentException(e);
}
}
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 RecordingEnrollmentPort implements PlanEnrollmentCompletionPort
{
private int invocationCount;
private String lastPlanId;
private String lastOperator;
private Date lastCompleteTime;
@Override
public void completeEnrollments(String planId, String operator, Date completeTime)
{
invocationCount++;
lastPlanId = planId;
lastOperator = operator;
lastCompleteTime = completeTime;
}
}
private static class InMemoryPlanService implements ITrainClassPlanService
{
private final Map<String, TrainClassPlan> store = new LinkedHashMap<>();
private List<PlanItemVO> listResult = new ArrayList<>();
private int statusUpdateInvocationCount;
@Override
public TrainClassPlan selectTrainClassPlanByPlanId(String planId)
{
return effectiveView(store.get(planId));
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return effectiveView(store.get(planId));
}
@Override
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return listResult;
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
for (TrainClassPlan plan : store.values())
{
if (plan.getPlanName().trim().equalsIgnoreCase(planName.trim())
&& !plan.getPlanId().equals(excludePlanId))
{
return true;
}
}
return false;
}
@Override
public int insertTrainClassPlan(TrainClassPlan plan)
{
store.put(plan.getPlanId(), plan);
return 1;
}
@Override
public int updateTrainClassPlan(TrainClassPlan update)
{
TrainClassPlan existing = store.get(update.getPlanId());
existing.setPlanName(update.getPlanName());
existing.setStartTime(update.getStartTime());
existing.setEndTime(update.getEndTime());
existing.setReportDeadline(update.getReportDeadline());
existing.setTrainAddress(update.getTrainAddress());
existing.setRecruitNum(update.getRecruitNum());
existing.setApplyCondition(update.getApplyCondition());
existing.setUpdateBy(update.getUpdateBy());
existing.setUpdateTime(update.getUpdateTime());
existing.setRemark(update.getRemark());
return 1;
}
@Override
public int updateTrainClassPlanStatus(TrainClassPlan update)
{
statusUpdateInvocationCount++;
TrainClassPlan existing = store.get(update.getPlanId());
existing.setPlanStatus(update.getPlanStatus());
existing.setUpdateBy(update.getUpdateBy());
existing.setUpdateTime(update.getUpdateTime());
return 1;
}
/** Mirrors the mapper's read-only effectivePlanStatusPolicy for this JVM fake. */
private TrainClassPlan effectiveView(TrainClassPlan source)
{
if (source == null)
{
return null;
}
TrainClassPlan view = new TrainClassPlan();
view.setPlanId(source.getPlanId());
view.setPlanName(source.getPlanName());
view.setStartTime(source.getStartTime());
view.setEndTime(source.getEndTime());
view.setReportDeadline(source.getReportDeadline());
view.setTrainAddress(source.getTrainAddress());
view.setRecruitNum(source.getRecruitNum());
view.setApplyCondition(source.getApplyCondition());
view.setPlanStatus(source.getPlanStatus());
view.setDelFlag(source.getDelFlag());
view.setCreateBy(source.getCreateBy());
view.setCreateTime(source.getCreateTime());
view.setUpdateBy(source.getUpdateBy());
view.setUpdateTime(source.getUpdateTime());
view.setRemark(source.getRemark());
if (Integer.valueOf(0).equals(source.getPlanStatus())
&& source.getStartTime() != null
&& !source.getStartTime().after(new Date()))
{
view.setPlanStatus(1);
}
return view;
}
}
}
@@ -0,0 +1,91 @@
-- ==============================================================================
-- 001_generator_identity_precheck.sql
-- Generator Kingbase Identity 预检只读脚本 (增强版: 支持 Partial 状态与数据量探测)
--
-- 功能:只读探测 gen_table.table_id 与 gen_table_column.column_id 的 Identity 状态
-- 判定准则:
-- 1. CASE_1_BOTH_NEED_PATCH: 两列均缺失 Identity,需依次执行 002a 和 002b
-- 2. CASE_2_BOTH_ALREADY_PATCHED: 两列均已具备 Identity,禁止重复执行任何 Patch
-- 3. CASE_3_ONLY_COLUMN_NEEDS_PATCH: 仅 column_id 缺失,严禁执行 002a,仅执行 002b
-- 4. CASE_4_ONLY_TABLE_NEEDS_PATCH: 仅 table_id 缺失,严禁执行 002b,仅执行 002a
-- 安全说明:纯只读查询,无任何 DDL / DML / 序列修改副作用
-- ==============================================================================
-- 1. 列级别 Identity 详细属性检查
SELECT
c.relname AS table_name,
a.attname AS column_name,
t.typname AS data_type,
a.attnotnull AS is_not_null,
COALESCE(a.attidentity, '') AS attidentity,
CASE a.attidentity
WHEN 'd' THEN 'ALREADY_PATCHED (GENERATED BY DEFAULT AS IDENTITY)'
WHEN 'a' THEN 'ALREADY_PATCHED (GENERATED ALWAYS AS IDENTITY)'
ELSE 'NEED_PATCH (MISSING_IDENTITY)'
END AS patch_status
FROM sys_class c
JOIN sys_attribute a ON a.attrelid = c.oid
JOIN sys_type t ON a.atttypid = t.oid
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND ((c.relname = 'gen_table' AND a.attname = 'table_id')
OR (c.relname = 'gen_table_column' AND a.attname = 'column_id'))
ORDER BY c.relname, a.attname;
-- 2. 检查关联 Sequence 存在性
SELECT
c.relname AS sequence_name,
c.relkind AS relkind,
CASE c.relkind
WHEN 'S' THEN 'SEQUENCE_EXISTS'
ELSE 'UNKNOWN'
END AS seq_status
FROM sys_class c
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname IN ('gen_table_table_id_seq', 'gen_table_column_column_id_seq')
ORDER BY c.relname;
-- 3. 现有数据行数与最大 ID 统计 (区分空表与已有数据环境)
SELECT
'gen_table' AS table_name,
COUNT(*) AS row_count,
COALESCE(MAX(table_id), 0) AS max_id,
CASE WHEN COUNT(*) = 0 THEN 'EMPTY_METADATA_ENV (默认起始序列即可安全使用)'
ELSE 'EXISTING_METADATA_ENV (补丁后需重点核验 Sequence 对齐)' END AS env_type
FROM gen_table
UNION ALL
SELECT
'gen_table_column' AS table_name,
COUNT(*) AS row_count,
COALESCE(MAX(column_id), 0) AS max_id,
CASE WHEN COUNT(*) = 0 THEN 'EMPTY_METADATA_ENV (默认起始序列即可安全使用)'
ELSE 'EXISTING_METADATA_ENV (补丁后需重点核验 Sequence 对齐)' END AS env_type
FROM gen_table_column;
-- 4. 全局 Partial / Full 状态判定与执行建议
WITH status_data AS (
SELECT
MAX(CASE WHEN c.relname = 'gen_table' AND a.attname = 'table_id' THEN COALESCE(a.attidentity, '') END) AS table_id_id,
MAX(CASE WHEN c.relname = 'gen_table_column' AND a.attname = 'column_id' THEN COALESCE(a.attidentity, '') END) AS column_id_id
FROM sys_class c
JOIN sys_attribute a ON a.attrelid = c.oid
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND ((c.relname = 'gen_table' AND a.attname = 'table_id')
OR (c.relname = 'gen_table_column' AND a.attname = 'column_id'))
)
SELECT
CASE
WHEN table_id_id IN ('d', 'a') AND column_id_id IN ('d', 'a') THEN
'CASE_2_BOTH_ALREADY_PATCHED: 两列均已配置 Identity,禁止执行任何 Patch,直接运行 003_generator_identity_verify.sql'
WHEN table_id_id NOT IN ('d', 'a') AND column_id_id NOT IN ('d', 'a') THEN
'CASE_1_BOTH_NEED_PATCH: 两列均缺失 Identity,需依次执行 002a_gen_table_identity_patch.sql 与 002b_gen_table_column_identity_patch.sql'
WHEN table_id_id IN ('d', 'a') AND column_id_id NOT IN ('d', 'a') THEN
'CASE_3_ONLY_COLUMN_NEEDS_PATCH: 仅 column_id 缺失,严禁执行 002a,仅可执行 002b_gen_table_column_identity_patch.sql'
WHEN table_id_id NOT IN ('d', 'a') AND column_id_id IN ('d', 'a') THEN
'CASE_4_ONLY_TABLE_NEEDS_PATCH: 仅 table_id 缺失,严禁执行 002b,仅可执行 002a_gen_table_identity_patch.sql'
ELSE
'CASE_UNKNOWN: 异常状态,请联系管理员核实'
END AS precheck_scenario_verdict
FROM status_data;
@@ -0,0 +1,22 @@
-- ==============================================================================
-- 002a_gen_table_identity_patch.sql
-- Generator Kingbase Identity 独立补丁 A: gen_table.table_id
--
-- 变更目标:
-- 为 public.gen_table.table_id 添加 GENERATED BY DEFAULT AS IDENTITY
--
-- 执行准入前置条件(强制要求):
-- 1. 必须先执行 001_generator_identity_precheck.sql;
-- 2. 仅当 Precheck 判定结果为 CASE_1_BOTH_NEED_PATCH 或 CASE_4_ONLY_TABLE_NEEDS_PATCH 时方可执行;
-- 3. 若 table_id 已为 ALREADY_PATCHED,严禁执行本脚本(Kingbase 将抛出 already an identity column 错误)。
--
-- 环境数据安全约束:
-- - 本脚本仅添加 Identity 特性,默认从 1 开始;
-- - 若当前 gen_table 为空表 (row_count = 0),执行后直接安全;
-- - 若当前 gen_table 已存在历史元数据 (row_count > 0),执行后必须通过 003 脚本核对 Sequence 对齐状态;
-- - 严禁 DROP 表、重建表或清空 Generator 元数据。
-- ==============================================================================
ALTER TABLE gen_table
ALTER COLUMN table_id
ADD GENERATED BY DEFAULT AS IDENTITY;
@@ -0,0 +1,22 @@
-- ==============================================================================
-- 002b_gen_table_column_identity_patch.sql
-- Generator Kingbase Identity 独立补丁 B: gen_table_column.column_id
--
-- 变更目标:
-- 为 public.gen_table_column.column_id 添加 GENERATED BY DEFAULT AS IDENTITY
--
-- 执行准入前置条件(强制要求):
-- 1. 必须先执行 001_generator_identity_precheck.sql;
-- 2. 仅当 Precheck 判定结果为 CASE_1_BOTH_NEED_PATCH 或 CASE_3_ONLY_COLUMN_NEEDS_PATCH 时方可执行;
-- 3. 若 column_id 已为 ALREADY_PATCHED,严禁执行本脚本(Kingbase 将抛出 already an identity column 错误)。
--
-- 环境数据安全约束:
-- - 本脚本仅添加 Identity 特性,默认从 1 开始;
-- - 若当前 gen_table_column 为空表 (row_count = 0),执行后直接安全;
-- - 若当前 gen_table_column 已存在历史元数据 (row_count > 0),执行后必须通过 003 脚本核对 Sequence 对齐状态;
-- - 严禁 DROP 表、重建表或清空 Generator 元数据。
-- ==============================================================================
ALTER TABLE gen_table_column
ALTER COLUMN column_id
ADD GENERATED BY DEFAULT AS IDENTITY;
@@ -0,0 +1,146 @@
-- ==============================================================================
-- 003_generator_identity_verify.sql
-- Generator Kingbase Identity 验证只读脚本 (增强版: 含 Sequence 对齐安全核验)
--
-- 功能:只读验证 gen_table 与 gen_table_column 的 Identity 状态及 Sequence 安全性
-- 验证维度:
-- 1. 列 Identity 属性配置:attidentity 精确等于 'd'
-- 2. Sequence 存在性:gen_table_table_id_seq / gen_table_column_column_id_seq 均为 'S'
-- 3. Sequence 依赖关系:sys_depend 中记录类型为 'i' (internal identity dependency)
-- 4. Sequence 对齐安全位:下一个分配 ID 必须严格大于现有 MAX(id) (或空表 SAFE)
-- 安全说明:纯只读查询,无任何 DDL / DML / nextval / setval 副作用
-- ==============================================================================
-- 1. 验证列级别 Identity 配置
SELECT
c.relname AS table_name,
a.attname AS column_name,
a.attidentity AS attidentity,
CASE
WHEN a.attidentity = 'd' THEN 'PASS (GENERATED BY DEFAULT AS IDENTITY)'
ELSE 'FAIL (ATTIDENTITY_NOT_D)'
END AS identity_check
FROM sys_class c
JOIN sys_attribute a ON a.attrelid = c.oid
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND ((c.relname = 'gen_table' AND a.attname = 'table_id')
OR (c.relname = 'gen_table_column' AND a.attname = 'column_id'))
ORDER BY c.relname, a.attname;
-- 2. 验证 Sequence 存在性与类别
SELECT
c.relname AS sequence_name,
c.relkind AS relkind,
CASE
WHEN c.relkind = 'S' THEN 'PASS (SEQUENCE_EXISTS)'
ELSE 'FAIL (NOT_A_SEQUENCE)'
END AS sequence_check
FROM sys_class c
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname IN ('gen_table_table_id_seq', 'gen_table_column_column_id_seq')
ORDER BY c.relname;
-- 3. 验证 Sequence 依赖关系 (deptype = 'i')
SELECT
s.relname AS sequence_name,
t.relname AS target_table,
a.attname AS target_column,
d.deptype AS dependency_type,
CASE
WHEN d.deptype = 'i' THEN 'PASS (INTERNAL_IDENTITY_DEPENDENCY)'
ELSE 'FAIL (INVALID_DEPENDENCY_TYPE)'
END AS dependency_check
FROM sys_depend d
JOIN sys_class s ON s.oid = d.objid
JOIN sys_class t ON t.oid = d.refobjid
JOIN sys_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
JOIN sys_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname IN ('gen_table', 'gen_table_column')
AND a.attname IN ('table_id', 'column_id')
ORDER BY t.relname, a.attname;
-- 4. 验证 Sequence 当前位置与已有 MAX(id) 对齐安全性 (只读)
SELECT
'gen_table' AS table_name,
'table_id' AS column_name,
t.row_count,
t.max_id AS existing_max_id,
s.last_value::bigint AS sequence_last_value,
s.is_called,
CASE
WHEN t.row_count = 0 THEN 'EMPTY_TABLE_SAFE'
WHEN (CASE WHEN s.is_called THEN s.last_value::bigint + 1 ELSE s.last_value::bigint END) > t.max_id THEN 'SAFE'
ELSE 'UNSAFE (SEQUENCE_BEHIND_OR_EQUAL_MAX_ID)'
END AS seq_alignment_status
FROM (SELECT COUNT(*) AS row_count, COALESCE(MAX(table_id), 0) AS max_id FROM gen_table) t,
gen_table_table_id_seq s
UNION ALL
SELECT
'gen_table_column' AS table_name,
'column_id' AS column_name,
c.row_count,
c.max_id AS existing_max_id,
s.last_value::bigint AS sequence_last_value,
s.is_called,
CASE
WHEN c.row_count = 0 THEN 'EMPTY_TABLE_SAFE'
WHEN (CASE WHEN s.is_called THEN s.last_value::bigint + 1 ELSE s.last_value::bigint END) > c.max_id THEN 'SAFE'
ELSE 'UNSAFE (SEQUENCE_BEHIND_OR_EQUAL_MAX_ID)'
END AS seq_alignment_status
FROM (SELECT COUNT(*) AS row_count, COALESCE(MAX(column_id), 0) AS max_id FROM gen_table_column) c,
gen_table_column_column_id_seq s;
-- 5. 总体综合判定 (包含属性配置、序列存在、依赖关系及 Sequence 对齐安全)
WITH identity_summary AS (
SELECT count(*) AS id_cnt
FROM sys_class c
JOIN sys_attribute a ON a.attrelid = c.oid
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND ((c.relname = 'gen_table' AND a.attname = 'table_id')
OR (c.relname = 'gen_table_column' AND a.attname = 'column_id'))
AND a.attidentity = 'd'
),
seq_summary AS (
SELECT count(*) AS seq_cnt
FROM sys_class c
JOIN sys_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname IN ('gen_table_table_id_seq', 'gen_table_column_column_id_seq')
AND c.relkind = 'S'
),
dep_summary AS (
SELECT count(*) AS dep_cnt
FROM sys_depend d
JOIN sys_class s ON s.oid = d.objid
JOIN sys_class t ON t.oid = d.refobjid
JOIN sys_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
JOIN sys_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname IN ('gen_table', 'gen_table_column')
AND a.attname IN ('table_id', 'column_id')
AND d.deptype = 'i'
),
seq_align AS (
SELECT
sum(CASE WHEN t.row_count = 0 OR (CASE WHEN s.is_called THEN s.last_value::bigint + 1 ELSE s.last_value::bigint END) > t.max_id THEN 1 ELSE 0 END) AS safe_cnt
FROM (
SELECT 'gen_table' AS tbl, COUNT(*) AS row_count, COALESCE(MAX(table_id), 0) AS max_id FROM gen_table
) t, gen_table_table_id_seq s
UNION ALL
SELECT
sum(CASE WHEN c.row_count = 0 OR (CASE WHEN s.is_called THEN s.last_value::bigint + 1 ELSE s.last_value::bigint END) > c.max_id THEN 1 ELSE 0 END) AS safe_cnt
FROM (
SELECT 'gen_table_column' AS tbl, COUNT(*) AS row_count, COALESCE(MAX(column_id), 0) AS max_id FROM gen_table_column
) c, gen_table_column_column_id_seq s
)
SELECT
CASE
WHEN i.id_cnt = 2 AND s.seq_cnt = 2 AND d.dep_cnt = 2 AND (SELECT sum(safe_cnt) FROM seq_align) = 2
THEN 'VERIFY: PASS (所有 Identity、Sequence、依赖关系与序列对齐安全核验一致通过)'
ELSE 'VERIFY: FAIL (存在核验未达标或 Sequence 落后风险,详情见分项输出)'
END AS overall_verify_result
FROM identity_summary i, seq_summary s, dep_summary d;
@@ -0,0 +1,79 @@
# Kingbase Generator Identity 基础设施补丁
## 1. 补丁目标
解决 RuoYi 代码生成器(Generator)在 KingbaseES 数据库下执行表结构导入(`importGenTable`)时,因 `gen_table.table_id` 和 `gen_table_column.column_id` 缺失自增/Identity 属性,导致 `useGeneratedKeys="true"` 回填失败并引发主键冲突(Duplicate key violation)的运行时兼容性问题。
## 2. 补丁范围与安全红线
- **涉及对象**:严格仅限 `public.gen_table.table_id` 与 `public.gen_table_column.column_id`。
- **安全红线**:
- 严禁修改 21 张 `train_*` 业务表;
- 严禁修改 `sys_*` 系统/权限/字典表;
- 严禁 DROP 表、重建表或清空/截断 Generator 历史元数据;
- 严禁在未做 Precheck 判定前盲目全量执行 Patch。
## 3. 文件清单
| 文件名 | 类型 | 说明 |
| :--- | :---: | :--- |
| `001_generator_identity_precheck.sql` | 只读探测 | 检查两列 Identity 配置、现有数据量及全局 Case 场景。 |
| `002a_gen_table_identity_patch.sql` | DDL 补丁 | 仅为 `gen_table.table_id` 添加 `GENERATED BY DEFAULT AS IDENTITY`。 |
| `002b_gen_table_column_identity_patch.sql` | DDL 补丁 | 仅为 `gen_table_column.column_id` 添加 `GENERATED BY DEFAULT AS IDENTITY`。 |
| `003_generator_identity_verify.sql` | 只读验证 | 综合校验列属性、Sequence 存在、依赖关系及 Sequence 对齐安全性。 |
---
## 4. 四种 Partial / Full 执行决策矩阵
根据 `001_generator_identity_precheck.sql` 查询 4 返回的 `precheck_scenario_verdict`,严格按以下矩阵执行:
| 场景编号 | Precheck 判定结果 | 执行动作 |
| :--- | :--- | :--- |
| **Scenario A** | `CASE_1_BOTH_NEED_PATCH`<br>(两列均缺失 Identity) | 1. 执行 `002a_gen_table_identity_patch.sql`<br>2. 执行 `002b_gen_table_column_identity_patch.sql`<br>3. 执行 `003_generator_identity_verify.sql` |
| **Scenario B** | `CASE_2_BOTH_ALREADY_PATCHED`<br>(两列均已配置 Identity) | **严禁执行任何 Patch!**<br>直接执行 `003_generator_identity_verify.sql` 复核。 |
| **Scenario C** | `CASE_3_ONLY_COLUMN_NEEDS_PATCH`<br>(仅 `column_id` 缺失) | **严禁执行 002a!**<br>1. 仅执行 `002b_gen_table_column_identity_patch.sql`<br>2. 执行 `003_generator_identity_verify.sql` |
| **Scenario D** | `CASE_4_ONLY_TABLE_NEEDS_PATCH`<br>(仅 `table_id` 缺失) | **严禁执行 002b!**<br>1. 仅执行 `002a_gen_table_identity_patch.sql`<br>2. 执行 `003_generator_identity_verify.sql` |
---
## 5. 数据环境分类与 Sequence 对齐要求
### 5.1 Case A:Generator 元数据为空(0 rows,全新环境)
- `001` 预检中 `env_type` 为 `EMPTY_METADATA_ENV`;
- 执行 Patch 后,Kingbase 自动生成的 Sequence 从 1 开始分配;
- 执行 `003` 核验,Sequence 对齐状态将显示 `EMPTY_TABLE_SAFE`,可直接投入使用。
### 5.2 Case B:Generator 元数据已存在(已有历史导入数据)
- `001` 预检中 `env_type` 为 `EXISTING_METADATA_ENV`;
- **核心风险**:Kingbase `ADD GENERATED BY DEFAULT AS IDENTITY` 在已有数据环境下默认从 1 开始分配,**不宣称自动对齐已有数据 MAX(id)**!
- **操作要求**:
1. 执行对应补丁后,**必须立即执行 `003_generator_identity_verify.sql`**;
2. 观察验证输出中的 `seq_alignment_status`:
- 若为 `SAFE`:表明 sequence 位置已超前于现有数据(例如已有试点导入推进了序列),可直接使用;
- 若为 `UNSAFE`:表明 sequence 当前位置落后或等于现有主键,**绝对禁止直接继续 Generator Import**!
3. **Sequence 对齐专项步骤(需 DBA 确认并在维护窗口执行)**:
针对落后的序列,使用 Kingbase 兼容的管理语法调整序列游标:
```sql
-- 示例:对齐 gen_table sequence
SELECT setval('gen_table_table_id_seq', (SELECT COALESCE(MAX(table_id), 0) + 1 FROM gen_table), false);
-- 示例:对齐 gen_table_column sequence
SELECT setval('gen_table_column_column_id_seq', (SELECT COALESCE(MAX(column_id), 0) + 1 FROM gen_table_column), false);
```
4. 重新执行 `003_generator_identity_verify.sql`,确认全部输出 `SAFE` 且最终结果为 `VERIFY: PASS` 后,方可开放表导入功能。
---
## 6. 执行命令示例(无真实凭据)
```bash
# 步骤 1: 预检并确认 Scenario
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f 001_generator_identity_precheck.sql
# 步骤 2: 根据 Precheck 决策矩阵有选择地执行对应补丁
# 仅当命中 Scenario A 或 D 时:
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f 002a_gen_table_identity_patch.sql
# 仅当命中 Scenario A 或 C 时:
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f 002b_gen_table_column_identity_patch.sql
# 步骤 3: 严格复核验证
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f 003_generator_identity_verify.sql
```
+4 -21
View File
@@ -3,7 +3,7 @@ import request from '@/utils/request'
// 查询教室信息列表
export function listClassroom(query) {
return request({
url: '/train/classroominfo/list',
url: '/train/classroom/list',
method: 'get',
params: query
})
@@ -12,7 +12,7 @@ export function listClassroom(query) {
// 查询教室信息详细
export function getClassroom(classRoomId) {
return request({
url: '/train/classroominfo/' + classRoomId,
url: '/train/classroom/' + classRoomId,
method: 'get'
})
}
@@ -20,7 +20,7 @@ export function getClassroom(classRoomId) {
// 新增教室信息
export function addClassroom(data) {
return request({
url: '/train/classroominfo',
url: '/train/classroom',
method: 'post',
data: data
})
@@ -29,25 +29,8 @@ export function addClassroom(data) {
// 修改教室信息
export function updateClassroom(data) {
return request({
url: '/train/classroominfo',
url: '/train/classroom/' + data.classRoomId,
method: 'put',
data: data
})
}
// 删除教室信息
export function delClassroom(classRoomId) {
return request({
url: '/train/classroominfo/' + classRoomId,
method: 'delete'
})
}
// 导出教室信息
export function exportClassroom(query) {
return request({
url: '/train/classroominfo/export',
method: 'post',
params: query
})
}
+2 -10
View File
@@ -26,19 +26,11 @@ export function addCourse(data) {
})
}
// 修改课程信息
// 修改课程信息 (API Contract 3.4: PUT /train/course/{cId})
export function updateCourse(data) {
return request({
url: '/train/course',
url: '/train/course/' + data.cId,
method: 'put',
data: data
})
}
// 删除课程信息
export function delCourse(cId) {
return request({
url: '/train/course/' + cId,
method: 'delete'
})
}
+9 -9
View File
@@ -3,7 +3,7 @@ import request from '@/utils/request'
// 查询开班计划列表
export function listPlan(query) {
return request({
url: '/train/plan/list',
url: '/train/plans',
method: 'get',
params: query
})
@@ -12,7 +12,7 @@ export function listPlan(query) {
// 查询开班计划详细
export function getPlan(planId) {
return request({
url: '/train/plan/' + planId,
url: '/train/plans/' + planId,
method: 'get'
})
}
@@ -20,25 +20,25 @@ export function getPlan(planId) {
// 新增开班计划
export function addPlan(data) {
return request({
url: '/train/plan',
url: '/train/plans',
method: 'post',
data: data
})
}
// 修改开班计划
export function updatePlan(data) {
export function updatePlan(planId, data) {
return request({
url: '/train/plan',
url: '/train/plans/' + planId,
method: 'put',
data: data
})
}
// 删除开班计划
export function delPlan(planId) {
// 结束培训计划
export function completePlan(planId) {
return request({
url: '/train/plan/' + planId,
method: 'delete'
url: '/train/plans/' + planId + '/complete',
method: 'post'
})
}
+4 -12
View File
@@ -3,7 +3,7 @@ import request from '@/utils/request'
// 查询实训场地列表
export function listSite(query) {
return request({
url: '/train/site/list',
url: '/train/training-site/list',
method: 'get',
params: query
})
@@ -12,7 +12,7 @@ export function listSite(query) {
// 查询实训场地详细
export function getSite(siteId) {
return request({
url: '/train/site/' + siteId,
url: '/train/training-site/' + siteId,
method: 'get'
})
}
@@ -20,7 +20,7 @@ export function getSite(siteId) {
// 新增实训场地
export function addSite(data) {
return request({
url: '/train/site',
url: '/train/training-site',
method: 'post',
data: data
})
@@ -29,16 +29,8 @@ export function addSite(data) {
// 修改实训场地
export function updateSite(data) {
return request({
url: '/train/site',
url: '/train/training-site/' + data.siteId,
method: 'put',
data: data
})
}
// 删除实训场地
export function delSite(siteId) {
return request({
url: '/train/site/' + siteId,
method: 'delete'
})
}
+1 -9
View File
@@ -29,16 +29,8 @@ export function addTeacher(data) {
// 修改教师信息
export function updateTeacher(data) {
return request({
url: '/train/teacher',
url: '/train/teacher/' + (data.tId || ''),
method: 'put',
data: data
})
}
// 删除教师信息
export function delTeacher(tId) {
return request({
url: '/train/teacher/' + tId,
method: 'delete'
})
}
+74 -106
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="教室名称" prop="roomName">
<el-input
v-model="queryParams.roomName"
@@ -17,21 +17,12 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="容纳人数" prop="capacity">
<el-input
v-model="queryParams.capacity"
placeholder="请输入容纳人数"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建管理员账号"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="教室状态" prop="roomStatus">
<el-select v-model="queryParams.roomStatus" placeholder="教室状态" clearable>
<el-option label="正在使用" :value="0" />
<el-option label="可用空闲" :value="1" />
<el-option label="维护中" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -42,74 +33,55 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:classroom:edit']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:info:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['train:classroom:edit']"
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:info:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:info:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:info:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="infoList" @selection-change="handleSelectionChange">
<el-table v-loading="loading" :data="classroomList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="教室ID主键" align="center" prop="classRoomId" />
<el-table-column label="教室ID" align="center" prop="classRoomId" :show-overflow-tooltip="true" />
<el-table-column label="教室名称" align="center" prop="roomName" />
<el-table-column label="教室位置" align="center" prop="roomLocation" />
<el-table-column label="容纳人数" align="center" prop="capacity" />
<el-table-column label="0 正在使用 1 可用,空闲 2 其他" align="center" prop="roomStatus" />
<el-table-column label="创建管理员账号" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="核定容纳人数" align="center" prop="capacity">
<template slot-scope="scope">
<el-tag type="info">{{ scope.row.capacity }} 人</el-tag>
</template>
</el-table-column>
<el-table-column label="教室状态" align="center" prop="roomStatus">
<template slot-scope="scope">
<el-tag v-if="scope.row.roomStatus === 1" type="success">可用空闲</el-tag>
<el-tag v-else-if="scope.row.roomStatus === 0" type="danger">正在使用</el-tag>
<el-tag v-else type="info">维护中</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:classroom:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:info:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:info:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -123,22 +95,26 @@
/>
<!-- 添加或修改教室信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="教室名称" prop="roomName">
<el-input v-model="form.roomName" placeholder="请输入教室名称" />
<el-input v-model="form.roomName" placeholder="请输入教室名称" maxlength="60" />
</el-form-item>
<el-form-item label="教室位置" prop="roomLocation">
<el-input v-model="form.roomLocation" placeholder="请输入教室位置" />
<el-input v-model="form.roomLocation" placeholder="请输入教室位置" maxlength="100" />
</el-form-item>
<el-form-item label="容纳人数" prop="capacity">
<el-input v-model="form.capacity" placeholder="请输入容纳人数" />
<el-input-number v-model="form.capacity" :min="1" :max="10000" placeholder="容纳人数" style="width: 100%" />
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建管理员账号" />
<el-form-item label="教室状态" prop="roomStatus">
<el-radio-group v-model="form.roomStatus">
<el-radio :label="1">可用空闲</el-radio>
<el-radio :label="0">正在使用</el-radio>
<el-radio :label="2">维护中</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -150,10 +126,10 @@
</template>
<script>
import { listInfo, getInfo, delInfo, addInfo, updateInfo } from "@/api/train/info"
import { listClassroom, getClassroom, addClassroom, updateClassroom } from '@/api/train/classroom'
export default {
name: "Info",
name: 'Classroom',
data() {
return {
// 遮罩层
@@ -169,9 +145,9 @@ export default {
// 总条数
total: 0,
// 教室信息表格数据
infoList: [],
classroomList: [],
// 弹出层标题
title: "",
title: '',
// 是否显示弹出层
open: false,
// 查询参数
@@ -181,19 +157,30 @@ export default {
roomName: null,
roomLocation: null,
capacity: null,
roomStatus: null,
createUser: null,
roomStatus: null
},
// 表单参数
form: {},
// 表单校验
rules: {
roomName: [
{ required: true, message: "教室名称不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建管理员账号不能为空", trigger: "blur" }
{ required: true, message: '教室名称不能为空', trigger: 'blur' }
],
capacity: [
{ required: true, message: '容纳人数不能为空', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('容纳人数不能为空'))
} else if (Number(value) <= 0) {
callback(new Error('容纳人数必须大于0'))
} else {
callback()
}
},
trigger: ['blur', 'change']
}
]
}
}
},
@@ -204,8 +191,8 @@ export default {
/** 查询教室信息列表 */
getList() {
this.loading = true
listInfo(this.queryParams).then(response => {
this.infoList = response.rows
listClassroom(this.queryParams).then(response => {
this.classroomList = response.rows
this.total = response.total
this.loading = false
})
@@ -221,14 +208,11 @@ export default {
classRoomId: null,
roomName: null,
roomLocation: null,
capacity: null,
roomStatus: null,
createUser: null,
createTime: null,
updateTime: null,
capacity: 50,
roomStatus: 1,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
@@ -237,66 +221,50 @@ export default {
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.classRoomId)
this.single = selection.length!==1
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加教室信息"
this.title = '添加教室信息'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const classRoomId = row.classRoomId || this.ids
getInfo(classRoomId).then(response => {
getClassroom(classRoomId).then(response => {
this.form = response.data
this.open = true
this.title = "修改教室信息"
this.title = '修改教室信息'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.classRoomId != null) {
updateInfo(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
updateClassroom(this.form).then(response => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addInfo(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
addClassroom(this.form).then(response => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const classRoomIds = row.classRoomId || this.ids
this.$modal.confirm('是否确认删除教室信息编号为"' + classRoomIds + '"的数据项?').then(function() {
return delInfo(classRoomIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/info/export', {
...this.queryParams
}, `info_${new Date().getTime()}.xlsx`)
}
}
}
+97 -129
View File
@@ -1,6 +1,14 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="课程编码" prop="cCode">
<el-input
v-model="queryParams.cCode"
placeholder="请输入课程统一业务编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="课程名称" prop="cName">
<el-input
v-model="queryParams.cName"
@@ -9,21 +17,19 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="课程时长 (分钟)" prop="cDuration">
<el-form-item label="课程类型" prop="cType">
<el-input
v-model="queryParams.cDuration"
placeholder="请输入课程时长 (分钟)"
v-model="queryParams.cType"
placeholder="请输入课程类型"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建人 (管理员 / 教师)"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="课程状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -34,79 +40,54 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:course:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:course:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['train:course:edit']"
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:course:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:course:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:course:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="课程编号主键" align="center" prop="cId" />
<el-table-column label="课程名称" align="center" prop="cName" />
<el-table-column label="课程类型" align="center" prop="cType" />
<el-table-column label="课程时长 (分钟)" align="center" prop="cDuration" />
<el-table-column label="教学目标" align="center" prop="teachingObjective" />
<el-table-column label="课程内容" align="center" prop="courseContent" />
<el-table-column label="配套文档,多个逗号分隔" align="center" prop="attachDoc" />
<el-table-column label="配套视频地址" align="center" prop="attachVideo" />
<el-table-column label="配套模型链接" align="center" prop="attachModelLink" />
<el-table-column label="关联3D设备拆解模型、实训场景资源ID,多ID逗号分隔" align="center" prop="modelResourceIds" />
<el-table-column label="创建人 (管理员 / 教师)" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="课程编码" align="center" prop="cCode" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="课程名称" align="center" prop="cName" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="课程类型" align="center" prop="cType" width="120" />
<el-table-column label="学时" align="center" prop="cDuration" width="80" />
<el-table-column label="教学目标" align="center" prop="teachingObjective" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="课程内容" align="center" prop="courseContent" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="status" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:course:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:course:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:course:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -120,37 +101,34 @@
/>
<!-- 添加或修改课程信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="课程名称" prop="cName">
<el-input v-model="form.cName" placeholder="请输入课程名称" />
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<el-form-item label="课程编码" prop="cCode">
<el-input v-model="form.cCode" placeholder="请输入课程统一业务编码 (如 KC-2026-001)" maxlength="50" />
</el-form-item>
<el-form-item label="课程时长 (分钟)" prop="cDuration">
<el-input v-model="form.cDuration" placeholder="请输入课程时长 (分钟)" />
<el-form-item label="课程名称" prop="cName">
<el-input v-model="form.cName" placeholder="请输入课程名称" maxlength="100" />
</el-form-item>
<el-form-item label="课程类型" prop="cType">
<el-input v-model="form.cType" placeholder="请输入课程类型 (如 理论课/实操课)" maxlength="30" />
</el-form-item>
<el-form-item label="课程学时" prop="cDuration">
<el-input-number v-model="form.cDuration" :min="1" :max="9999" placeholder="请输入课程学时" style="width: 100%;" />
</el-form-item>
<el-form-item label="教学目标" prop="teachingObjective">
<el-input v-model="form.teachingObjective" type="textarea" placeholder="请输入内容" />
<el-input v-model="form.teachingObjective" type="textarea" :rows="3" placeholder="请输入教学目标" />
</el-form-item>
<el-form-item label="课程内容">
<editor v-model="form.courseContent" :min-height="192"/>
<el-form-item label="课程内容" prop="courseContent">
<el-input v-model="form.courseContent" type="textarea" :rows="4" placeholder="请输入课程内容/摘要" />
</el-form-item>
<el-form-item label="配套文档,多个逗号分隔" prop="attachDoc">
<el-input v-model="form.attachDoc" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="配套视频地址" prop="attachVideo">
<el-input v-model="form.attachVideo" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="配套模型链接" prop="attachModelLink">
<el-input v-model="form.attachModelLink" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="关联3D设备拆解模型、实训场景资源ID,多ID逗号分隔" prop="modelResourceIds">
<el-input v-model="form.modelResourceIds" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建人 (管理员 / 教师)" />
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -162,11 +140,23 @@
</template>
<script>
import { listCourse, getCourse, delCourse, addCourse, updateCourse } from "@/api/train/course"
import { listCourse, getCourse, addCourse, updateCourse } from '@/api/train/course'
export default {
name: "Course",
name: 'Course',
data() {
const validateDuration = (rule, value, callback) => {
if (value !== undefined && value !== null && value !== '') {
const num = Number(value)
if (!Number.isInteger(num) || num <= 0) {
callback(new Error('课程学时必须为正整数'))
} else {
callback()
}
} else {
callback()
}
}
return {
// 遮罩层
loading: true,
@@ -174,8 +164,6 @@ export default {
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
@@ -183,34 +171,33 @@ export default {
// 课程信息表格数据
courseList: [],
// 弹出层标题
title: "",
title: '',
// 是否显示弹出层
open: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
cCode: null,
cName: null,
cType: null,
cDuration: null,
teachingObjective: null,
courseContent: null,
attachDoc: null,
attachVideo: null,
attachModelLink: null,
modelResourceIds: null,
createUser: null,
status: null
},
// 表单参数
form: {},
// 表单校验
rules: {
cCode: [
{ required: true, message: '课程编码不能为空', trigger: 'blur' },
{ max: 50, message: '课程编码长度不能超过50个字符', trigger: 'blur' }
],
cName: [
{ required: true, message: "课程名称不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建人 (管理员 / 教师)不能为空", trigger: "blur" }
{ required: true, message: '课程名称不能为空', trigger: 'blur' },
{ max: 100, message: '课程名称长度不能超过100个字符', trigger: 'blur' }
],
cDuration: [
{ validator: validateDuration, trigger: 'blur' }
]
}
}
},
@@ -236,21 +223,16 @@ export default {
reset() {
this.form = {
cId: null,
cCode: null,
cName: null,
cType: null,
cDuration: null,
teachingObjective: null,
courseContent: null,
attachDoc: null,
attachVideo: null,
attachModelLink: null,
modelResourceIds: null,
createUser: null,
createTime: null,
updateTime: null,
status: 1,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
@@ -259,20 +241,19 @@ export default {
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.cId)
this.single = selection.length!==1
this.multiple = !selection.length
this.single = selection.length !== 1
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加课程信息"
this.title = '添加课程'
},
/** 修改按钮操作 */
handleUpdate(row) {
@@ -280,45 +261,32 @@ export default {
const cId = row.cId || this.ids
getCourse(cId).then(response => {
this.form = response.data
if (this.form.status === undefined || this.form.status === null) {
this.form.status = 1
}
this.open = true
this.title = "修改课程信息"
this.title = '修改课程'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.cId != null) {
updateCourse(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addCourse(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const cIds = row.cId || this.ids
this.$modal.confirm('是否确认删除课程信息编号为"' + cIds + '"的数据项?').then(function() {
return delCourse(cIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/course/export', {
...this.queryParams
}, `course_${new Date().getTime()}.xlsx`)
}
}
}
+155 -261
View File
@@ -1,68 +1,30 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="开班名称" prop="planName">
<el-input
v-model="queryParams.planName"
placeholder="请输入开班名称"
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="88px"
>
<el-form-item label="培训班名称" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入培训班名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="计划状态" prop="planStatus">
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开班时间">
<el-date-picker
v-model="startTimeRange"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="所属课程编号,关联train_course.c_id" prop="cId">
<el-input
v-model="queryParams.cId"
placeholder="请输入所属课程编号,关联train_course.c_id"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="开班时间" prop="startTime">
<el-date-picker clearable
v-model="queryParams.startTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择开班时间">
</el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker clearable
v-model="queryParams.endTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择结束时间">
</el-date-picker>
</el-form-item>
<el-form-item label="培训地点" prop="trainAddress">
<el-input
v-model="queryParams.trainAddress"
placeholder="请输入培训地点"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="授课教师工号,关联train_teacher.t_id" prop="tId">
<el-input
v-model="queryParams.tId"
placeholder="请输入授课教师工号,关联train_teacher.t_id"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="招生人数" prop="recruitNum">
<el-input
v-model="queryParams.recruitNum"
placeholder="请输入招生人数"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建管理员账号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
@@ -74,141 +36,99 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:plan:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:plan:add']"
>新增</el-button>
>新增计划</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:plan:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:plan:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:plan:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="planList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="开班计划ID主键" align="center" prop="planId" />
<el-table-column label="开班名称" align="center" prop="planName" />
<el-table-column label="所属课程编号,关联train_course.c_id" align="center" prop="cId" />
<el-table-column label="开班时间" align="center" prop="startTime" width="180">
<el-table v-loading="loading" :data="planList">
<el-table-column label="计划编号" align="center" prop="planId" min-width="220" show-overflow-tooltip />
<el-table-column label="培训班名称" align="left" prop="planName" min-width="230" show-overflow-tooltip />
<el-table-column label="状态" align="center" prop="planStatus" width="100">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
<el-tag :type="statusTagType(scope.row.planStatus)">{{ statusLabel(scope.row.planStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" prop="endTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="培训地点" align="center" prop="trainAddress" />
<el-table-column label="授课教师工号,关联train_teacher.t_id" align="center" prop="tId" />
<el-table-column label="招生人数" align="center" prop="recruitNum" />
<el-table-column label="报名条件" align="center" prop="applyCondition" />
<el-table-column label="0 未开始 1 进行中 2 已结束 3 已取消" align="center" prop="planStatus" />
<el-table-column label="创建管理员账号" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="开班时间" align="center" prop="startTime" width="165" />
<el-table-column label="结束时间" align="center" prop="endTime" width="165" />
<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">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isLocked(scope.row.planStatus)"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:plan:edit']"
>修改</el-button>
<el-button
v-hasPermi="['train:plan:complete']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:plan:remove']"
>删除</el-button>
icon="el-icon-finished"
:disabled="isLocked(scope.row.planStatus)"
@click="handleComplete(scope.row)"
>结束计划</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改开班计划对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="开班名称" prop="planName">
<el-input v-model="form.planName" placeholder="请输入开班名称" />
</el-form-item>
<el-form-item label="所属课程编号,关联train_course.c_id" prop="cId">
<el-input v-model="form.cId" placeholder="请输入所属课程编号,关联train_course.c_id" />
</el-form-item>
<el-form-item label="开班时间" prop="startTime">
<el-date-picker clearable
v-model="form.startTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择开班时间">
</el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker clearable
v-model="form.endTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择结束时间">
</el-date-picker>
<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">
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训班名称" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开班时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择开班时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择结束时间" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="报到截止" prop="reportDeadline">
<el-date-picker v-model="form.reportDeadline" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择报到截止时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划人数" prop="recruitNum">
<el-input-number v-model="form.recruitNum" :min="1" :precision="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="培训地点" prop="trainAddress">
<el-input v-model="form.trainAddress" placeholder="请输入培训地点" />
</el-form-item>
<el-form-item label="授课教师工号,关联train_teacher.t_id" prop="tId">
<el-input v-model="form.tId" placeholder="请输入授课教师工号,关联train_teacher.t_id" />
</el-form-item>
<el-form-item label="招生人数" prop="recruitNum">
<el-input v-model="form.recruitNum" placeholder="请输入招生人数" />
<el-input v-model="form.trainAddress" maxlength="200" show-word-limit placeholder="请输入培训地点" />
</el-form-item>
<el-form-item label="报名条件" prop="applyCondition">
<el-input v-model="form.applyCondition" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建管理员账号" />
<el-input v-model="form.applyCondition" type="textarea" :rows="3" placeholder="请输入报名条件" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -220,67 +140,38 @@
</template>
<script>
import { listPlan, getPlan, delPlan, addPlan, updatePlan } from "@/api/train/plan"
import { addPlan, completePlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
export default {
name: "Plan",
name: 'Plan',
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 开班计划表格数据
planList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
startTimeRange: [],
title: '',
open: false,
// 查询参数
statusOptions: [
{ value: 0, label: '未开始' },
{ value: 1, label: '进行中' },
{ value: 2, label: '已结束' },
{ value: 3, label: '已取消' }
],
queryParams: {
pageNum: 1,
pageSize: 10,
planName: null,
cId: null,
startTime: null,
endTime: null,
trainAddress: null,
tId: null,
recruitNum: null,
applyCondition: null,
planStatus: null,
createUser: null,
planStatus: null
},
// 表单参数
form: {},
// 表单校验
rules: {
planName: [
{ required: true, message: "开班名称不能为空", trigger: "blur" }
],
cId: [
{ required: true, message: "所属课程编号,关联train_course.c_id不能为空", trigger: "blur" }
],
startTime: [
{ required: true, message: "开班时间不能为空", trigger: "blur" }
],
endTime: [
{ required: true, message: "结束时间不能为空", trigger: "blur" }
],
tId: [
{ required: true, message: "授课教师工号,关联train_teacher.t_id不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建管理员账号不能为空", trigger: "blur" }
],
planName: [{ required: true, message: '培训班名称不能为空', trigger: 'blur' }],
startTime: [{ required: true, message: '开班时间不能为空', trigger: 'change' }],
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
}
}
},
@@ -288,107 +179,110 @@ export default {
this.getList()
},
methods: {
/** 查询开班计划列表 */
getList() {
this.loading = true
listPlan(this.queryParams).then(response => {
const query = { ...this.queryParams }
if (this.startTimeRange && this.startTimeRange.length === 2) {
query.startTimeBegin = this.startTimeRange[0]
query.startTimeEnd = this.startTimeRange[1]
}
listPlan(query).then(response => {
this.planList = response.rows
this.total = response.total
}).finally(() => {
this.loading = false
})
},
// 取消按钮
statusLabel(status) {
const option = this.statusOptions.find(item => item.value === status)
return option ? option.label : '未知'
},
statusTagType(status) {
return { 0: 'info', 1: 'primary', 2: 'success', 3: 'danger' }[status] || 'info'
},
isLocked(status) {
return status === 2 || status === 3
},
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
planId: null,
planName: null,
cId: null,
startTime: null,
endTime: null,
reportDeadline: null,
trainAddress: null,
tId: null,
recruitNum: null,
recruitNum: 1,
applyCondition: null,
planStatus: null,
createUser: null,
createTime: null,
updateTime: null,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.startTimeRange = []
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.planId)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加开班计划"
this.title = '创建培训计划'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const planId = row.planId || this.ids
getPlan(planId).then(response => {
getPlan(row.planId).then(response => {
this.form = response.data
this.open = true
this.title = "修改开班计划"
this.title = '编辑培训计划'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.planId != null) {
updatePlan(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addPlan(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.startTime >= this.form.endTime) {
this.$modal.msgError('结束时间必须晚于开班时间')
return
}
if (this.form.reportDeadline > this.form.startTime) {
this.$modal.msgError('报到截止时间不得晚于开班时间')
return
}
const request = this.form.planId
? updatePlan(this.form.planId, this.planPayload())
: addPlan(this.planPayload())
request.then(() => {
this.$modal.msgSuccess(this.form.planId ? '修改成功' : '创建成功')
this.open = false
this.getList()
})
})
},
/** 删除按钮操作 */
handleDelete(row) {
const planIds = row.planId || this.ids
this.$modal.confirm('是否确认删除开班计划编号为"' + planIds + '"的数据项?').then(function() {
return delPlan(planIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
planPayload() {
return {
planName: this.form.planName,
startTime: this.form.startTime,
endTime: this.form.endTime,
reportDeadline: this.form.reportDeadline,
trainAddress: this.form.trainAddress,
recruitNum: this.form.recruitNum,
applyCondition: this.form.applyCondition,
remark: this.form.remark
}
},
/** 导出按钮操作 */
handleExport() {
this.download('train/plan/export', {
...this.queryParams
}, `plan_${new Date().getTime()}.xlsx`)
handleComplete(row) {
this.$modal.confirm('结束计划后将锁定计划信息并保留全部历史记录,是否继续?').then(() => {
return completePlan(row.planId)
}).then(() => {
this.$modal.msgSuccess('计划已结束')
this.getList()
}).catch(() => {})
}
}
}
+68 -104
View File
@@ -1,7 +1,7 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="实训场地名称" prop="siteName">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="场地名称" prop="siteName">
<el-input
v-model="queryParams.siteName"
placeholder="请输入实训场地名称"
@@ -17,21 +17,11 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="最大容纳人数" prop="capacity">
<el-input
v-model="queryParams.capacity"
placeholder="请输入最大容纳人数"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建管理员账号"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="场地状态" prop="siteStatus">
<el-select v-model="queryParams.siteStatus" placeholder="场地状态" clearable>
<el-option label="可用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -42,75 +32,55 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:site:edit']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:site:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['train:site:edit']"
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:site:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:site:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:site:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="siteList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="实训场地ID主键" align="center" prop="siteId" />
<el-table-column label="场地ID" align="center" prop="siteId" :show-overflow-tooltip="true" />
<el-table-column label="实训场地名称" align="center" prop="siteName" />
<el-table-column label="场地位置" align="center" prop="siteLocation" />
<el-table-column label="最大容纳人数" align="center" prop="capacity" />
<el-table-column label="场地配套设备:VR、3D拆解、3D巡检设备" align="center" prop="siteDevice" />
<el-table-column label="0 停用 1 可用" align="center" prop="siteStatus" />
<el-table-column label="创建管理员账号" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="最大容纳人数" align="center" prop="capacity">
<template slot-scope="scope">
<el-tag type="info">{{ scope.row.capacity }} 人</el-tag>
</template>
</el-table-column>
<el-table-column label="配套设备描述" align="center" prop="siteDevice" :show-overflow-tooltip="true" />
<el-table-column label="场地状态" align="center" prop="siteStatus">
<template slot-scope="scope">
<el-tag v-if="scope.row.siteStatus === 1" type="success">可用</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:site:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:site:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:site:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -124,25 +94,28 @@
/>
<!-- 添加或修改实训场地对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="实训场地名称" prop="siteName">
<el-input v-model="form.siteName" placeholder="请输入实训场地名称" />
<el-input v-model="form.siteName" placeholder="请输入实训场地名称 (如 VR演练区)" maxlength="80" />
</el-form-item>
<el-form-item label="场地位置" prop="siteLocation">
<el-input v-model="form.siteLocation" placeholder="请输入场地位置" />
<el-input v-model="form.siteLocation" placeholder="请输入场地位置" maxlength="100" />
</el-form-item>
<el-form-item label="最大容纳人数" prop="capacity">
<el-input v-model="form.capacity" placeholder="请输入最大容纳人数" />
<el-input-number v-model="form.capacity" :min="1" :max="10000" placeholder="最大容纳人数" style="width: 100%" />
</el-form-item>
<el-form-item label="场地配套设备:VR、3D拆解、3D巡检设备" prop="siteDevice">
<el-input v-model="form.siteDevice" type="textarea" placeholder="请输入内容" />
<el-form-item label="场地配套设备" prop="siteDevice">
<el-input v-model="form.siteDevice" type="textarea" placeholder="例如:VR头显12套、3D数字孪生演练台等" :rows="3" />
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建管理员账号" />
<el-form-item label="场地状态" prop="siteStatus">
<el-radio-group v-model="form.siteStatus">
<el-radio :label="1">可用</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -154,10 +127,10 @@
</template>
<script>
import { listSite, getSite, delSite, addSite, updateSite } from "@/api/train/site"
import { listSite, getSite, addSite, updateSite } from '@/api/train/site'
export default {
name: "Site",
name: 'TrainingSite',
data() {
return {
// 遮罩层
@@ -175,7 +148,7 @@ export default {
// 实训场地表格数据
siteList: [],
// 弹出层标题
title: "",
title: '',
// 是否显示弹出层
open: false,
// 查询参数
@@ -185,20 +158,30 @@ export default {
siteName: null,
siteLocation: null,
capacity: null,
siteDevice: null,
siteStatus: null,
createUser: null,
siteStatus: null
},
// 表单参数
form: {},
// 表单校验
rules: {
siteName: [
{ required: true, message: "实训场地名称不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建管理员账号不能为空", trigger: "blur" }
{ required: true, message: '实训场地名称不能为空', trigger: 'blur' }
],
capacity: [
{ required: true, message: '最大容纳人数不能为空', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('最大容纳人数不能为空'))
} else if (Number(value) <= 0) {
callback(new Error('最大容纳人数必须大于0'))
} else {
callback()
}
},
trigger: ['blur', 'change']
}
]
}
}
},
@@ -226,15 +209,12 @@ export default {
siteId: null,
siteName: null,
siteLocation: null,
capacity: null,
capacity: 30,
siteDevice: null,
siteStatus: null,
createUser: null,
createTime: null,
updateTime: null,
siteStatus: 1,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
@@ -243,20 +223,20 @@ export default {
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.siteId)
this.single = selection.length!==1
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加实训场地"
this.title = '添加实训场地'
},
/** 修改按钮操作 */
handleUpdate(row) {
@@ -265,44 +245,28 @@ export default {
getSite(siteId).then(response => {
this.form = response.data
this.open = true
this.title = "修改实训场地"
this.title = '修改实训场地'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.siteId != null) {
updateSite(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addSite(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const siteIds = row.siteId || this.ids
this.$modal.confirm('是否确认删除实训场地编号为"' + siteIds + '"的数据项?').then(function() {
return delSite(siteIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/site/export', {
...this.queryParams
}, `site_${new Date().getTime()}.xlsx`)
}
}
}
+81 -145
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="教师姓名" prop="tName">
<el-input
v-model="queryParams.tName"
@@ -9,34 +9,18 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="性别:男 / 女" prop="tGender">
<el-form-item label="所属单位" prop="orgName">
<el-input
v-model="queryParams.tGender"
placeholder="请输入性别:男 / 女"
v-model="queryParams.orgName"
placeholder="请输入所属单位"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="出生日期" prop="tBirth">
<el-date-picker clearable
v-model="queryParams.tBirth"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择出生日期">
</el-date-picker>
</el-form-item>
<el-form-item label="所属院系" prop="tDepartment">
<el-form-item label="职务职称" prop="jobTitle">
<el-input
v-model="queryParams.tDepartment"
placeholder="请输入所属院系"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="职称:讲师 / 副教授 / 教授" prop="tTitle">
<el-input
v-model="queryParams.tTitle"
placeholder="请输入职称:讲师 / 副教授 / 教授"
v-model="queryParams.jobTitle"
placeholder="请输入职务/职称"
clearable
@keyup.enter.native="handleQuery"
/>
@@ -49,21 +33,11 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="邮箱" prop="tEmail">
<el-input
v-model="queryParams.tEmail"
placeholder="请输入邮箱"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建人 (管理员 / 教师)"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="教师状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -74,81 +48,59 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:teacher:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:teacher:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['train:teacher:edit']"
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:teacher:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:teacher:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:teacher:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="teacherList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="教师工号" align="center" prop="tId" />
<el-table-column label="教师姓名" align="center" prop="tName" />
<el-table-column label="性别:男 / 女" align="center" prop="tGender" />
<el-table-column label="出生日期" align="center" prop="tBirth" width="180">
<el-table-column label="性别" align="center" prop="tGender" width="80" />
<el-table-column label="出生日期" align="center" prop="tBirth" width="120">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.tBirth, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="所属院系" align="center" prop="tDepartment" />
<el-table-column label="职称:讲师 / 副教授 / 教授" align="center" prop="tTitle" />
<el-table-column label="联系电话" align="center" prop="tPhone" />
<el-table-column label="邮箱" align="center" prop="tEmail" />
<el-table-column label="创建人 (管理员 / 教师)" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="所属单位" align="center" prop="orgName" :show-overflow-tooltip="true" />
<el-table-column label="职务/职称" align="center" prop="jobTitle" :show-overflow-tooltip="true" />
<el-table-column label="联系电话" align="center" prop="tPhone" width="130" />
<el-table-column label="邮箱" align="center" prop="tEmail" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="status" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:teacher:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:teacher:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:teacher:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -162,39 +114,46 @@
/>
<!-- 添加或修改教师信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-dialog :title="title" :visible.sync="open" width="550px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<el-form-item label="教师姓名" prop="tName">
<el-input v-model="form.tName" placeholder="请输入教师姓名" />
<el-input v-model="form.tName" placeholder="请输入教师姓名" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="性别:男 / 女" prop="tGender">
<el-input v-model="form.tGender" placeholder="请输入性别:男 / 女" />
<el-form-item label="性别" prop="tGender">
<el-radio-group v-model="form.tGender">
<el-radio label="男">男</el-radio>
<el-radio label="女">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="出生日期" prop="tBirth">
<el-date-picker clearable
<el-date-picker
v-model="form.tBirth"
clearable
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择出生日期">
</el-date-picker>
placeholder="请选择出生日期"
/>
</el-form-item>
<el-form-item label="所属院系" prop="tDepartment">
<el-input v-model="form.tDepartment" placeholder="请输入所属院系" />
<el-form-item label="所属单位" prop="orgName">
<el-input v-model="form.orgName" placeholder="请输入所属单位/部门" maxlength="100" />
</el-form-item>
<el-form-item label="职称:讲师 / 副教授 / 教授" prop="tTitle">
<el-input v-model="form.tTitle" placeholder="请输入职称:讲师 / 副教授 / 教授" />
<el-form-item label="职务/职称" prop="jobTitle">
<el-input v-model="form.jobTitle" placeholder="请输入职务/职称" maxlength="50" />
</el-form-item>
<el-form-item label="联系电话" prop="tPhone">
<el-input v-model="form.tPhone" placeholder="请输入联系电话" />
<el-input v-model="form.tPhone" placeholder="请输入11位手机号码" maxlength="20" />
</el-form-item>
<el-form-item label="邮箱" prop="tEmail">
<el-input v-model="form.tEmail" placeholder="请输入邮箱" />
<el-input v-model="form.tEmail" placeholder="请输入电子邮箱" maxlength="100" />
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建人 (管理员 / 教师)" />
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注信息" maxlength="500" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -206,10 +165,10 @@
</template>
<script>
import { listTeacher, getTeacher, delTeacher, addTeacher, updateTeacher } from "@/api/train/teacher"
import { listTeacher, getTeacher, addTeacher, updateTeacher } from '@/api/train/teacher'
export default {
name: "Teacher",
name: 'Teacher',
data() {
return {
// 遮罩层
@@ -218,8 +177,6 @@ export default {
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
@@ -227,7 +184,7 @@ export default {
// 教师信息表格数据
teacherList: [],
// 弹出层标题
title: "",
title: '',
// 是否显示弹出层
open: false,
// 查询参数
@@ -235,27 +192,25 @@ export default {
pageNum: 1,
pageSize: 10,
tName: null,
tGender: null,
tBirth: null,
tDepartment: null,
tTitle: null,
orgName: null,
jobTitle: null,
tPhone: null,
tEmail: null,
createUser: null,
status: null
},
// 表单参数
form: {},
// 表单校验
rules: {
tName: [
{ required: true, message: "教师姓名不能为空", trigger: "blur" }
{ required: true, message: '教师姓名不能为空', trigger: 'blur' },
{ max: 50, message: '教师姓名长度不能超过50个字符', trigger: 'blur' }
],
tDepartment: [
{ required: true, message: "所属院系不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建人 (管理员 / 教师)不能为空", trigger: "blur" }
tPhone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号码', trigger: 'blur' }
],
tEmail: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
]
}
}
},
@@ -282,18 +237,16 @@ export default {
this.form = {
tId: null,
tName: null,
tGender: null,
tGender: '男',
tBirth: null,
tDepartment: null,
tTitle: null,
orgName: null,
jobTitle: null,
tPhone: null,
tEmail: null,
createUser: null,
createTime: null,
updateTime: null,
status: 1,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
@@ -302,66 +255,49 @@ export default {
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.tId)
this.single = selection.length!==1
this.multiple = !selection.length
this.single = selection.length !== 1
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加教师信息"
this.title = '添加教师信息'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const tId = row.tId || this.ids
const tId = row.tId || this.ids[0]
getTeacher(tId).then(response => {
this.form = response.data
this.open = true
this.title = "修改教师信息"
this.title = '修改教师信息'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.tId != null) {
updateTeacher(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addTeacher(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const tIds = row.tId || this.ids
this.$modal.confirm('是否确认删除教师信息编号为"' + tIds + '"的数据项?').then(function() {
return delTeacher(tIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/teacher/export', {
...this.queryParams
}, `teacher_${new Date().getTime()}.xlsx`)
}
}
}
+20 -9
View File
@@ -107,15 +107,15 @@ flowchart LR
## 5. 一期规模与核心指标统计 (Review Round 1)
- **一期 API 总数:** 91 个
- **P0 优先级 (核心闭环必须实现):** 49 个
- **P1 优先级 (辅助支撑能力):** 40 个
- **一期 API 总数:** 89 个 (以正文真实定义的 HTTP Method + Path 物理 Endpoint 为准)
- **P0 优先级 (核心闭环必须实现):** 53 个
- **P1 优先级 (辅助支撑能力):** 34 个
- **P2 优先级 (依赖现场确认/延后):** 2 个
- **模块分布统计:**
- **Module A (人员与基础资源):** 38 个 (P0: 16, P1: 22, P2: 0)
- **Module B (开班计划与排课):** 25 个 (P0: 17, P1: 7, P2: 1)
- **Module A (人员与基础资源):** 38 个 (P0: 20, P1: 18, P2: 0)
- **Module B (开班计划与排课):** 24 个 (P0: 17, P1: 6, P2: 1)
- **Module C (住宿/门禁/考勤):** 13 个 (P0: 9, P1: 4, P2: 0)
- **Module D (成绩/学习/统计/画像):** 15 个 (P0: 7, P1: 7, P2: 1)
- **Module D (成绩/学习/统计/画像):** 14 个 (P0: 7, P1: 6, P2: 1)
- **外部 Adapter 接口数量:** 3 个 (`FaceAttendanceAdapter`, `DoorAccessAdapter`, `ExternalLearningAdapter`)
- **核心业务 Command API 数量:** 24 个 (包含 P0 核心 Command API 20 个 [19 个核心业务流程 Command + 1 个 P0 计划管理更新: `PUT /train/plans/{planId}`], P1 辅助 Command 4 个)
- **核心聚合 VO 数量:** 8 个
@@ -131,9 +131,9 @@ flowchart LR
为确保后续 Issue 拆分与开发聚焦于最小可用的完整培训业务闭环,特设立 **Phase 1 Minimal Contract** 视图。后续开发阶段将首先按此基线开展编码与单元测试:
### 6.1 P0 API 规模概览 (共 49 个)
- **Module A (16个):** 教师列表/详情/增/改;学员列表/详情/录入;课程列表/详情/新增;教室列表/详情/新增;场地列表/详情/新增;宿舍列表/详情/新增/选项。
- **Module B (17个):** 计划列表/增/查/改/完成/工作台;本期课程列表/增;参训名单列表/单增/批量导入/详情;新增课次/修改课次/课次详情/冲突预检/完整课表;报到确认。
### 6.1 P0 API 规模概览 (共 53 个)
- **Module A (20个):** 教师列表/详情/增/改;学员列表/详情/录入;课程列表/详情/新增;教室列表/详情/新增;场地列表/详情/新增;宿舍列表/详情/新增/选项。
- **Module B (17个):** 计划列表/增/查/改/完成/工作台;本期课程列表/增;参训名单列表/单增/批量导入/详情;新增课次/修改课次/冲突预检/完整课表;报到确认。
- **Module C (9个):** 批量预分配宿舍/单人调房/走读设置/住宿列表;门禁同步重试;签到/签退/人工修正/课次考勤名单。
- **Module D (7个):** 成绩项目列表/权重配置;录入成绩;成绩台账大表/学员成绩单;学习记录接收;计划统计概览。
@@ -173,3 +173,14 @@ flowchart LR
### 6.4 P0 Application Service 清单 (10 个 P0 业务编排 Service)
`PlanAppService`, `EnrollmentAppService`, `SchedulingAppService`, `AccommodationAppService`, `ReportAppService`, `AttendanceAppService`, `ScoreAppService`, `LearningAppService`, `StatisticsAppService`, `ExternalIntegrationAppService`。
---
## 7. 版本说明 (Version History)
### V1.0.2
1. 补录40008 CLASSROOM_NAME_DUPLICATED
2. 补录40009 SITE_NAME_DUPLICATED
3. 冻结ClassroomDetailVO.boards为0..N班牌摘要列表
4. 新增ClassroomBoardSummaryVO定义
5. Endpoint Method/Path/Priority/Permission/count均不变
+6 -2
View File
@@ -2,7 +2,7 @@
> **模块定位:** 支撑培训业务运转的基础主数据管理(Teacher, Student, Course, Resource, Classroom, Site, Board, Dormitory)。
> **设计特点:** 标准基础资料型对象,保留规范化的资源 CRUD 与关联管理接口,严格遵循敏感信息脱敏与业务校验。不引入审批流、多版本、复杂搜索等过度设计。
> **优先级统计:** 共 38 个 API (P0: 16个, P1: 22个, P2: 0个)。
> **优先级统计:** 共 38 个 API (P0: 20个, P1: 18个, P2: 0个)。
---
@@ -137,7 +137,11 @@
### 4.1 教室管理 (Classroom)
- `GET /train/classroom/list` 【P0】【确认】(分页查询教室,排课选普通教室必用)
- `GET /train/classroom/{classRoomId}` 【P0】【确认】(获取教室详情及绑定班牌)
- `POST /train/classroom` 【P0】【确认】(新增教室:`roomName`, `roomLocation`, `capacity`, `roomStatus`)
- **响应数据:** `ClassroomDetailVO` (教室状态 `roomStatus` 语义为:0 正在使用, 1 可用空闲, 2 其他)
- **1:N 关联语义:** 一个 Classroom 可关联 0..N 个 ElectronicBoard(依据 Database V2,`train_electronic_board.bind_classroom_id` 没有 UNIQUE 约束)。严禁将关联关系弱化为 1:1,禁止在契约中定义 `electronicBoard`、`primaryBoard` 或 `preferredBoard`,严禁“取第一条班牌作为默认班牌”。
- **空值处理:** 当教室无绑定班牌时,`boards` 属性必须返回空列表 `[]`,不得返回 `null`。
- **端点边界:** 不新增独立子资源接口(如 `/classroom/{id}/boards`),班牌轻量摘要信息作为教室详情响应(`ClassroomDetailVO.boards`)内嵌返回。
- `POST /train/classroom` 【P0】【确认】(新增教室:`roomName`, `roomLocation`, `capacity`, `roomStatus` [0正在使用 1可用空闲 2其他])
- `PUT /train/classroom/{classRoomId}` 【P1】【确认】(修改教室)
### 4.2 实训场地管理 (Training Site)
@@ -2,7 +2,7 @@
> **模块定位:** 智慧培训业务核心流转引擎,贯穿“立项建班 → 课程配置 → 名单确定 → 课次编排与冲突检测 → 课程表生成 → 学员报到”。
> **设计原则:** 禁止退化为 5 张表的独立 CRUD,全面以“开班计划聚合工作台”与“业务 Command”为主线。
> **优先级统计:** 共 25 个 API (P0: 17个, P1: 7个, P2: 1个)。
> **优先级统计:** 共 24 个 API (P0: 17个, P1: 6个, P2: 1个)。
---
@@ -2,7 +2,7 @@
> **模块定位:** 培训业务成效与多维量化评估模块,贯穿“成绩项目与权重配置 → 成绩录入导入台账 → 外部学习数据幂等接入 → 统计概览 → 学员简单画像”。
> **核心原则:** 统计一期不建持久化表、成绩权重总和强校验 100%、学习记录事件幂等防重、加权综合分作为核心结果、及格判定与画像标签保持现场弹性。
> **优先级统计:** 共 15 个 API (P0: 7个, P1: 7个, P2: 1个)。
> **优先级统计:** 共 14 个 API (P0: 7个, P1: 6个, P2: 1个)。
---
+44 -3
View File
@@ -43,15 +43,21 @@
| `days` | List<TimetableDayVO> | 按日期分组的课次排期集合 |
| `days[].date` | String | 日期 (yyyy-MM-dd) |
| `days[].dayOfWeek` | Integer | 星期几 (1~7) |
| `days[].lessons` | List<TimetableLessonItemVO> | 当日课次列表 |
| `lessons[].lessonId` | Long | 课次 ID |
| `lessons[].lessonId` | Long | 课次主键标识 |
| `lessons[].planCourseId` | Long | 本期计划课程关联标识 (回显表单必需) |
| `lessons[].courseName` | String | 本期课程名称 |
| `lessons[].tId` | String | 授课教师工号/标识 (回显表单必需) |
| `lessons[].teacherName` | String | 授课教师姓名 |
| `lessons[].startTime` | Date | 课次开始时间 |
| `lessons[].endTime` | Date | 课次结束时间 |
| `lessons[].venueType` | Integer | 场地类型 (1教室 2实训场地) |
| `lessons[].venueName` | String | 教室名称或实训场地名称 |
| `lessons[].classRoomId` | Long | 普通教室物理标识 (venueType=1 时有效,回显表单必需) |
| `lessons[].classRoomName` | String | 普通教室名称 |
| `lessons[].siteId` | Long | 实训场地物理标识 (venueType=2 时有效,回显表单必需) |
| `lessons[].siteName` | String | 实训场地名称 |
| `lessons[].venueName` | String | 场地聚合展示名称 |
| `lessons[].boardSn` | String | 关联电子班牌 SN 号(若有) |
| `lessons[].remark` | String | 课次备注 (回显表单必需) |
---
@@ -133,6 +139,41 @@
---
### 1.10 教室详情视图:`ClassroomDetailVO` 【P0】【确认】
- **定位:** 查看教室详情及关联班牌列表(对应 `GET /train/classroom/{classRoomId}`)。
- **1:N 绑定关系:** 一个 Classroom 可关联 0..N 个 ElectronicBoard(依据 Database V2,`train_electronic_board.bind_classroom_id` 没有 UNIQUE 约束)。禁止契约定义 `electronicBoard`、`primaryBoard`、`preferredBoard`,严禁“取第一条班牌作为默认班牌”。
- **字段规范:**
| 字段名 | 类型 | 说明 |
|:---|:---|:---|
| `classRoomId` | String | 教室标识 |
| `roomName` | String | 教室名称 |
| `roomLocation` | String | 教室物理位置 |
| `capacity` | Long | 容纳人数 |
| `roomStatus` | Integer | 教室状态:0正在使用 1可用空闲 2其他 |
| `remark` | String | 备注信息 |
| `createTime` | DateTime | 创建时间 |
| `boards` | List<ClassroomBoardSummaryVO> | 绑定的班牌摘要列表。无绑定班牌时返回 `[]`,不得返回 `null` |
---
### 1.11 班牌摘要视图:`ClassroomBoardSummaryVO` 【P0】【确认】
- **定位:** 教室详情中内嵌的轻量班牌摘要对象(1:N 响应集合元素)。
- **字段规范与语义:**
| 字段名 | 类型 | 说明 | 语义来源 |
|:---|:---|:---|:---|
| `boardId` | String | 系统内部稳定班牌ID | `train_electronic_board.board_id` |
| `boardSn` | String | 设备硬件序列号 | `train_electronic_board.board_sn` |
| `boardStatus` | Integer | 班牌当前业务/设备状态 | 沿用 `train_electronic_board.board_status` 语义 |
> **边界红线:**
> 1. 本 VO 仅且只包含 `boardId`、`boardSn`、`boardStatus` 三个字段;
> 2. 严禁加入 `boardIp`、`boardContent`、`bindPlanId`、`lastHeartbeat`、`createUser`、`updateTime` 以及完整 `TrainElectronicBoard` 实体字段;
> 3. 无绑定班牌时,父级 `boards` 返回空列表 `[]`,不得返回 `null`。
---
## 2. 核心业务命令与请求 DTO (Command & Request DTO)
### 2.1 P0 核心命令与请求 DTO (14 个)
@@ -108,6 +108,8 @@
| **40005** | `BOARD_SN_DUPLICATED` | 电子班牌 SN 设备编号已存在 | 电子班牌 |
| **40006** | `DORM_ROOM_DUPLICATED` | 同一楼栋下该宿舍房号已存在 | 宿舍房间 |
| **40007** | `DORM_CAPACITY_CANNOT_SHRINK` | 房间容量不可调整至小于当前已入住人数 | 宿舍房间 |
| **40008** | `CLASSROOM_NAME_DUPLICATED` | 教室名称已存在 | 教室 |
| **40009** | `SITE_NAME_DUPLICATED` | 实训场地名称已存在 | 实训场地 |
| **40010** | `PLAN_TIME_INVALID` | 培训计划起止时间不合法 (结束时间必须晚于开始时间) | 开班计划 (UC-01) |
| **40011** | `PLAN_NAME_DUPLICATED` | 同名培训计划已存在 | 开班计划 (UC-01) |
| **40012** | `PLAN_STATUS_LOCKED` | 当前培训计划已结训或取消,不允许修改 | 开班计划 (UC-02) |
@@ -138,3 +140,5 @@
| **40093** | `SCORE_PLAN_MISMATCH` | 业务不变量校验失败:成绩录入学员工号与科目所属班次不符 | 成绩录入 (UC-21) |
| **40100** | `STUDY_EVENT_INVALID` | 外部学习记录数据不合法 (结束时间早于开始时间或时长为负) | 学习接入 (UC-24) |
| **40110** | `PORTRAIT_NOT_FOUND` | 该学员画像尚未生成,请先执行画像刷新 | 学员画像 (UC-28) |
> **补录说明:** 错误码 `40008` (`CLASSROOM_NAME_DUPLICATED`) 与 `40009` (`SITE_NAME_DUPLICATED`) 属于 Issue Backlog 已有要求在错误码 Catalog 中的遗漏补录,不是新增业务需求。
+5 -5
View File
@@ -29,7 +29,7 @@
| **画像模型弹性化** | `05`, `06`, `11` | 固定四维雷达图 + 固定标签枚举 | 【确认】简单画像数据;采用弹性指标集 `metrics` + 标签集 `tags`;具体计算维度与标签标记为待现场确认 |
| **时效规则归位** | `03`, `08`, `10`, `11` | 6 项硬业务不变量 (含 Report Deadline) | **5 项跨表硬业务不变量** + **1 项报到生命周期策略 (Report Lifecycle Policy)**;支持 BLOCK / WARNING / OVERRIDE 三种模式 |
| **外部调用解耦规范** | `04`, `07`, `08`, `09` | 误表达 afterCommit 本身为异步,提及 Outbox/MQ | **严禁引入 MQ / Kafka / Outbox【扩展】**;明确为事务提交后通过 afterCommit 触发后续调用,一期建议经轻量异步执行器或事务外调用,外部失败绝不回滚已提交事务 |
| **全面优先级划分** | `00`, `01`, `02`, `03`, `04`, `05`, `06` | 91 个 API 未分级 | 全面划分:**P0 (49个)**、**P1 (40个)**、**P2 (2个)**;新增 **Phase 1 Minimal Contract** 视图 |
| **全面优先级划分** | `00`, `01`, `02`, `03`, `04`, `05`, `06` | 91 个 API 未分级 (V1.0原统计) | 全面划分:**P0 (53个)**、**P1 (34个)**、**P2 (2个)**,物理总数 **89 个**;新增 **Phase 1 Minimal Contract** 视图 |
| **业务Command口径统一** | `00`, `01`, `06` | 19个与20个数字模糊混淆 | 明确 **P0 核心 Command API 共 20 个**(包含 19 个核心业务流程 Command + 1 个 P0 计划管理更新 `PUT /train/plans/{planId}`),P1 辅助 Command 4 个 |
| **消除数据库绝对承诺** | `11`, `12` | 100%无需修改数据库 | 修正为客观评估:当前评估预计无需改 DB,采用“预计无/可能有/需重新评估”,若引入新持久化业务对象重新进入 DB Change Review |
@@ -48,12 +48,12 @@
## 4. API 优先级分级 (P0 / P1 / P2) 统计裁定
- **P0 API (共 49 个):** 一期核心闭环必须实现,包含:
- Module A 基础主数据核心 CRUD (16个);
- **P0 API (共 53 个):** 一期核心闭环必须实现,包含:
- Module A 基础主数据核心 CRUD (20个);
- Module B 开班、本期课程、参训名单导入、排课、冲突预检、完整课表、学员报到 (17个);
- Module C 批量宿舍预分配、调房、走读、住宿查看、门禁重试、签到、签退、考勤修正、课次考勤 (9个);
- Module D 成绩配置、打分录入、成绩台账大表、学员成绩单、外部学习记录接入、综合统计概览 (7个)。
- **P1 API (共 40 个):** 一期辅助管理能力(如主档启停用、教学资源独立管理、班牌注册管理、房态看板、门禁流水、人工补录考勤、成绩Excel导入、学习记录流水、学员训练详情、学员画像等)。
- **P1 API (共 34 个):** 一期辅助管理能力(如主档启停用、教学资源独立管理、班牌注册管理、房态看板、门禁流水、人工补录考勤、成绩Excel导入、学习记录流水、学员训练详情、学员画像等)。
- **P2 API (共 2 个):** 依赖现场确认或面向学员端的次要门户接口(`GET /train/student/my-training` 学员端门户、`GET /train/plans/{id}/statistics/score-distribution` 成绩区间下钻分析)。
---
@@ -143,7 +143,7 @@ UC-29 结束培训计划 (P0)
1. `afterCommit` 技术语义已全面纠正,杜绝“afterCommit本身是异步”的误读,明确外部失败不回滚已提交本地事务;
2. 彻底删除了“100%不改DB”绝对化承诺,建立了客观科学的三态评估与变更控制原则;
3. 统计口径完全统一并消除歧义:
- API 总数:**91 个** (P0: 49, P1: 40, P2: 2);
- API 总数:**89 个** (P0: 53, P1: 34, P2: 2);
- 核心业务 Command API:共 **24 个** (P0 核心 Command API 20 个 [包含 19 个核心业务流程 Command + 1 个 P0 计划管理更新 `PUT /train/plans/{planId}`],P1 辅助 Command 4 个);
- P0 业务编排 Service:**10 个 P0 Application Service**;
4. 领域核心标识 `enrollment_id`, `plan_course_id`, `lesson_id` 稳固落实,5 大跨表硬 Invariant 与 1 项 Lifecycle Policy 界限分明;
+82
View File
@@ -0,0 +1,82 @@
# RailwaySystem API Contract V1.0.1 Patch 说明
> **版本定位:** API Contract V1.0.1 一致性修订补丁
> **修订日期:** 2026-09-14
> **修订原则:** 仅针对技术负责人正式裁决 (CG-01 与 CG-02) 开展一致性修正,**不改变 PRD 业务范围,不修改 Database V2 物理结构**。
---
## PATCH-01:Module A P0 统计遗漏与全局口径修正 (CG-01)
### 1. 修订原因
- 在 API Contract V1.0 的 `02_module_a_base_resource_api.md` 头部摘要及总纲中,将 Module A 的 P0 统计误写为 16 个;
- 经核验正文详细定义,宿舍基础维护的 4 个 API(列表、详情、新增、选项)正文中均已明确打上【P0】标记;
- 业务主链路中,Accommodation 模块的批量预分配与调房强依赖可维护的宿舍基础房间资源、核定容量与性别隔离属性,故宿舍基础维护 4 个 API 属于一期不可或缺的 P0 核心能力。
### 2. 修订内容
- `docs/api-v2/02_module_a_base_resource_api.md` 头部统计由 `P0: 16个, P1: 22个` 修正为 `P0: 20个, P1: 18个`;
- 全局 P0 正式集合以逐 API 标记为准,从原宣称的 49 个正式收敛并确认为 **53 个**;
- 模块头部声明口径下 P1 由 40 个修正为 36 个(经 PATCH-03 进一步收敛物理条目为 34 个);P2 保持 2 个不变。
### 3. API 增删统计
- **新增 HTTP API:** 0
- **删除 HTTP API:** 0
---
## PATCH-02:TimetableLessonItemVO 字段补齐以支持课次编辑回显 (CG-02)
### 1. 修订原因
- 原 `docs/api-v2/06_dto_vo_catalog.md` 中定义的 `PlanTimetableVO`(`TimetableLessonItemVO`)仅包含了课程名称、教师名称、场地名称等展示文本,缺少前端进入课次编辑弹窗表单回显所需的实体主键/外键 ID;
- 若无外键 ID,前端从课表点击进入编辑时无法预填下拉选项,容易误导后续开发新增单查接口;
- 技术负责人裁决禁止新增 `GET /train/lessons/{lessonId}`,由前端从完整课表直接获取详情并编辑,故需在 VO 层补强数据字段。
### 2. 修订内容
在 `docs/api-v2/06_dto_vo_catalog.md` 的 `TimetableLessonItemVO` 中,严格沿用 Database V2 / API Contract 统一命名规范,补齐以下必要关联字段:
- `planCourseId`: Long (本期计划课程关联标识)
- `tId`: String (授课教师工号/标识)
- `venueType`: Integer (场地类型:1教室 2实训场地)
- `classRoomId`: Long (普通教室物理标识,venueType=1 时有效)
- `classRoomName`: String (普通教室名称)
- `siteId`: Long (实训场地物理标识,venueType=2 时有效)
- `siteName`: String (实训场地名称)
- `remark`: String (课次备注)
### 3. API 增删统计
- **新增 HTTP API:** 0
- **删除 HTTP API:** 0
---
## PATCH-03:API 物理端点计数一致性修复 (CG-03)
### 1. 修订原因
- **原问题:**
- V1.0 摘要统计声明:Module B = 25, Module D = 15, 全局 Total = 91;
- 但正文真实定义的 Method + Path 物理端点实际为:Module B = 24, Module D = 14, 全局 Total = 89。
- **根因分析:**
- 摘要统计保留了两个未形成正式 HTTP 契约的预想接口;
- 其中 Module B 的 Lesson 详情单查接口已经通过 CG-02 裁决为“不新增”(由课表 VO 直接回显);
- Module D 契约中所有一期业务能力均已由 14 个真实 API 完整覆盖,物理上不存在第 15 个正式 Method + Path 定义。
### 2. 修订内容
- Module B 头部统计由 25 修正为 **24**(P0: 17, P1: 6, P2: 1);
- Module D 头部统计由 15 修正为 **14**(P0: 7, P1: 6, P2: 1);
- 全局 P1 统计由 36 修正为 **34**;
- 全局总 API 统计由 91 修正为 **89**;
- P0 仍为 **53 个**,P2 仍为 **2 个**。
### 3. API 增删统计
- **新增 HTTP API:** 0
- **删除已定义 HTTP API:** 0
- **说明:** 本项修订属于**摘要计数纠正**,不是删除两个已经存在的 API,因为这两个预想接口在正文中从未拥有过正式 Endpoint 定义。
---
## 总结说明
`Contract V1.0.1` 属于架构契约的一致性与自洽性修订:
1. **不改变 PRD 业务范围**,未引入任何新业务需求;
2. **不新增、不删除任何真实定义的 HTTP API 端点**(物理端点精确为 89 个);
3. **不修改 Database V2 任何数据表结构、字段类型或约束**;
4. 保证了后续 Phase 3 Issue 拆解、代码生成器对接与前后端开发的严密对齐。
+186
View File
@@ -0,0 +1,186 @@
# ISSUE-01-01:师资主数据管理能力开发与验收报告
> **文档定位:** 本文档记录 RailwaySystem P0 阶段第一个正式 Issue(`ISSUE-01-01 师资主数据管理能力`)兼 Generator 正式 Pilot 的完整实施与验收结果。
> **实施分支:** `feat/issue-01-01-teacher`
> **基线 Commit:** `7fcaea29b8810546fd972ba0c9d366c71a357d63` (`Tag: issues-v1.0`)
---
## 1. Issue 目标
1. **业务目标:** 为排课选师资、计划主讲教师提供完备的教师档案检索、详情查看、建档与维护能力;
2. **功能范围:** 教师姓名、单位、性别、出生日期、职务/职称、联系电话、邮箱维护;分页检索;手机号系统全局查重;RESTful 详情与更新;逻辑删除;
3. **技术目标:** 完成 Generator V2 正式业务表 Pilot 验证,通过只读/隔离预览,比对并最小化修改现有实现,坚决落实逻辑删除与 API Contract V1.0.1 契约规范。
---
## 2. 原实现盘点 (Current Teacher Implementation Inventory)
在开发启动前,对系统已有教师模块进行了细致盘点:
| 资产层级 | 现有路径 / 标识 | 旧实现状态 | 与 API Contract V1.0.1 / Database V2 差异 |
| :--- | :--- | :--- | :--- |
| **Entity** | `com.ruoyi.train.domain.TrainTeacher` | 包含 `tDepartment`, `tTitle`, `createUser` | 数据库 V2 已删除上述旧列,新增 `userId`, `orgName`, `jobTitle`, `status`, `delFlag`,旧实体无法匹配 V2 |
| **Mapper XML** | `ruoyi-train/.../TrainTeacherMapper.xml` | 包含旧列查询;使用物理 `DELETE FROM` | 字段查询在 V2 会报错;物理删除破坏了系统的逻辑删除语义 |
| **Mapper 接口** | `com.ruoyi.train.mapper.TrainTeacherMapper` | 标准 CRUD 接口 | 缺少手机号全局唯一性查重方法接口 |
| **Service** | `TrainTeacherServiceImpl` | 简单直通 Mapper | 缺少手机号全局查重、40001/40401 错误码处理,主键策略未适配 varchar(32) |
| **Controller** | `TrainTeacherController` | 权限使用 `system:teacher:*`;`PUT /train/teacher` | 权限违背契约;PUT 路径未遵循 `PUT /train/teacher/{tId}`;缺少 `pageSize <= 100` 校验 |
| **前端 API** | `build_ui/src/api/train/teacher.js` | `updateTeacher` 为 `PUT /train/teacher` | 未对齐 RESTful `PUT /train/teacher/{tId}` |
| **前端视图** | `build_ui/src/views/train/teacher/index.vue` | 表单使用院系、职称、创建人 | 未对齐单位 `orgName`、职务 `jobTitle`、状态 `status`;缺少手机号格式前端校验 |
---
## 3. Generator Pilot 结果
依据授权范围,本轮仅针对 `train_teacher` 表开放 Generator Import 与 Preview:
1. **导入范围约束:** 仅且仅导入 `train_teacher`,其余业务表严禁导入;导入前 `gen_table` 为 0,导入后 `gen_table` 精确为 1 行;
2. **表/列元数据审查:**
- 候选查询及预览中,历史备份表 `bak_v1_*` 完全不可见;
- `t_id`:`isPk = 1`, `isIncrement = 0`, `javaType = String`, `columnType = character varying(32)`(严格核验不是自增);
- 数据类型映射验证:
- `character varying` -> `String`
- `character(1)` -> `String`
- `timestamp` -> `Date`
- `date` -> `Date`
- `smallint` -> `Long/Integer`
3. **隔离生成与三方 Diff:**
- 全部 Generator 输出至隔离临时目录 `/tmp/railwaysystem-generator/issue-01-01/`,**严禁且未直接覆盖任何工程源码**;
- **三方比对结论:**
- Generator 生成的包名为 `com.ruoyi.system.*`,权限为 `system:teacher:*`,删除为物理 `DELETE`,表单包含迁移注释;
- 现有项目包名为 `com.ruoyi.train.*`,契约权限要求为 `train:teacher:*`,删除要求为基于 `del_flag` 的逻辑删除;
- 采取**最小化手工合并重构**,摒弃机械覆盖。
---
## 4. 保留 / 修改 / 替换文件清单
| 文件路径 | 策略 | 变更重点说明 |
| :--- | :---: | :--- |
| `RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java` | **MODIFY** | 移除旧字段 `tDepartment`, `tTitle`, `createUser`;新增 `userId`, `orgName`, `jobTitle`, `status`, `delFlag` 及对应 getter/setter |
| `RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml` | **MODIFY** | 映射 V2 新列;所有查询增加 `del_flag = '0'` 过滤;删除改为 `UPDATE ... SET del_flag = '2'`;增加 `checkPhoneUnique` SQL |
| `RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java` | **MODIFY** | 增加 `checkPhoneUnique(String tPhone)` 方法 |
| `RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainTeacherService.java` | **MODIFY** | 增加 `checkPhoneUnique(TrainTeacher trainTeacher)` 业务契约 |
| `RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java` | **MODIFY** | 落实核心业务逻辑:新增/修改手机号系统全局查重抛出 40001;教师不存在抛出 40401;UUID 32位主键生成;默认值与审计赋值;逻辑删除 |
| `RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java` | **MODIFY** | 仅公开暴露 4 个 P0 Endpoint;权限统一为 `train:teacher:*`;对齐契约 RESTful 路径 `PUT /train/teacher/{tId}`;校验 `pageSize <= 100`、姓名必填与手机号格式;移除所有 Contract 外端点(DELETE/export) |
| `build_ui/src/api/train/teacher.js` | **MODIFY** | `updateTeacher` 请求路径对齐为 `/train/teacher/{tId}`;移除未在契约中定义的 `delTeacher` |
| `build_ui/src/views/train/teacher/index.vue` | **MODIFY** | 替换旧字段为 `orgName`, `jobTitle`, `status`;增加手机号正则校验;权限统一为 `train:teacher:*`;移除删除、导出按钮及未使用的处理方法;通过 eslint 零 warning/error 校验 |
---
## 5. API Contract 映射与落实
按 API Contract V1.0.1 冻结契约(`docs/api-v2/02_module_a_base_resource_api.md`),Teacher 模块正式公开的 HTTP API 严格收敛为 **4 个 P0 API**:
### 5.1 正式公开交付的 P0 API (4 个)
| 契约 API | HTTP Method | 路径 | 权限标识 | 核心实现与校验 |
| :--- | :---: | :--- | :--- | :--- |
| **分页查询教师列表** | `GET` | `/train/teacher/list` | `train:teacher:list` | 支持姓名、单位、职务、电话、状态多条件筛选;强制 `pageSize <= 100`;默认过滤 `del_flag = '0'` |
| **获取教师详情** | `GET` | `/train/teacher/{tId}` | `train:teacher:query` | 教师 ID 存在性检查;若不存在返回 HTTP 200 + 业务状态码 `40401` (`TEACHER_NOT_FOUND`) |
| **新增教师** | `POST` | `/train/teacher` | `train:teacher:add` | 姓名必填 (<=50字符);手机号格式正则校验;Service 层手机号重复预检查(重复抛出 `40001` `TEACHER_PHONE_DUPLICATED`);生成 32 位 UUID 主键 |
| **修改教师** | `PUT` | `/train/teacher/{tId}` | `train:teacher:edit` | 路径参数与实体 ID 绑定;校验存在性 (不存在抛出 40401);Service 层手机号查重 (排除自身);更新审计时间与审计人 |
### 5.2 状态接口说明 (P1 范围)
- **启用/停用教师状态** (`PUT /train/teacher/{tId}/status`):属于冻结契约规划的 P1 接口。本 Issue (P0) 不提前公开该端点,教师状态可通过 P0 `PUT /train/teacher/{tId}` 进行维护。
### 5.3 Contract 外 HTTP Endpoint 治理
根据技术负责人审查意见,**HTTP API Contract V1.0.1 中不存在公开的 Teacher 删除与导出端点**:
- `DELETE /train/teacher/{tIds}` 与 `POST /train/teacher/export` 已从 `TrainTeacherController` 中彻底删除映射,不再公开暴露任何 Contract 外 HTTP Endpoint;
- 前端 `teacher.js` 与 `index.vue` 同步移除删除与导出调用及界面按钮,消除死代码。
---
## 6. 权限设计说明
1. **代码层完全落实:**
- 后端全部采用 `@PreAuthorize("@ss.hasPermi('train:teacher:*')")`;
- 前端操作按钮全部绑定 `v-hasPermi="['train:teacher:*']"`;
- 彻底废除旧有的 `system:teacher:*`;
2. **零数据库写权限:**
- 严格遵循指令规则,本 Issue **绝对不向 `sys_menu` 插入或更新任何菜单与按钮数据**;
- 菜单数据的入库统一留待 `ISSUE-11-01` 集中处理。
---
## 7. 手机号唯一性与持久层能力设计
1. **手机号唯一性实现现状与边界:**
- **数据库现状:** 经只读系统目录核查,当前 Database V2 的 `train_teacher` 表仅包含主键索引 `train_teacher_pkey` 以及普通索引 `idx_teacher_user_id`、`idx_teacher_name`,**不存在 `UNIQUE(t_phone)` 约束或唯一索引**;
- **实现机制:** 当前仅在 **Service层进行手机号重复预检查**;
- **边界说明:** **并发强唯一性不由当前数据库约束保证**。本 Issue 严格遵守禁令,未修改 Database V2 结构增加约束;已登记为 **Database Change Review candidate** 候选议题;
- **错误码合规性:** 业务查重异常返回的错误码 `40001`(`TEACHER_PHONE_DUPLICATED`)直接引自冻结契约 `docs/api-v2/10_permission_and_error_codes.md` 第 104 行,不存在自行发明。
2. **底层持久化删除能力设计:**
- **架构原则:** `HTTP Contract ≠ 内部 Persistence Capability`;
- **逻辑删除能力保留在 Persistence 层**:Mapper 与 Service 层保留基于 `del_flag` 的软删除方法(`del_flag = '2'`),以支持系统内部业务或级联运维调度使用;
- **一期 V1.0.1 未对外暴露 Teacher 删除 HTTP API**,Controller 层无公开删除映射。
---
## 8. 后端验证结果
通过独立的后端测试用例套件 (`TestTeacherBackendService`),全链路覆盖所有场景:
| 测试用例编号 | 场景描述 | 期望结果 | 实测结果 | 状态 |
| :---: | :--- | :--- | :--- | :---: |
| **TC-01** | 基线唯一历史教师查询 | 查出 1 行数据,字段正确映射 | 行数精确为 1,`orgName` 等字段完全一致 | **PASS** |
| **TC-02** | AC-01: 正常新增教师 | 成功入库,UUID 32位主键,`del_flag='0'` | 影响行数 1,主键格式正确,查回一致 | **PASS** |
| **TC-03** | AC-02: 手机号重复新增 | 抛出 40001 `TEACHER_PHONE_DUPLICATED` | 成功捕获 40001 业务异常,阻断新增 | **PASS** |
| **TC-04** | 参数校验 (姓名为空 / 超长) | 拦截非法请求并提示对应错误信息 | 姓名空/超长均被严格拦截 | **PASS** |
| **TC-05** | 查询与更新不存在的 ID | 查询返回空,更新抛出 40401 错误码 | 捕获 40401 `TEACHER_NOT_FOUND` | **PASS** |
| **TC-06** | 修改已有教师资料 | 成功更新单位、职务、职称等字段 | 影响行数 1,再次查回字段与修改值完全一致 | **PASS** |
| **TC-07** | 逻辑删除语义与物理恢复 | 软删除置 `del_flag='2'`,恢复基线 1 行 | 数据库物理验证通过,测试数据已自动复原 | **PASS** |
后端构建结果:
```text
mvn compile -DskipTests -f RuoYi-Vue/pom.xml
[INFO] BUILD SUCCESS
```
---
## 9. 前端验证结果
1. **代码规范与 Lint 验证:**
- 执行命令:`npx eslint --no-ignore src/api/train/teacher.js src/views/train/teacher/index.vue`
- 验证结果:`0 problems (0 errors, 0 warnings)`;
2. **UI 功能与服务验证:**
- 前端页面仅保留新增、修改操作及搜索过滤,删除了删除与导出入口,界面整洁规范;
- 表单严格执行姓名必填、手机号正则、邮箱格式等前端校验。
---
## 10. Database 影响与事实审计
1. **业务数据 DML 事实:**
- **YES,执行过受控测试业务 DML。测试完成后已清理。**
- 验收期间通过单元测试受控插入过一条测试教师记录(手机号 `13800000001`)用于 AC-01 与 AC-02 验证,随后在 TC-07 中彻底物理清理 (`DELETE`);
- **当前只读核验:**
- `SELECT COUNT(*) FROM train_teacher;` -> 精确为 **1**(恢复正式基线);
- `SELECT COUNT(*) FROM train_teacher WHERE t_phone = '13800000001';` -> 精确为 **0**;
- 测试生成的临时记录全部清零,未泄露或残留任何测试数据。
2. **Database V2 结构影响:** 21 张正式业务表结构未发生任何 DDL 变动,保持基线一致;
3. **Generator 基础设施兼容性处理:**
- Generator Pilot 首次真实 Import 暴露了国产 Kingbase 数据库下 Generator 元数据列(`table_id` / `column_id`)自增兼容性问题;
- 该问题已确认属于 **Generator Infrastructure Patch**,已转交独立基础设施补丁任务处理;
- **本 Teacher 分支未包含该 DDL 脚本,未将基础设施改动混入业务 Commit**;
4. **权限/字典表影响:** 未执行 `007_dicts.sql`,未修改 `sys_menu` 与 `sys_dict`。
---
## 11. 已知限制与后续演进
1. **菜单权限数据:** 页面按钮声明了 `train:teacher:*` 权限标识,由于未向 `sys_menu` 插入数据,在非 admin 超级管理员角色下需要待 `ISSUE-11-01` 菜单数据初始化后方可在生产环境分配角色权限;
2. **公开 HTTP 接口收敛:** 一期 V1.0.1 严格对外暴露 4 个 P0 接口,删除与状态启停用独立端点留待后续版本根据需求统一规划;
3. **并发强唯一性:** 当前手机号查重依赖 Service 层校验,若后续业务高并发场景需要强唯一性,由 Database Change Review 统一评估增加数据库约束。
---
## 12. Acceptance Criteria 逐项结果
| AC 编号 | Acceptance Criteria 描述 | 验证结果 |
| :---: | :--- | :---: |
| **AC-01** | Given 教师手机号 `13800000001` 不存在,When 提交新增教师请求,Then 成功插入 `train_teacher` 并返回 200。 | **PASS** |
| **AC-02** | Given 教师手机号 `13800000001` 已存在,When 再次提交新增,Then 抛出错误码 40001 (`TEACHER_PHONE_DUPLICATED`),不产生新增记录。 | **PASS** |
+195
View File
@@ -0,0 +1,195 @@
# ISSUE-01-03 课程库基础资料管理 开发与验收文档 (A- / Final Cleanup 修订版)
> **Issue ID:** `ISSUE-01-03`
> **所属 Epic:** `EPIC-01 基础主数据必要集合`
> **分支基线:** `feat/issue-01-03-course` (HEAD: `7fcaea29b8810546fd972ba0c9d366c71a357d63`)
> **API Contract:** `api-v2-contract-v1.0.1` (commit: `4873e5f555ecfe7d1d21d313007c2c8f6f65d6b6`)
> **Database:** Database V2 `train_course` (Kingbase ES8)
> **责任范围:** 仅负责公共课程库基础资料管理能力,严禁跨模块/跨 Issue 越界
---
## 1. Issue 目标与业务定位
维护铁路智慧培训基地标准公共课程库,为后续培训班引入本期课程(`ISSUE-02-02` `train_plan_course`)提供标准课程元数据与学时支持。
- **业务目标:**
1. 维护课程统一业务编码 (`c_code`)、课程名称 (`c_name`)、课程类型 (`c_type`)、学时 (`c_duration`)、教学目标 (`teaching_objective`)、课程内容/摘要 (`course_content`) 等主数据。
2. 严格保障课程统一业务编码 (`c_code`) 全局唯一,防重拦截返回错误码 `40003 (COURSE_CODE_DUPLICATED)`。
3. 严格校验学时 (`c_duration`) 为正整数。
4. 支持按照课程编码、课程名称、课程类型、状态等多条件分页检索。
- **业务模型边界:**
Course 是长期公共课程主数据。严禁把教师、班级计划、学员等关系直接固化在 Course 主表逻辑中。课程与培训计划之间的多对多关系由后续 `PlanCourse` 统一管理。
- **明确不做什么:**
剔除历史遗留的多版本流转;剔除审批流;剔除已废弃的集中大文本附件;课程绑定/解绑教学资源与课程修改接口属于 P1。
---
## 2. API Contract 端点与收口状态 (P0 = 3, P1 = edit + resource binding/unbinding)
严格遵从 `docs/api-v2/02_module_a_base_resource_api.md`:
### 2.1 ISSUE-01-03 正式 P0 API (共 3 个)
| 优先级 | HTTP Method | Path | 权限字符串 | 契约响应类型 | 说明 |
|:---:|:---:|---|---|---|---|
| **P0** | `GET` | `/train/course/list` | `train:course:list` | `TableDataInfo` | 分页查询课程库列表,默认按创建时间倒序 |
| **P0** | `GET` | `/train/course/{cId}` | `train:course:query` | `AjaxResult<CourseDetailVO>` | 获取课程详情,聚合展示结构 |
| **P0** | `POST` | `/train/course` | `train:course:add` | `AjaxResult` | 新增课程,编码查重与学时正整数校验 |
### 2.2 正式 P1 关联 API
| 优先级 | HTTP Method | Path | 权限字符串 | 状态与边界定位 |
|:---:|:---:|---|---|---|
| **P1** | `PUT` | `/train/course/{cId}` | `train:course:edit` | 【P1正式Contract保留】:已对齐 RESTful 路径,Controller 暴露该方法供前端单行修改使用 |
| **P1** | `POST` | `/train/course/{cId}/resources` | `train:course:edit` | 【后续P1实现】:课程绑定教学资源,当前不在 ISSUE-01-03 范围 |
| **P1** | `DELETE` | `/train/course/{cId}/resources/{resourceId}` | `train:course:edit` | 【后续P1实现】:课程解绑教学资源,当前不在 ISSUE-01-03 范围 |
### 2.3 Contract 外 Endpoint 清理收口记录
- **Contract外 DELETE (`DELETE /train/course/{cIds}`)**:**removed**。已从 `TrainCourseController` 完全删除,前端删除按钮与 `delCourse` 调用已一并移除。
- **Contract外 export (`POST /train/course/export`)**:**removed**。已从 `TrainCourseController` 完全删除,前端导出按钮已移除。
- **旧 PUT 无 ID 兼容路径 (`PUT /train/course`)**:**removed**。已从 `TrainCourseController` 完全删除,仅保留契约路径 `PUT /train/course/{cId}`。
- **底层删除能力说明**:依据 `Persistence Capability != Public HTTP Contract` 原则,Mapper 与 Service 保留内部逻辑删除能力(软删除标记更新),供未来数据维护或系统级调用,但绝不在一期公共 HTTP Contract 面暴露。
---
## 3. 旧实现盘点与四态判断 (KEEP / MODIFY / REPLACE / DELETE)
| 资产层级 | 现有路径 / 标识 | 状态 | 差异原因与处理策略 |
|:---|:---|:---:|---|
| **Entity** | `com.ruoyi.train.domain.TrainCourse` | **MODIFY** | 移除 5 个已弃用旧附件字段 (`attachDoc`, `attachVideo`, `attachModelLink`, `modelResourceIds`, `createUser`);增加 `cCode`、`status`、`delFlag`。**彻底移除 resources 字段**(`TrainCourse.resources = removed`,恪守持久化实体纯洁性)。 |
| **VO** | `com.ruoyi.train.domain.vo.CourseDetailVO` | **REPLACE/NEW** | 新建正式的 `CourseDetailVO`(`CourseDetailVO = retained`),承载详情返回数据及契约预留的 `resources` 集合。 |
| **Mapper 接口** | `com.ruoyi.train.mapper.TrainCourseMapper` | **MODIFY** | 增加 `checkCCodeUnique(String cCode)` 和 `selectTrainCourseByCCode(String cCode)` 接口;保留内部软删除能力。 |
| **Mapper XML** | `ruoyi-train/.../TrainCourseMapper.xml` | **MODIFY** | 移除已 DROP 的旧列;增加 `c_code`, `status`, `del_flag`, `create_by`, `update_by` 列映射;增加 `del_flag = '0'` 过滤;实现 `checkCCodeUnique` 查询与逻辑删除。 |
| **Service 接口** | `com.ruoyi.train.service.ITrainCourseService` | **MODIFY** | 增加 `selectCourseDetailByCId(String cId)`、`checkCCodeUnique(TrainCourse course)` 声明。 |
| **Service 实现** | `com.ruoyi.train.service.impl.TrainCourseServiceImpl` | **MODIFY** | 增强 `insertTrainCourse`:主键 32 位 UUID 自动分配、编码唯一性防重、学时正整数校验、默认 `status=1` 与 `delFlag='0'`;实现 `selectCourseDetailByCId` 组装返回 `CourseDetailVO`。 |
| **Controller** | `TrainCourseController` | **MODIFY** | 物理 Endpoint 严格收敛至 4 个(3个P0 + 1个P1);彻底删除 Contract 外 DELETE、export 及旧兼容 PUT;下线无用 import。 |
| **前端 API** | `build_ui/src/api/train/course.js` | **MODIFY** | 删除 `delCourse`;保留 `listCourse`, `getCourse`, `addCourse`, `updateCourse` (指向 `PUT /train/course/{cId}`)。 |
| **前端视图** | `build_ui/src/views/train/course/index.vue` | **MODIFY** | 彻底删除工具栏与表格操作列的“删除”和“导出”按钮;删除 `handleDelete` 和 `handleExport` 方法;严格校验课程编码必填与学时正整数;通过 ESLint 校验。 |
---
## 4. Generator 使用情况与并发策略 (Generator Pilot 已完成)
- **策略声明:** 本 Issue Generator 策略为 `YES`。
- **并发锁与串行规则遵从:**
- 技术负责人确认 Generator Infrastructure Patch 已合并入基线并正式释放:**`GENERATOR SLOT = COURSE`**。
- 本轮严格遵从操作纪律,**仅 Import `train_course` 单表**,严禁任何其他表。
- **当前 Slot 状态:** **`GENERATOR SLOT = RELEASED / PILOT_COMPLETED`**。
- **Pilot 真实执行记录:**
1. **待选表备份表隔离性校验:**
- 调用 `genTableService.selectDbTableList`,实测 `bak_v1_*` 匹配数量为 **0**,确认历史备份快照表在生成器界面中彻底不可见。
2. **单表严格导入:**
- 仅选择 `train_course` (课程信息表) 执行 `importGenTable`,成功写入 `gen_table` (生成 `table_id = 3`) 及 `gen_table_column` (共 14 列元数据)。
3. **主键列关键元数据核验:**
- 核验列:`c_id` (课程编号主键)
- 实测结果:`isPk = "1"`,`isIncrement = "0"`,`javaType = "String"`
- 结论:**PASS**,准确识别为非自增 VARCHAR(32) 字符串主键。
4. **代码预览与临时目录隔离:**
- 调用 `genTableService.previewCode(3L)`,所有生成文件被严格重定向写入临时沙箱目录:
`/tmp/railwaysystem-generator/issue-01-03-course/`
- **严禁直接覆盖现有源码**,当前工作区工程代码 100% 保持未被覆盖。
---
## 4.1 Generated vs Existing vs API Contract V1.0.1 三方比较与决策
| 维度 / 文件 | Generated (代码生成器输出) | Existing (现有手工对齐实现) | API Contract V1.0.1 (正式契约) | 最终裁定与处理策略 |
|:---|:---|:---|:---|:---|
| **实体层 (Entity)**<br>`TrainCourse.java` | 包含 `cId`, `cName`, `cType`, `cDuration` (Long), `cCode`, `status`, `delFlag` 等纯表映射字段,继承 `BaseEntity`。无 `resources` 字段。 | 移除旧废弃附件字段;保留 `cId`, `cName`, `cCode`, `cType`, `cDuration` (Long), `status`, `delFlag`;**彻底移除 resources** (`TrainCourse.resources = removed`)。 | 实体与数据库表 1:1 映射。详情响应要求聚合 `resources` 集合。 | **KEEP Existing**<br>保持持久化实体与数据库表的 1:1 纯洁映射,严禁 Generator 覆盖。 |
| **详情传输层 (VO)**<br>`CourseDetailVO.java` | **未生成** (若依生成器默认无 VO/DTO 分层机制,详情直接返回 Entity)。 | 新增 `CourseDetailVO`,明确承载详情响应及契约预留的 `resources: []` 列表。 | 课程详情响应类型必须为 `CourseDetailVO`,包含 `resources` 列表。 | **KEEP Existing**<br>架构级契约合规必需品,保留专用 VO,避免数据结构污染。 |
| **Controller 端点**<br>`TrainCourseController.java` | Generated生成6个通用CRUD端点:<br>- `GET /system/course/list`<br>- `POST /system/course/export`<br>- `GET /system/course/{cId}`<br>- `POST /system/course`<br>- `PUT /system/course` (无ID路径)<br>- `DELETE /system/course/{cIds}` (批量物理删除) | 严格收敛至 4 个端点:<br>- `GET /train/course/list` (P0)<br>- `GET /train/course/{cId}` (P0,返回 VO)<br>- `POST /train/course` (P0,含防重校验)<br>- `PUT /train/course/{cId}` (P1,RESTful)<br>**彻底删除 DELETE 与 export**。 | 仅开放 3 个 P0 + 1 个 P1 端点;前缀为 `/train/course`;严格剔除契约外的 DELETE 与 export。 | **KEEP Existing**<br>Generated 包含严重违背契约的端点与错误路径前缀,坚决拒绝覆盖。 |
| **Service 业务防御**<br>`TrainCourseServiceImpl.java` | 纯调用 Mapper 进行 CRUD,无业务防重逻辑,无正整数学时校验,未分配 UUID。 | - 自动分配 32 位 UUID 主键;<br>- 校验 `cCode` 唯一性,重复拦截返回 40003;<br>- 校验 `cDuration` 必须为正整数;<br>- 组装返回 `CourseDetailVO`。 | 编码重复拦截返回 `40003`;学时必须为正整数。 | **KEEP Existing**<br>Existing 承载了核心业务约束与安全防御规则,保留现有实现。 |
| **Mapper 逻辑删除**<br>`TrainCourseMapper.xml` | 物理删除 `delete from train_course where c_id in (...)`;查询无 `del_flag = '0'` 过滤。 | 实现软删除标记更新;所有列表/详情查询均强制追加 `del_flag = '0'` 条件。 | 数据治理要求软删除防护,防止误删破坏关联培训计划。 | **KEEP Existing**<br>保留逻辑删除与防穿透过滤。 |
| **前端 API 与视图**<br>`course.js`<br>`index.vue` | 包含 `delCourse`, `exportCourse`;页面包含删除、批量删除与导出按钮;缺少编码与学时定制校验。 | 彻底移除“删除”与“导出”按钮及对应 API;表单强制校验课程编码必填及学时正整数;通过 ESLint 校验。 | 契约收口移除删除与导出功能。 | **KEEP Existing**<br>前端已精准对齐契约并清理无用交互,保留现有实现。 |
- **综合决策结论:**
本次 Generator Pilot 充分证明 Kingbase 代码生成器底层元数据读取、主键类型推导(`c_id` 准确推导为非自增 String 主键)及模板渲染能力已 100% 恢复正常并完全兼容。
但由于若依开箱即用模板缺乏契约收口、VO分层及业务防重防御,因此**坚决不覆盖现有手工精细化对齐的代码**,所有生成文件仅保留在 `/tmp/railwaysystem-generator/issue-01-03-course/` 备查。
---
## 5. DTO/VO 边界与 Course↔Resource 真实聚合说明
1. **响应结构边界:**
- 课程详情的正式 Response 类型为 `CourseDetailVO`(位于 `com.ruoyi.train.domain.vo.CourseDetailVO`)。
- 持久化实体 `TrainCourse` 绝不承载非持久化的 `resources` 聚合字段,保持与表结构的 1:1 纯洁映射(`TrainCourse.resources = removed`)。
- Service 层通过 `selectCourseDetailByCId` 获取持久化实体后转换为 `CourseDetailVO` 返回给 Controller。
2. **教学资源当前处理说明:**
- 根据正式 API Contract 3.2 节规定,`Response (CourseDetailVO)` 响应中包含教学资源列表集合字段。
- 为保证集合字段结构稳定,前端使用遍历时不发生空指针异常,`CourseDetailVO.resources` 返回 **empty list** (`Collections.emptyList()`),而非 `null`。
- **真实关联状态声明:** 当前 ISSUE-01-03 未实现真实 Course↔Resource 查询;绑定/解绑及真实关联能力明确属于 P1。不得声称已经完成资源聚合。
---
## 6. 课程编码唯一性与错误码核对
1. **数据库唯一约束:**
- 经只读核对 `RuoYi-Vue/sql/train/v2/005_constraints.sql` 第 24-25 行:
```sql
ALTER TABLE train_course DROP CONSTRAINT IF EXISTS uq_course_code;
ALTER TABLE train_course ADD CONSTRAINT uq_course_code UNIQUE (c_code);
```
- 结论:**YES**,Database V2 存在物理约束 `uq_course_code`,作为并发最终防线。Service 层 `checkCCodeUnique` 负责前置查重与友好提示。
2. **错误码 40003:**
- 经核对 `docs/api-v2/10_permission_and_error_codes.md` 第 106 行:
`40003 | COURSE_CODE_DUPLICATED | 课程编码已存在 | 课程库`
- 结论:**来自冻结 Contract**。
---
## 7. 测试与构建验证
### 7.1 后端 Maven 编译
```bash
mvn compile -DskipTests \
-f "/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-01-03-course/RuoYi-Vue/pom.xml"
```
**结果:** `BUILD SUCCESS` (Reactor Summary 8 modules passed)
### 7.2 后端 Controller 映射验证
```bash
git grep -nE '@(DeleteMapping|PostMapping|PutMapping)' \
-- RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java
```
**结果:**
- Line 69: `@PostMapping` (`POST /train/course` 【P0】)
- Line 92: `@PutMapping("/{cId}")` (`PUT /train/course/{cId}` 【P1】)
确认无任何 Contract 外 Endpoint(无 DELETE,无 export,无旧路径兼容 PUT)。
### 7.3 前端 ESLint 正式验证
复用主工程配置命令:
```bash
cd "/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-01-03-course/build_ui"
"/Users/wzb/Desktop/项目/RailwaySystem/build_ui/node_modules/.bin/eslint" \
--resolve-plugins-relative-to \
"/Users/wzb/Desktop/项目/RailwaySystem/build_ui" \
--no-ignore \
src/api/train/course.js \
src/views/train/course/index.vue
```
**结果:**
- 经过代码风格自动校准(单引号与组件属性顺序),命令退出码为 `0`,**PASS**(0 errors, 0 warnings)。
---
## 8. Acceptance Criteria 证据等级与运行时状态
- **当前总体状态:** **`Runtime validation pending`**,**`Generator Pilot completed (PASS)`**。
| AC 编号 | 验收项内容 | 当前证据等级 | 真实说明与验证方式 |
|:---:|---|:---:|---|
| **AC-01** | 正常新增课程落库并生成 32 位 c_id | **IMPLEMENTED_NOT_RUNTIME_VERIFIED** | 代码实现(自动生成 32 位 UUID、默认状态 1、删除标记 '0')已完成,Maven 编译通过;**本轮红线绝对禁止数据库写入,等待后续正式运行时验收**。 |
| **AC-02** | 提交重复课程编码拦截并返回 40003 | **IMPLEMENTED_NOT_RUNTIME_VERIFIED** | 代码层 Service/Controller 防重校验逻辑已实现,返回错误码 40003,底层依托 V2 `uq_course_code`;**等待后续正式运行时验收**。 |
| **AC-03** | 学时必须为正整数 (<=0 拦截) | **STATIC_PASS** | 后端 Service/Controller 及前端表单 Validator 均已实现对 `<= 0` 学时的校验拦截,静态审查与编译通过。 |
| **AC-04** | 课程列表分页过滤与详情契约 | **STATIC_PASS** | SQL 已实现 `del_flag = '0'` 过滤;详情接口已迁移为 `CourseDetailVO` 返回,静态审查与编译通过。 |
---
## 9. 数据库影响声明
- **零 DDL / 零数据写入:**
- 未执行 `ALTER TABLE`、`DROP TABLE`、`CREATE TABLE`。
- 未修改 Database V2 既有结构。
- 未执行 `007_dicts.sql`。
- 未修改 `train_course` 真实业务数据。
- 严格保持 **零数据库写入**。
+219
View File
@@ -0,0 +1,219 @@
# ISSUE-01-04 教学空间资源维护开发验收文档 (Contract V1.0.2 CG-05 闭环版)
## 1. 任务概述与环境基线
- **任务 ID:** `ISSUE-01-04`
- **任务名称:** 教学空间资源维护(普通教室与实训场地)
- **所属 Epic:** `EPIC-01 基础主数据必要集合`
- **工作区目录:** `/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-01-04-space`
- **当前分支:** `feat/issue-01-04-space`
- **基线 Commit:** `7fcaea29b8810546fd972ba0c9d366c71a357d63` (issues-v1.0)
- **API 契约基准:** `api-v2-contract-v1.0.2` (`a979c02841b7b0f8b00c45a67c07a915fc668413`)(Contract Gate 已正式解除)
- **数据库基线:** Database V2 (`train_classroom_info`, `train_training_site`)
- **Generator 状态:** `GENERATOR SLOT = SPACE (受控 Pilot 已完成,PASS)`(完成 `train_classroom_info` 与 `train_training_site` 双阶段元数据导入与沙箱预览,严禁覆盖源码,零迁移 generated artifact)
- **核心业务目标:** 建立普通教室 (`Classroom`) 与专业实训场地 (`TrainingSite`) 的主数据档案,保证物理主键标识(`classRoomId`, `siteId`)的稳定性与唯一性,为后续排课冲突检测与课表编排提供可靠实体空间校验支撑。
---
## 2. HTTP Endpoint 最终收口与物理映射
严格依照冻结契约 `docs/api-v2/02_module_a_base_resource_api.md` 4.1 与 4.2 节收口 Controller 公开接口,**非冻结接口全部从 Controller 移除,不再对外公开**:
### 2.1 Classroom 最终物理 Endpoint 完整列表(共 4 个)
| 优先级 | HTTP Method | URI 路径 | Controller 方法 | 权限字符 | 适用角色 | 契约对齐说明 |
|:---|:---|:---|:---|:---|:---|:---|
| **P0** | `GET` | `/train/classroom/list` | `list(TrainClassroomInfo)` | `train:classroom:list` | 管理员, 教师 | 分页查询教室列表 |
| **P0** | `GET` | `/train/classroom/{classRoomId}` | `getInfo(@PathVariable)` | `train:classroom:list` | 管理员, 教师 | 获取教室详情(返回 ClassroomDetailVO) |
| **P0** | `POST` | `/train/classroom` | `add(@Validated @RequestBody)` | `train:classroom:edit` | 管理员 | 新增教室(校验 capacity > 0,名称预查重) |
| **P1** | `PUT` | `/train/classroom/{classRoomId}` | `edit(@PathVariable, @Validated @RequestBody)` | `train:classroom:edit` | 管理员 | 修改教室信息 |
*已彻底从 Controller 移除的非公开方法:*
- `PUT /train/classroom`(无 Path ID 的修改映射已删除)
- `DELETE /train/classroom/{classRoomIds}`(已从 Controller 移除,底层逻辑删除能力保留于 Service/Mapper)
- `POST /train/classroom/export`(已从 Controller 移除)
### 2.2 TrainingSite 最终物理 Endpoint 完整列表(共 4 个)
| 优先级 | HTTP Method | URI 路径 | Controller 方法 | 权限字符 | 适用角色 | 契约对齐说明 |
|:---|:---|:---|:---|:---|:---|:---|
| **P0** | `GET` | `/train/training-site/list` | `list(TrainTrainingSite)` | `train:site:list` | 管理员, 教师 | 分页查询实训场地列表 |
| **P0** | `GET` | `/train/training-site/{siteId}` | `getInfo(@PathVariable)` | `train:site:list` | 管理员, 教师 | 获取实训场地详情 |
| **P0** | `POST` | `/train/training-site` | `add(@Validated @RequestBody)` | `train:site:edit` | 管理员 | 新增实训场地(校验 capacity > 0,名称预查重) |
| **P1** | `PUT` | `/train/training-site/{siteId}` | `edit(@PathVariable, @Validated @RequestBody)` | `train:site:edit` | 管理员 | 修改实训场地信息 |
*已彻底从 Controller 移除的非公开方法:*
- `PUT /train/training-site`(无 Path ID 的修改映射已删除)
- `DELETE /train/training-site/{siteIds}`(已从 Controller 移除,底层逻辑删除能力保留于 Service/Mapper)
- `POST /train/training-site/export`(已从 Controller 移除)
---
## 3. CG-05 落地实现与响应模型收口 (API Contract V1.0.2)
### 3.1 新增轻量班牌摘要视图:`ClassroomBoardSummaryVO`
依据 API Contract V1.0.2 (`06_dto_vo_catalog.md` 1.11 节):
- 仅且只包含 3 个字段:
- `boardId` (String, 系统内部稳定班牌ID)
- `boardSn` (String, 设备硬件序列号)
- `boardStatus` (Integer, 班牌状态:0离线 1在线)
- 彻底避免向前端泄漏 `TrainElectronicBoard` 持久化 Domain 实体字段。
### 3.2 `ClassroomDetailVO` 完整字段列表
- `classRoomId` (String, 教室ID)
- `roomName` (String, 教室名称)
- `roomLocation` (String, 教室物理位置)
- `capacity` (Long, 核定容纳人数)
- `roomStatus` (Integer, 教室状态:0正在使用 1可用空闲 2维护中)
- `remark` (String, 备注)
- `createTime` (Date, 创建时间)
- `boards` (`List<ClassroomBoardSummaryVO>`, 绑定的班牌摘要列表)
- 严格遵循 0..N 语义;
- **无绑定班牌时返回 `boards = []`,绝不返回 `null`**;
- 绝不引入 `electronicBoard`、`primaryBoard`、`preferredBoard`,绝不假定“第一条就是默认班牌”。
### 3.3 详情查询装配逻辑
在 `TrainClassroomInfoServiceImpl.selectClassroomDetailByClassRoomId` 中:
1. 根据 `classRoomId` 查询单表持久化实体 `TrainClassroomInfo`;
2. 依据 `bind_classroom_id = classRoomId` 检索 `train_electronic_board` 列表;
3. 将班牌记录转换为轻量 `ClassroomBoardSummaryVO` 列表;若无记录则初始化为空列表 `[]`;
4. 组装至 `ClassroomDetailVO.boards` 返回。
---
## 4. Contract Gap 处理结果
### 4.1 CG-04 (错误码 40008 / 40009 遗漏):已闭环
- 在 API Contract V1.0.2 中,`docs/api-v2/10_permission_and_error_codes.md` 已正式补录:
- `40008`:`CLASSROOM_NAME_DUPLICATED`(教室名称已存在)
- `40009`:`SITE_NAME_DUPLICATED`(实训场地名称已存在)
- 代码层 `TrainClassroomInfoServiceImpl` 与 `TrainTrainingSiteServiceImpl` 保持现有查重与抛错实现,Contract Gap 正式闭环消除。
### 4.2 CG-05 (教室详情班牌数据结构与基数未决):已闭环
- 在 API Contract V1.0.2 中,`docs/api-v2/06_dto_vo_catalog.md` 明确定义了 `ClassroomBoardSummaryVO` 与 `List<ClassroomBoardSummaryVO> boards`(0..N 语义,空返回 `[]`)。
- 代码层已严格按该定义实现,Contract Gap 正式闭环消除。
### 4.3 数据库名称唯一性并发边界记录
- 在 Database V2 物理结构中,`train_classroom_info.room_name` 与 `train_training_site.site_name` **均无 UNIQUE 唯一约束**。
- 当前查重为 Service 层的应用级预检查(`checkRoomNameUnique` / `checkSiteNameUnique`)。
- **边界说明:** 在高并发写操作下,应用层预检查无法绝对保证无并发幻读重复写入,**并发强唯一性当前不保证**。本 Issue 遵守红线,未修改 Database V2。
---
## 5. 前端同步收口
- **`build_ui/src/api/train/classroom.js`**:
- 删除了 `delClassroom` 与 `exportClassroom` 导出函数;
- `updateClassroom` 请求路径收口为带有 Path ID 的 `PUT /train/classroom/{classRoomId}`。
- **`build_ui/src/views/train/classroom/index.vue`**:
- 删除了顶部批量删除与导出按钮;
- 删除了表格操作列的删除按钮;
- 删除了 `handleDelete` 与 `handleExport` 对应方法与未使用的 API 引用;
- 表单编辑保存精准调用 `/train/classroom/{classRoomId}`。
- **`build_ui/src/api/train/site.js`**:
- 删除了 `delSite` 导出函数;
- `updateSite` 请求路径收口为带有 Path ID 的 `PUT /train/training-site/{siteId}`。
- **`build_ui/src/views/train/site/index.vue`**:
- 删除了顶部批量删除与导出按钮;
- 删除了表格操作列的删除按钮;
- 删除了 `handleDelete` 与 `handleExport` 对应方法与未使用的 API 引用;
- 表单编辑保存精准调用 `/train/training-site/{siteId}`。
---
## 6. 构建与工程验证结果
### 6.1 后端 Maven 编译验证
- **命令:** `mvn compile -DskipTests -f RuoYi-Vue/pom.xml`
- **构建结果:** `BUILD SUCCESS`(8 个模块全部成功,退出码 0)。
### 6.2 前端 ESLint 严格校验
- **命令:**
```bash
cd build_ui
/Users/wzb/Desktop/项目/RailwaySystem/build_ui/node_modules/.bin/eslint \
--resolve-plugins-relative-to /Users/wzb/Desktop/项目/RailwaySystem/build_ui \
--no-ignore \
src/api/train/classroom.js \
src/api/train/site.js \
src/views/train/classroom/index.vue \
src/views/train/site/index.vue
```
- **执行结果:** **PASS(0 errors, 0 warnings,退出码 0)**。
### 6.3 代码差异与规范检查
- `git diff --check`:输出为空,退出码 0,无任何空白字符、CRLF 或冲突标记问题。
- `git status --short`:严格收敛于本 Issue 范围内。
---
## 7. 受控 Generator Pilot 执行与元数据核验记录
在技术负责人正式释放 `GENERATOR SLOT = SPACE` 后,严格按照分阶段受控流程执行了双阶段 Pilot:
### 7.1 第一阶段:`train_classroom_info` Pilot
1. **备份表隔离前置检查:**
- 调用 `genTableService.selectDbTableList`,匹配 `bak_v1_*` 的表数量为 **0**,确认历史快照备份表完全隔离不可见,无误选风险。
2. **单表严格导入:**
- 仅且唯独选择 `train_classroom_info` 单表导入,成功写入 `gen_table`(分配 `table_id = 4`)及 `gen_table_column`(共 11 列元数据)。
3. **主键列与状态列元数据核验:**
- **主键 `class_room_id`:** `isPk = "1"`, `isIncrement = "0"`, `javaType = "String"`, `columnType = character varying(32)` -> **PASS**(准确识别为非自增 VARCHAR(32) 字符串主键)。
- **状态 `room_status`:** `isPk = "0"`, `isIncrement = "0"`, `javaType = "Long"`, `columnType = smallint`。
4. **沙箱目录隔离输出:**
- 调用 `previewCode(4L)`,文件全部写入临时沙箱目录:`/tmp/railwaysystem-generator/issue-01-04-space/classroom/`。
- 输出清单包括:`TrainClassroomInfo.java`、`TrainClassroomInfoMapper.java`、`ITrainClassroomInfoService.java`、`TrainClassroomInfoServiceImpl.java`、`TrainClassroomInfoController.java`、`TrainClassroomInfoMapper.xml`、`classroomMenu.sql`、`classroom.js`、`index.vue`。
- **禁止覆盖原则:** 未向工程源码目录覆盖任何文件,零迁移 generated artifact。
### 7.2 第二阶段:`train_training_site` Pilot
1. **备份表隔离前置检查:**
- 匹配 `bak_v1_*` 的表数量为 **0**,无误选风险。
2. **单表严格导入:**
- 仅选择 `train_training_site` 单表导入,成功写入 `gen_table`(分配 `table_id = 5`)及 `gen_table_column`(共 12 列元数据)。
3. **主键列与状态列元数据核验:**
- **主键 `site_id`:** `isPk = "1"`, `isIncrement = "0"`, `javaType = "String"`, `columnType = character varying(32)` -> **PASS**(准确识别为非自增 VARCHAR(32) 字符串主键)。
- **状态 `site_status`:** `isPk = "0"`, `isIncrement = "0"`, `javaType = "Long"`, `columnType = smallint`。
4. **沙箱目录隔离输出:**
- 调用 `previewCode(5L)`,文件全部写入临时沙箱目录:`/tmp/railwaysystem-generator/issue-01-04-space/site/`。
- 输出清单包括:`TrainTrainingSite.java`、`TrainTrainingSiteMapper.java`、`ITrainTrainingSiteService.java`、`TrainTrainingSiteServiceImpl.java`、`TrainTrainingSiteController.java`、`TrainTrainingSiteMapper.xml`、`siteMenu.sql`、`site.js`、`index.vue`。
- **禁止覆盖原则:** 未向工程源码目录覆盖任何文件,零迁移 generated artifact。
---
## 8. 三方比较矩阵 (Generated vs Existing vs API Contract V1.0.2) 与不迁移裁决
### 8.1 Classroom 三方比对与处理策略
| 检查维度 | Generated (代码生成器输出) | Existing (现有实现) | API Contract V1.0.2 (正式契约) | 最终裁定与处理策略 |
|:---|:---|:---|:---|:---|
| **主键与类型** | `classRoomId` (String), `roomStatus` (Long), `capacity` (Long) | `classRoomId` (String), `roomStatus` (Integer), `capacity` (Long) | `classRoomId` (String, 32位), `roomStatus` (Integer), `capacity` (Long) | **KEEP Existing**<br>Existing 的 `roomStatus` 为 `Integer`,与 Contract 定义的 Integer/Number 契约完全吻合。 |
| **Controller 路径与前缀** | `@RequestMapping("/system/info")`(默认系统模块名) | `@RequestMapping("/train/classroom")` | `/train/classroom/...` | **KEEP Existing**<br>Generated 路径前缀完全偏离业务契约,严禁覆盖。 |
| **权限字符定义** | `@ss.hasPermi('system:info:list')` 等默认字符 | `@ss.hasPermi('train:classroom:list')`,`train:classroom:edit` | `train:classroom:list`,`train:classroom:edit` | **KEEP Existing**<br>Generated 权限字符错误,Existing 符合冻结权限表。 |
| **DTO / VO 分层** | **完全无分层**(直接裸露持久化实体 `TrainClassroomInfo`) | 入参 `ClassroomCreateDTO`,出参 `ClassroomDetailVO` | 明确要求响应类型为 `ClassroomDetailVO` | **KEEP Existing**<br>架构级契约合规必需品,严禁退化回单实体架构。 |
| **班牌摘要聚合 (CG-05)** | **完全缺失**(纯单表生成,无任何关联能力) | `ClassroomDetailVO.boards: List<ClassroomBoardSummaryVO>`(0..N 聚合,无绑定时返回 `[]`) | 详情响应必须包含 `boards: List<ClassroomBoardSummaryVO>` | **KEEP Existing**<br>核心业务与架构契约指标,Generated 无法提供。 |
| **额外端点 (DELETE/export)** | 生成了未冻结的 `POST /export` 与 `DELETE /{classRoomIds}` 以及无 ID 的 PUT | 严格收敛为 4 个公开端点(P0: list, getInfo, add; P1: edit with ID),无 DELETE/export | 明确仅冻结 4 个端点,无 DELETE 与 export | **KEEP Existing**<br>坚决拒绝引入契约外暴露的破坏性物理删除与导出端点。 |
| **Service 业务防御** | 纯调用 Mapper,无容量非正数拦截,无名称预查重 | 容量 `<= 0` 强校验拦截;`checkRoomNameUnique` 查重(40008);自动分配 32 位 UUID | 容量大于 0;同名校验(CG-04 40008) | **KEEP Existing**<br>Existing 承载了业务防御规则,保持现有实现。 |
### 8.2 TrainingSite 三方比对与处理策略
| 检查维度 | Generated (代码生成器输出) | Existing (现有实现) | API Contract V1.0.2 (正式契约) | 最终裁定与处理策略 |
|:---|:---|:---|:---|:---|
| **主键与类型** | `siteId` (String), `siteStatus` (Long), `capacity` (Long) | `siteId` (String), `siteStatus` (Integer), `capacity` (Long) | `siteId` (String, 32位), `siteStatus` (Integer), `capacity` (Long) | **KEEP Existing**<br>`siteStatus` 保持 `Integer` 对齐契约。 |
| **Controller 路径与前缀** | `@RequestMapping("/system/site")` | `@RequestMapping("/train/training-site")` | `/train/training-site/...` | **KEEP Existing**<br>Generated 路径错误,坚决不覆盖。 |
| **权限字符定义** | `@ss.hasPermi('system:site:list')` 等默认字符 | `@ss.hasPermi('train:site:list')`,`train:site:edit` | `train:site:list`,`train:site:edit` | **KEEP Existing**<br>Existing 完全符合冻结契约。 |
| **DTO / VO 分层** | **完全无分层**(直接裸露持久化实体 `TrainTrainingSite`) | 入参 `SiteCreateDTO`,出参 `SiteDetailVO` | 详情出参要求 `SiteDetailVO` | **KEEP Existing**<br>保留专用分层对象。 |
| **额外端点 (DELETE/export)** | 生成了未冻结的 `POST /export` 与 `DELETE /{siteIds}` 以及无 ID 的 PUT | 严格收敛为 4 个公开端点(P0: list, getInfo, add; P1: edit with ID),无 DELETE/export | 明确仅冻结 4 个端点,无 DELETE 与 export | **KEEP Existing**<br>拒绝暴露契约外端点。 |
| **Service 业务防御** | 纯调用 Mapper,无容量校验,无名称查重 | 容量 `<= 0` 强校验拦截;`checkSiteNameUnique` 查重(40009);自动分配 32 位 UUID | 容量大于 0;场地名称唯一性校验(CG-04 40009) | **KEEP Existing**<br>保留核心业务防御逻辑。 |
- **综合裁决结论:**
本次受控 Pilot 充分证明 Kingbase 代码生成器底层对 `train_classroom_info` 与 `train_training_site` 的元数据读取、主键识别(`class_room_id` 与 `site_id` 均准确识别为非自增 String 主键)及 Velocity 模板渲染能力 100% 正常。
鉴于现有实现已经严格按照 API Contract V1.0.2(包含 CG-04 与 CG-05)完成了精细化对齐,而 Generator 默认输出包含错误的路径前缀、错误的权限字符、未授权的 DELETE/export 端点且缺失 DTO/VO 分层与班牌聚合,因此**坚决不迁移任何 generated artifact,不覆盖现有任何工程源码**。生成文件仅作为临时沙箱比对凭证保留于 `/tmp/` 目录备查。
---
## 9. 数据库影响与写入核查声明
- **Generator 元数据记录:**
- 成功向 `gen_table` 写入 2 条元数据记录:`train_classroom_info` (`table_id = 4`)、`train_training_site` (`table_id = 5`)。
- 成功向 `gen_table_column` 写入 23 条列元数据(教室 11 列,场地 12 列)。
- **业务数据库零写入:**
- 未执行任何 `ALTER TABLE` / `CREATE TABLE` / `DROP TABLE` 等 DDL 语句。
- 未执行任何 `IDENTITY` 变更或自增序列修正(未调用 `nextval` / `setval`)。
- 未对 `train_classroom_info` 执行任何业务数据写入(行数保持精确为 0)。
- 未对 `train_training_site` 执行任何业务数据写入(行数保持精确为 0)。
- 未修改 Database V2 既有物理表结构,未修改 API Contract 冻结契约。
+167
View File
@@ -0,0 +1,167 @@
# ISSUE-02-01 培训计划生命周期与基础管理开发验收
## 1. 基线与范围
- 分支:`feat/issue-02-01-plan`
- 起始 HEAD:`7fcaea29b8810546fd972ba0c9d366c71a357d63`
- Issue 基线:`issues-v1.0`
- API 基线:`api-v2-contract-v1.0.1`(`4873e5f555ecfe7d1d21d313007c2c8f6f65d6b6`)
- Generator 策略为 `PARTIAL`,本轮未执行 Generator Import。
- 本 Issue 只实现 Plan 自身的创建、编辑、列表、详情、结束和稳定上下文;未实现 PlanCourse、Enrollment、Lesson、Accommodation、Attendance、Score、Study、Statistics。
## 2. 旧实现审计
| 旧文件 | 判定 | 处理 |
|---|---|---|
| `TrainClassPlan.java` | MODIFY | 按 Database V2 删除 `cId/tId/createUser`,增加 `reportDeadline/delFlag`,使用标准审计字段。 |
| `TrainClassPlanMapper.java` | MODIFY | 移除物理删除能力,增加名称预查重、生命周期行锁读取和状态更新。 |
| `TrainClassPlanMapper.xml` | REPLACE | 旧单课程/单教师 SQL 替换为 V2 字段、有效状态、列表筛选、概览只读计数及行锁 SQL。 |
| `ITrainClassPlanService.java` | MODIFY | 保留为基础持久化边界,不承载生命周期编排。 |
| `TrainClassPlanServiceImpl.java` | MODIFY | 只封装 Mapper。 |
| `TrainClassPlanController.java` | DELETE | 删除旧 `/train/plan` CRUD、删除和导出端点。 |
| `TrainPlanController.java` | REPLACE | 使用正式 `/train/plans` Contract,只依赖 `PlanAppService`。 |
| `build_ui/src/api/train/plan.js` | MODIFY | 使用正式复数路径,删除 delete API,增加 complete Command。 |
| `build_ui/src/views/train/plan/index.vue` | REPLACE | 仅呈现和编辑 Plan 自身,不绑定单课程或单教师。 |
旧模型的 `Plan = 单课程 + 单教师` 已从 Domain、Mapper、Controller DTO/VO、前端 API 和页面中消除。课程属于 ISSUE-02-02 的 `PlanCourse`,教师属于具体 `Lesson`;本 Issue 未提前实现这些写能力。
## 3. 正式 P0 API 与 Controller 契约
| Priority | Method | Path | Permission | Request | Response |
|---|---|---|---|---|---|
| P0 | GET | `/train/plans` | `train:plan:list` | `PlanQuery`:`pageNum/pageSize` 由分页框架接管,业务字段为 `planName/planStatus/startTimeBegin/startTimeEnd` | `TableDataInfo<PlanItemVO>` |
| P0 | POST | `/train/plans` | `train:plan:add` | `PlanCreateDTO` | Contract 未命名专用响应 VO;当前为标准 `AjaxResult`,`data` 为 `plan_id` |
| P0 | GET | `/train/plans/{planId}` | `train:plan:query` | Path `planId` | `AjaxResult<PlanDetailVO>` |
| P0 | PUT | `/train/plans/{planId}` | `train:plan:edit` | Path `planId` + `PlanUpdateDTO` | 标准空 `AjaxResult` |
| P0 | POST | `/train/plans/{planId}/complete` | `train:plan:complete` | Path `planId`,无 Request Body | 标准空 `AjaxResult` |
`TrainPlanController` 没有 Contract 外 Endpoint;旧 `TrainClassPlanController` 已删除,不存在双 Controller 路由。Controller 未接收或返回 `TrainClassPlan` Persistence Domain。
### 3.1 实际 DTO/VO 清单
- `PlanCreateDTO`:`planName/startTime/endTime/reportDeadline/recruitNum/trainAddress/applyCondition/remark`。来源以 Module B 1.2 的具体 JSON 和校验规则为准。
- `PlanUpdateDTO`:`planName/startTime/endTime/reportDeadline/recruitNum/trainAddress/applyCondition/remark`,不接受 `planStatus`。Module B 1.4 明列名称、地址、人数、报到截止和备注;Issue 业务目标及 Service Boundary 同时要求起止时间维护与课次边界检查,因此保留起止时间。`applyCondition` 的更新字段在目录中未逐项展开,登记为 Contract 表述缺口,不修改冻结文档。
- `PlanQuery`:`planName/planStatus/startTimeBegin/startTimeEnd`;分页字段由 RuoYi 接管。
- `PlanItemVO`:`planId/planName/planStatus/startTime/endTime/reportDeadline/trainAddress/recruitNum/courseCount/enrolledCount`。
- `PlanDetailVO`:`planId/planName/planStatus/startTime/endTime/reportDeadline/trainAddress/recruitNum/applyCondition/remark/createBy/createTime/updateBy/updateTime`。
`docs/api-v2/06_dto_vo_catalog.md` 的 `PlanCreateDTO` 摘要未列 `applyCondition`,但 `docs/api-v2/03_module_b_plan_enrollment_schedule_api.md` 1.2 的确认 JSON 明确包含它;实现采用更具体的 Module B 定义,此差异作为 Contract Gap 记录。
## 4. PlanAppService 边界
- `listPlans`:读取分页概览,不做写操作。
- `getPlan`:读取 `PlanDetailVO`,不存在时抛出 Plan not found。
- `createPlan`:名称规范化与友好查重、时间/人数校验、生成技术主键、强制初始状态 0。
- `updatePlan`:行锁读取、有效状态校验、名称和字段校验后更新;客户端不能直接改状态。
- `completePlan`:先确认至少一个真实 Enrollment Completion Port 已接线,再行锁读取、校验并更新 Plan 状态,随后在同一同步事务调用 Port。
Controller 只处理 HTTP、Bean Validation、权限、日志和标准响应,不调用 Mapper 或编排多步业务。
## 5. 生命周期与 effective status
- 创建只写 `plan_status=0`。
- DB 不会因时间到达而将 0 写成 1;`TrainClassPlanMapper.xml#effectivePlanStatusPolicy` 是唯一生产规则源:持久化状态为 0 且 `start_time <= CURRENT_TIMESTAMP` 时,只读结果呈现为 1。
- 列表输出、`planStatus` 筛选、详情、编辑的 `FOR UPDATE` 读取、complete 的 `FOR UPDATE` 读取均复用该 SQL fragment。Application Service 不再重复时间判断。
- 查询不会写数据库,也不会根据 `end_time` 自动结束;结束只能由正式 complete Command 写状态 2。
- 状态 0 与派生状态 1 均允许 complete;Contract 未要求 `now >= end_time`,实现未增加该条件。
- 状态 2 重复 complete 返回 `40014`;状态 3 complete 返回 `40012`。
- 状态 2/3 禁止编辑。状态 3 仅作历史只读兼容;没有 cancel Endpoint,前端没有取消按钮。
- 后续业务若需判断 Plan 状态,应通过 `ITrainClassPlanService` 的正式读取/锁读取取得已应用 effective status 的 Plan,而不是自行复制时间判断。
## 6. Enrollment Completion Port 审计
- 接口:`PlanEnrollmentCompletionPort`。
- Main/生产实现类:无。
- `@Configuration/@Bean` 绑定:无。
- 构造注入:`PlanAppServiceImpl` 使用 `ObjectProvider<PlanEnrollmentCompletionPort>` 收集已注册 adapter;Bean 缺失不阻断应用启动,但执行 complete 时会 fail closed。
- 测试实现:测试类内部 `RecordingEnrollmentPort`,另有 lambda throwing fake 用于异常传播;都不进入生产 runtime。
- 分类:**Case C — 只有接口与测试 Fake,生产尚未接线**。
没有注册 silent no-op 实现,也没有伪造 Enrollment UPDATE。当前没有生产 adapter 时,`completePlan` 在任何 Plan 查询或状态更新前抛出无业务错误码的内部 `IllegalStateException`;请求失败,Plan 不会产生单边 `status=2`。ISSUE-03-01 必须实现真实 adapter,并以集成测试验证 `enrollment_status=4`、`complete_time` 以及任一步失败时的跨表回滚。
### AC-03 拆分
- **AC-03a — UNIT_PASS**:单元测试验证 Plan 状态更新、Completion Port 恰好调用一次、缺失 Port 时 fail closed、端口异常不被吞掉;同时确认 `completePlan` 使用 `@Transactional(rollbackFor=Exception.class)` 且端口在方法内同步调用。
- **AC-03b — INTEGRATION_PENDING (ISSUE-03-01)**:真实 Enrollment 批量 `status=4 + complete_time` 及跨表提交/回滚尚未验证。
- **Integration Safety — UNIT_PASS**:Port missing 时在 Plan update 前失败,状态更新调用次数为 0,Plan 保持原状态。
当前证据只能表述为“transaction orchestration seam 已验证”;不能表述为完整跨表事务或 Enrollment runtime 已通过。
## 7. 错误码映射与 Contract Gap
| 常量 | 值 | 冻结语义与来源 | 当前触发位置 |
|---|---:|---|---|
| `PLAN_TIME_INVALID` | 40010 | API 10 错误码表;Module B 1.2;ISSUE-02-01 | create/update 的 `start >= end`;`reportDeadline > startTime` 同属计划时间参数校验 |
| `PLAN_NAME_DUPLICATED` | 40011 | API 10;Module B 1.2;ISSUE-02-01 | create/update 的 Service 友好名称预查重 |
| `PLAN_STATUS_LOCKED` | 40012 | API 10;ISSUE-02-01 | 状态 2/3 编辑;状态 3 complete |
| `PLAN_LESSON_OUT_OF_BOUNDS` | 40013 | API 10(UC-02) | 本 Issue 不实现 Lesson,当前无触发点;待 Scheduling 边界接入,未伪造校验 |
| `PLAN_ALREADY_COMPLETED` | 40014 | API 10(UC-29);ISSUE-02-01 | 状态 2 重复 complete |
| `PLAN_NOT_FOUND` | 40410 | 冻结 Issue Backlog 的多个 Plan 依赖用例 | detail/update/complete 的计划不存在 |
API 10 的全局错误码表未列 40410、Module B 1.3 也未为详情逐项列 not-found code,但冻结 Issue Backlog 已使用 `PLAN_NOT_FOUND (40410)`。实现未发明新码;此目录间缺口仅登记,不修改冻结文档。招生人数由 DTO Bean Validation 处理,Service 防御校验使用普通 `ServiceException`,不再错误复用 40010,也未新增业务错误码。
## 8. 名称唯一与技术主键
- Database V2 没有 `UNIQUE(plan_name)`;当前只有 Service 层忽略首尾空格与大小写的友好重复预检查,并发强唯一性不保证。本 Issue 不新增 UNIQUE。
- “自动编号”仅指 `plan_id = IdUtils.fastSimpleUUID()` 生成的 32 位 UUID 技术主键。没有、也未发明 `plan_code` 或 `plan_no` 物理字段。
## 9. 测试依赖与 14 个测试方法
`ruoyi-train/pom.xml` 仅新增:
```xml
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
```
没有新增 Mockito、Spring Boot Test、H2 或生产 runtime 依赖。全部 14 个测试使用 JVM `InMemoryPlanService` Fake 和测试 Port,不 Mock JDBC、不连接也不写真实数据库:
| # | `@Test` 方法 | 验证规则 |
|---:|---|---|
| 1 | `createPlanShouldGenerateIdAndForceInitialStatus` | 32 位 `plan_id`、名称 trim、强制状态 0、审计人,并通过反射确认 Domain 无 `cId/tId` |
| 2 | `createPlanShouldRejectReversedOrEqualTime` | `start > end` 与 `start == end` 均返回 40010 |
| 3 | `createPlanShouldRejectReportDeadlineAfterStart` | 报到截止晚于开班返回 40010 |
| 4 | `createPlanShouldRejectDuplicatedName` | 名称 trim/忽略大小写预查重返回 40011 |
| 5 | `getPlanShouldReturnDetailAndRejectMissingPlan` | detail VO 映射及不存在返回 40410 |
| 6 | `getPlanShouldExposeInProgressStatusAfterStartTime` | 0→1 有效状态只读呈现,且持久化 Fake 中仍为 0,证明查询不回写 |
| 7 | `updatePlanShouldUpdateEditablePlan` | 状态 0/1 可编辑与审计更新 |
| 8 | `updatePlanShouldRejectCompletedOrCancelledPlan` | 状态 2、状态 3 分别禁止 edit,均返回 40012 |
| 9 | `completePlanShouldUpdateStatusAndInvokeEnrollmentBoundary` | Plan→2、Port 参数/次数及事务注解 |
| 10 | `completePlanShouldPropagateEnrollmentPortFailure` | Port 运行时异常保持原异常向事务代理传播;Fake 记录更新发生在 Port 调用前 |
| 11 | `completePlanShouldFailClosedWhenEnrollmentPortMissing` | 无 Port 时抛内部异常、状态更新调用为 0、Plan 保持状态 0 |
| 12 | `completePlanShouldRejectRepeatedCompletion` | 重复 complete 返回 40014,Port 不重复调用 |
| 13 | `completePlanShouldRejectMissingOrCancelledPlan` | 不存在返回 40410;状态 3 complete 返回 40012 |
| 14 | `listPlansShouldDelegatePlanQuery` | list 委托并返回 `PlanItemVO` |
该单元测试不能证明真实 Spring 事务回滚或 Enrollment SQL;这些证据属于 AC-03b 集成门禁。
## 10. Database 与权限影响
- 未执行 Generator Import;无 DDL、迁移、ALTER/CREATE/DROP。
- 未修改 `007_dicts.sql`、`sys_menu`、`sys_dict`、`sys_role`、`sys_role_menu`。
- 未写任何测试/正式业务数据;测试均为 JVM 内存数据。
- 后端权限严格为 `train:plan:list/query/add/edit/complete`;前端按钮只使用 `train:plan:add/edit/complete`,无 `system:plan:*`。
## 11. Acceptance Criteria
| AC | 状态 | 证据与边界 |
|---|---|---|
| AC-01 逆序日期返回 40010 | UNIT_PASS | 逆序和相等边界均覆盖。 |
| AC-02 状态 2 修改返回 40012 | UNIT_PASS | 同时覆盖历史状态 3。 |
| AC-03a Plan complete orchestration seam | UNIT_PASS | 状态更新、Port 一次调用、缺失 Port fail closed、异常传播及事务注解已验证。 |
| AC-03b 30 名 Enrollment 真实联动 | INTEGRATION_PENDING (ISSUE-03-01) | 未实现 Enrollment adapter/SQL,后续必须验证 status=4、complete_time 与跨表回滚。 |
| Integration Safety | UNIT_PASS | Port missing 时 update 调用为 0,Plan 不会单边持久化为 2。 |
## 12. 验证记录
- `mvn test -pl ruoyi-train -am -f RuoYi-Vue/pom.xml`:14 tests,0 failures,0 errors,0 skipped,`BUILD SUCCESS`。
- `mvn compile -DskipTests -f RuoYi-Vue/pom.xml`:8 个 Reactor 模块全部成功,`BUILD SUCCESS`。
- 评审指定 ESLint(主目录已安装 executable + `--resolve-plugins-relative-to`,工作目录仍为本 Issue worktree):退出码 0,无 error/warning。
- `npm run build:prod`:build complete;仅有仓库既有 `src/views/graph/antv/index.vue` 的 `@antv/x6 Addon` warning,与本 Issue 文件无关。
- Final Review 本轮未修改任何前端文件;沿用上一轮 ESLint/build 结果。
- 最终 `git diff --check`、`git status --short`、`git diff --stat` 结果以交付回复为准。
- 未执行 commit/merge/rebase,等待技术负责人复审。
@@ -0,0 +1,202 @@
# Generator 基础设施补丁 — Kingbase Import Identity 修复记录
本文档详细记录 RuoYi Generator 代码生成器在 KingbaseES 数据库环境下的核心运行时兼容性问题排查、补丁设计、安全加固与验证全过程。
---
## 1. 问题背景
RailwaySystem 后台基于 RuoYi-Vue 架构改造,数据库由 MySQL 迁移至国产 KingbaseES (V8R6 / PostgreSQL 语法体系)。代码生成器(`ruoyi-generator` 模块)用于读取数据库系统元数据、导入数据表配置并自动生成前端 Vue 与后端 Java/MyBatis 代码。
在 Generator 元数据体系中,包含两张核心表:
- `gen_table`:存储已导入代码生成的表元数据;
- `gen_table_column`:存储表关联的列元数据。
在最初从 MySQL 初始化表结构至 Kingbase 时,这两张表的自增主键定义未能正确映射为 Kingbase 标准的 Identity 列。
---
## 2. 第一次真实 Import 如何暴露问题
在推进首个业务模块试点开发(**Teacher Pilot**,教师主数据模块)期间,需要通过前端代码生成管理界面(`/tool/gen`)调用“导入表(Import Table)”功能将 `train_teacher` 表导入至 Generator 配置库中。
当点击导入并提交时,后端触发了 `GenTableServiceImpl.importGenTable` 调用。此时服务端直接抛出数据库异常,表导入操作失败,导致后续代码生成流程无法继续。
---
## 3. 错误现象
后端日志与数据库抛出类似如下的主键冲突与唯一约束冲突异常:
```text
### Error updating database. Cause: com.kingbase8.util.KSQLException: ERROR: duplicate key value violates unique constraint "gen_table_pkey"
Detail: Key (table_id)=(0) already exists.
### The error may exist in URL [jar:file:.../ruoyi-generator.jar!/mapper/generator/GenTableMapper.xml]
### The error may involve com.ruoyi.generator.mapper.GenTableMapper.insertGenTable-Inline
### The error occurred while setting parameters
### SQL: insert into gen_table ( table_name, table_comment, class_name, ... ) values ( ?, ?, ?, ... )
```
对于 `gen_table_column`,同样在批量插入字段元数据时出现所有字段的 `column_id` 均被赋值为默认值 `0`,从而连续抛出主键冲突。
---
## 4. 根因分析
1. **MyBatis 配置与运行时预期**:
`GenTableMapper.xml` 与 `GenTableColumnMapper.xml` 中的插入语句均配置了:
```xml
<insert id="insertGenTable" parameterType="GenTable" useGeneratedKeys="true" keyProperty="tableId">
<insert id="insertGenTableColumn" parameterType="GenTableColumn" useGeneratedKeys="true" keyProperty="columnId">
```
RuoYi 的应用层期望数据库能够在插入记录时自动生成唯一递增主键,并通过 JDBC 驱动的 `getGeneratedKeys` 返回该主键值并回填至 Java 实体对象的 `tableId` / `columnId`。
2. **原始 MySQL 建表定义**:
在 `RuoYi-Vue/sql/ry_20250522.sql` 中,原始表结构为:
```sql
CREATE TABLE gen_table (
table_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
...
PRIMARY KEY (table_id)
);
CREATE TABLE gen_table_column (
column_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
...
PRIMARY KEY (column_id)
);
```
3. **Kingbase 结构缺失 Identity / Sequence**:
在早期导入 Kingbase 实例时,由于 KingbaseES 不支持 MySQL 方言的 `AUTO_INCREMENT` 关键字,该列在创建时仅退化为 `BIGINT NOT NULL`,未配置 `GENERATED BY DEFAULT AS IDENTITY`,也未创建对应的自增序列(Sequence)。
由于 INSERT 语句未指定主键值,在某些兼容模式下默认填入 `0`,第二次插入必然导致唯一主键冲突。
4. **定位结论**:
本问题属于典型的 **Generator 运行时基础设施兼容性缺陷(Generator Runtime Compatibility Bug)**,与业务层 Database V2 数据模型无关。
---
## 5. 补丁文件拆分与核心 DDL
为了避免局部补丁状态(Partial Patch)下执行发生冲突,补丁拆分为两个独立文件:
### 5.1 `002a_gen_table_identity_patch.sql`
```sql
ALTER TABLE gen_table
ALTER COLUMN table_id
ADD GENERATED BY DEFAULT AS IDENTITY;
```
### 5.2 `002b_gen_table_column_identity_patch.sql`
```sql
ALTER TABLE gen_table_column
ALTER COLUMN column_id
ADD GENERATED BY DEFAULT AS IDENTITY;
```
执行后,KingbaseES 底层会自动创建并维护两个关联序列:
- `gen_table_table_id_seq`
- `gen_table_column_column_id_seq`
---
## 6. Precheck(预检)与 Partial Patch 安全机制
### 6.1 Partial Patch Safety(局部补丁状态安全)
在实际运维过程中,数据库可能处于以下四种状态之一:
1. **Case 1 (`CASE_1_BOTH_NEED_PATCH`)**:两列均未配置 Identity;
2. **Case 2 (`CASE_2_BOTH_ALREADY_PATCHED`)**:两列均已配置 Identity;
3. **Case 3 (`CASE_3_ONLY_COLUMN_NEEDS_PATCH`)**:`table_id` 已修复,但 `column_id` 未修复;
4. **Case 4 (`CASE_4_ONLY_TABLE_NEEDS_PATCH`)**:`column_id` 已修复,但 `table_id` 未修复。
若在 Case 3 下全量执行整脚本,Kingbase 执行 `gen_table` ALTER 时将直接报错 `column "table_id" of relation "gen_table" is already an identity column` 并中断退出。
**解决方案**:
- `001_generator_identity_precheck.sql` 会直接输出全局 Case 判定;
- 补丁拆分为 `002a` 与 `002b`,根据 Precheck 的判定选择性执行,实现精准幂等与局部安全性。
---
## 7. Existing Metadata Safety 与 Sequence Alignment
### 7.1 核心原则
> **Identity 属性存在 ≠ Sequence 一定适合下一次 Insert**
在执行 `ALTER TABLE ... ADD GENERATED BY DEFAULT AS IDENTITY` 时:
- **空表环境(0 rows)**:默认 Sequence `start_value = 1`,下一次插入分配 ID 为 1,天然安全;
- **已有数据环境(已存在历史元数据)**:
- Kingbase `ADD IDENTITY` 默认创建从 1 开始的 Sequence,**不宣称自动对齐已有数据的 MAX(id)**;
- 若直接执行 Insert,Sequence 将尝试从 1 开始分配,如果已有数据包含 `id = 1`,将再次引发主键冲突!
### 7.2 Sequence 对齐机制(Sequence Alignment)
对于非空表环境,执行 Patch 后必须通过 Verify 脚本校验序列游标:
- 计算表达式:`next_id = (CASE WHEN is_called THEN last_value + 1 ELSE last_value END)`
- 若 `next_id > MAX(existing_id)`,判定为 `SAFE`;
- 若 `next_id <= MAX(existing_id)`,判定为 `UNSAFE`,必须由 DBA 执行专项对齐脚本:
```sql
-- 同步 Sequence 游标至当前最大值之后
SELECT setval('gen_table_table_id_seq', (SELECT COALESCE(MAX(table_id), 0) + 1 FROM gen_table), false);
SELECT setval('gen_table_column_column_id_seq', (SELECT COALESCE(MAX(column_id), 0) + 1 FROM gen_table_column), false);
```
---
## 8. Verify(复核验证)
执行只读复核脚本 `003_generator_identity_verify.sql`,执行四大维度的综合核验:
1. **核验列属性**:确认两列 `attidentity = 'd'`;
2. **核验 Sequence 存在性**:确认关联序列存在且 `relkind = 'S'`;
3. **核验依赖关系**:确认 `sys_depend.deptype = 'i'`(内部生命周期绑定);
4. **核验 Sequence 对齐安全性**:确认两表的 Sequence 处于 `SAFE` 或 `EMPTY_TABLE_SAFE` 状态;
5. **最终判定**:四项维度全通过输出 `VERIFY: PASS`。
---
## 9. Rollback 风险说明
若因特殊场景需要撤销该修改,语法为:
```sql
ALTER TABLE gen_table ALTER COLUMN table_id DROP IDENTITY IF EXISTS;
ALTER TABLE gen_table_column ALTER COLUMN column_id DROP IDENTITY IF EXISTS;
```
### 回滚风险评估
1. **Generator 瘫痪**:一旦移除 Identity,Generator 将立即恢复至无法导入表的故障状态;
2. **元数据序列断裂**:若已有元数据记录,回滚后再配置可能造成序列重新错位;
3. **结论**:该补丁作为 RuoYi 适配 Kingbase 必需的基础设施配置,**严禁回滚**;任何环境初始化后均应持久化保留此配置。
---
## 10. 新环境部署要求
在搭建全新 Kingbase 部署环境或自动化 CI/CD 环境时:
1. 执行若依基础建表脚本;
2. 执行预检:
```bash
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f RuoYi-Vue/sql/generator/kingbase/001_generator_identity_precheck.sql
```
3. 按照决策矩阵执行对应补丁(空表全新环境直接依次执行 `002a` 和 `002b`);
4. 执行复核验证并确认输出 `VERIFY: PASS`:
```bash
ksql -h <HOST> -p <PORT> -U <USER> -d <DBNAME> -f RuoYi-Vue/sql/generator/kingbase/003_generator_identity_verify.sql
```
---
## 11. 与 Database V2 无关性声明
- **范围隔离**:本修复纯属 Generator 工具层基础设施兼容补丁,仅修改 `public.gen_table` 与 `public.gen_table_column` 两张系统级代码生成支撑表的主键属性;
- **业务无关**:零改动 21 张 `train_*` 业务模型表;零改动任何业务枚举、外键约束与数据字典;
- **版本独立**:补丁存放于 `RuoYi-Vue/sql/generator/kingbase/`,独立于业务迁移目录 `RuoYi-Vue/sql/train/v2/`。
---
## 12. Teacher Pilot 验证事实与当前共享库核验
- 在 Teacher Pilot(`feat/issue-01-01-teacher`)开发期间,共享 Kingbase 数据库已应用该 DDL 修复;
- 真实导入测试:调用 Generator 导入 `train_teacher` 表,`gen_table` 成功生成非零递增主键(`table_id = 1`),同时其 16 个字段成功写入 `gen_table_column`(`column_id = 1..16`);
- 代码生成测试:成功基于导入元数据生成教师主数据相关 Controller、Service、Mapper、Domain 及前端 Vue 页面,未产生任何主键冲突;
- **当前共享库只读核验数据(实测)**:
- `gen_table`: `row_count = 1`, `max_id = 1`, `sequence last_value = 2`, `is_called = true`,下一个分配值为 3 > 1,状态为 `SAFE`;
- `gen_table_column`: `row_count = 16`, `max_id = 16`, `sequence last_value = 32`, `is_called = true`,下一个分配值为 33 > 16,状态为 `SAFE`;
- 属性 `attidentity = 'd'`,序列依赖均为 `deptype = 'i'`;
- 复核脚本输出:`VERIFY: PASS (所有 Identity、Sequence、依赖关系与序列对齐安全核验一致通过)`。
+36 -32
View File
@@ -4,22 +4,23 @@
> **业务最高基线:** `doc/RailwaySystem 智慧培训系统一期 PRD V1.0.md`
> **数据库基线:** `db-v2-migration-passed-20260914` (21张表 KingbaseES 物理迁移验证通过)
> **代码生成器基线:** `generator-v2-ready-20260914` (Kingbase 只读验证通过)
> **API 契约基线:** `api-v2-contract-v1.0` (Review Round 1 修订版,91 个 API)
> **本阶段原则:** **业务能力驱动拆分,严禁一张表机械对应一个 Issue;只拆 P0,P1/P2 仅作 Backlog 登记;严禁编写业务代码与修改数据库。**
> **API 契约基线:** `api-v2-contract-v1.0.1` (89 个物理 API 端点,P0: 53, P1: 34, P2: 2)
> **审查基线:** `docs/issues-v1/09_issue_review_round1.md` (Review Round 1 Final = A)
> **实施策略:** 严格以 PRD V1.0 业务闭环为主线,以 Database V2 为持久化底座,聚焦 P0 最小闭环。
---
## 1. 拆分目标与核心原则
本阶段旨在将 `api-v2-contract-v1.0` 中的 **49 个 P0 API** 以及支撑业务运转的核心基础设施,转化为**可独立开发、可代码审查 (Review)、可自动化测试、可业务验收**的 Epic 与 Issue。
本阶段旨在将 `api-v2-contract-v1.0.1` 中的 **53 个 P0 API** 以及支撑业务运转的核心基础设施,转化为**可独立开发、可代码审查 (Review)、可自动化测试、可业务验收**的 Epic 与 Issue。
### 1.1 最高拆分原则
1. **业务能力驱动,拒绝单表 CRUD:**
- 错误做法:拆分为 `开发 train_enrollment 表 CRUD`、`开发 train_lesson 表 CRUD`。
- 正确做法:拆分为 `实现培训计划参训人员管理 (含防重与名单)`、`实现课次排课与四维冲突排他锁检测`。
2. **只拆 P0 闭环,严格控制范围:**
- 锁定 49 个 P0 API 作为本轮研发唯一核心目标;
- 40 个 P1 辅助 API 与 2 个 P2 远期 API 仅在 [`07_p1_p2_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/07_p1_p2_backlog.md) 中登记备查,不计入开发排期。
本阶段**严禁编写任何业务代码**,所有拆解严格以 `api-v2-contract-v1.0`(commit `67543e04`)及 V1.0.1 修订为基线展开。
### 1.1 核心原则与硬约束
1. **业务能力驱动,拒绝单表 CRUD:** 严禁以数据库物理表为单位拆分 Issue(如禁止设立“开发 train_lesson CRUD”),必须按“可独立交付与验收的业务动作/能力”进行拆分(如“实现课次排课与三类资源冲突检测”);
2. **只拆 P0,严格范围控制:**
- 锁定 53 个 P0 API 作为本轮研发唯一核心目标;
- 34 个 P1 辅助 API 与 2 个 P2 远期 API 仅在 [`07_p1_p2_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/07_p1_p2_backlog.md) 中登记备查,不计入开发排期。
3. **主数据最小必要集合原则:**
- 基础主数据不作为全量阻塞项,仅建设支撑主业务链路所必需的最小集合(Teacher, Student, Course, Classroom, TrainingSite, Dormitory)。
4. **不变量与并发控制强行绑定:**
@@ -71,47 +72,50 @@ flowchart TD
| 统计维度 | 指标数值 | 备注说明 |
|:---|:---|:---|
| **Epic 总数** | **12 个** | 覆盖主数据、核心计划、排课、住宿、门禁、考勤、成绩、学习、统计、权限字典与端到端验收 |
| **P0 Issue 总数** | **21 个** | 每个 Issue 为 0.5~2 个开发日的独立可验收业务能力 |
| **P0 API 覆盖率** | **100% (49/49)** | 完整映射 API Contract 中全部 49 个 P0 API(且涵盖 Module A 全部 20 个接口) |
| **Contract Gap 数量** | **2 项** | 详见本文档第 4 节及 Risk Register,技术风险完全可控 |
| **P0 Issue 总数** | **26 个** | 每个 Issue 为 0.5~2 个开发日的独立可验收业务能力 |
| **P0 API 覆盖率** | **53 / 53 (100%)** | 契约逐条提取 53 个 P0 API 全量映射闭环,无遗漏无多余 |
| **Contract Gap 状态** | **CG-01: RESOLVED<br/>CG-02: RESOLVED** | CG-01 采纳 53 个逐 API 正式口径;CG-02 扩展 VO 回显字段闭环 |
| **开发 Wave 数量** | **8 个** | Wave 0 (准备) ~ Wave 7 (验收),层层递进 |
| **首批可立即开发 Issue** | **8 个** | Wave 0 (3个) + Wave 1 中不依赖 Plan 的基础数据 (5个) |
| **必须等待 Module B 的 Issue** | **10 个** | 涵盖排课、住宿分配、报到、考勤、成绩配置/录入、学习记录、工作台与端到端验收 |
| **Generator 策略分布** | YES: **4**, PARTIAL: **10**, NO: **7** | 基础表用 YES/PARTIAL 生成骨架,核心领域逻辑与聚合手写 |
| **Recommended Agent 分工** | Gemini: **9**, Codex: **12** | Gemini 负责基础 CRUD/VO/权限字典;Codex 负责高并发锁/复杂事务/不变量/端到端 |
| **必须等待 Module B 的 Issue** | **13 个** | 涵盖排课、住宿分配、报到、考勤、成绩配置/录入、学习记录、工作台与端到端验收 |
| **Generator 策略分布** | **YES: 4, PARTIAL: 11, NO: 11** | 机器校验:4 + 11 + 11 = 26 (符合 P0 Issue 总数 26) |
| **Recommended Agent 分工** | **Gemini: 11, Codex: 15** | Gemini 负责基础 CRUD/VO/权限字典;Codex 负责高并发锁/复杂事务/不变量/端到端 |
---
## 4. 契约偏差审查 (Contract Gap Analysis)
## 4. 契约偏差审查与裁决落地 (Contract Gap Analysis)
在对齐 `api-v2-contract-v1.0` 与 Issue 拆分过程中,识别并登记了 2 项契约统计与定义的细微偏差(Contract Gap):
在对齐 `api-v2-contract-v1.0` 与 Issue 拆分过程中登记的 2 项契约偏差,技术负责人已作出正式裁决并全部闭环:
### 4.1 Gap 1:Module A P0 统计数字与正文标注偏差 (16 vs 20)
- **现象:** `00_readme.md` 与 `02_module_a_base_resource_api.md` 概览统计声明 Module A 拥有 16 个 P0 API、22 个 P1 API(总数 38)。但正文中带有【P0】标记的条目实际有 20 个(教师 4 个、学员 3 个、课程 3 个、教室 3 个、场地 3 个、宿舍 4 个)。
- **根因:** 宿舍管理的 4 个接口(`GET /train/dormitory/list`, `GET /train/dormitory/{dormId}`, `POST /train/dormitory`, `GET /train/dormitory/options`)在正文中被标记为 P0,但概览统计口径中未将其计入主数据最小必要集合(4+3+3+3+3 = 16)。
- **处理策略:**
- 本次 Issue 拆分采取**最大化安全覆盖原则**:设立独立 `ISSUE-01-05` 完整实现宿舍 4 个基础维护接口,保证无论是按 16 还是 20 统计口径,实际业务均不受任何阻塞;
- 正式 API 覆盖率判定中,49/49 个 P0 API 实现 100% 全覆盖。
### 4.1 CG-01:Module A P0 统计数字与逐 API 标注矛盾【状态:RESOLVED】
- **裁决结论:** **详细 API 标记优先于错误摘要统计;P0 正式集合以 53 个逐 API 标记为准。**
- **落地执行:**
- 宿舍 4 个基础维护 API(列表、详情、新增、选项)属于一期 P0,因为 Accommodation 住宿主流程强依赖可维护的宿舍房间基础资源、核定容量与性别隔离属性;
- Module A P0 确认为 20 个,全局 P0 集合确定为 53 个(Module A: 20, Module B: 17, Module C: 9, Module D: 7);
- 由 `ISSUE-01-05` 完整承揽,P0 覆盖率正式达成 **53/53 (100%)**。
### 4.2 Gap 2:Module B 课次单查接口定义缺失
- **现象:** `00_readme.md` 第 136 行概览描述写有“课次详情”,但 `03_module_b_plan_enrollment_schedule_api.md` 正文中未独立定义 `GET /train/lessons/{lessonId}` 接口。
- **根因:** 排课界面的数据回显完全由 `GET /train/plans/{planId}/timetable`(完整课表 VO)及冲突预检接口支撑,不需要对单课次频繁发起独立的详情 HTTP 请求。
- **处理策略:** 严格遵循“不得在 Issue 阶段擅自发明新 API”铁律,暂不新增独立的课次详情接口,排课回显通过现有 Timetable 接口与课次列表解决;若后续前端组件有强诉求,由技术负责人走 API Contract Change 流程统一增补。
### 4.2 CG-02:课次编辑所需字段与单查接口缺失【状态:RESOLVED】
- **裁决结论:** **通过扩展 TimetableLessonItemVO 解决;不新增 Lesson 详情 API。**
- **落地执行:**
- 严禁在 Issue 阶段或契约中新增 `GET /train/lessons/{lessonId}` 端点,保持 API Contract 接口数量稳定;
- 在 `docs/api-v2/06_dto_vo_catalog.md` 中补齐 `TimetableLessonItemVO` 的关键外键关联字段(`planCourseId`、`tId`、`venueType`、`classRoomId`、`classRoomName`、`siteId`、`siteName`、`remark`);
- 前端通过完整课表 `PlanTimetableVO` 直接回显 Lesson 编辑表单,随后调用既有的 `PUT /train/lessons/{lessonId}`,流程完全自洽闭环。
---
## 5. 文档索引导航 (docs/issues-v1/)
本套 Issue 任务分解规范包含以下 9 篇文档:
本套 Issue 任务分解规范包含以下 10 篇文档:
| 序号 | 文档名称 | 核心内容 |
|:---|:---|:---|
| 00 | [`00_readme.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/00_readme.md) | 阶段总纲、最高原则、主链路、统计总览、Contract Gap 分析与文档索引 |
| 01 | [`01_epic_overview.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/01_epic_overview.md) | 12 个业务 Epic 概览、目标、业务边界与包含的 Issue 对应清单 |
| 02 | [`02_p0_issue_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/02_p0_issue_backlog.md) | 21 个 P0 Issue 详细技术卡片(严格包含 22 项技术定义与 Given/When/Then 验收准则) |
| 02 | [`02_p0_issue_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/02_p0_issue_backlog.md) | 26 个 P0 Issue 详细技术卡片(严格包含 22 项技术定义与 Given/When/Then 验收准则) |
| 03 | [`03_dependency_graph.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/03_dependency_graph.md) | Mermaid DAG 依赖图,清晰展示串行依赖与并行泳道 |
| 04 | [`04_wave_plan.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/04_wave_plan.md) | Wave 0 ~ Wave 7 开发推进批次计划、进入条件、产出目标与退出准则 |
| 05 | [`05_agent_assignment.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/05_agent_assignment.md) | Gemini Flash 与 Codex 研发分工建议及 Generator (YES/PARTIAL/NO) 执行策略 |
| 06 | [`06_acceptance_matrix.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/06_acceptance_matrix.md) | 全链路追踪矩阵 (UC → API → Issue → Test) 及 5 大 Invariant / 1 项生命周期策略归属矩阵 |
| 07 | [`07_p1_p2_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/07_p1_p2_backlog.md) | 40 个 P1 辅助 API 与 2 个 P2 远期 API 的统一登记与后续演进规划 |
| 08 | [`08_risk_register.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/08_risk_register.md) | 技术风险登记册、并发防死锁策略、外部 Mock 隔离与现场确认事项应对策略 |
| 07 | [`07_p1_p2_backlog.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/07_p1_p2_backlog.md) | 34 个 P1 辅助 API 与 2 个 P2 远期 API 的统一登记与后续演进规划 |
| 08 | [`08_risk_register.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/08_risk_register.md) | 技术风险登记册、稳定资源锁策略、外部 Mock 隔离与 CG-01 裁决辅助表 |
| 09 | [`09_issue_review_round1.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/09_issue_review_round1.md) | Phase 3 Issue Review Round 1 审查整改记录与结论报告 |
+2 -2
View File
@@ -219,7 +219,7 @@ flowchart TB
- `ISSUE-11-01`: RuoYi RBAC 权限体系与按钮授权集成 (RBAC Migration & Train Permissions)
- `ISSUE-11-02`: 培训业务数据字典与参数配置初始化方案 (Dictionary & SysConfig Strategy)
- **对应 P0 API:**
- 支撑全部 49 个 P0 API 的鉴权拦截与前端按钮 `v-hasPermi` 显隐控制。
- 支撑全部 53 个 P0 API 的鉴权拦截与前端按钮 `v-hasPermi` 显隐控制。
- **涉及数据表:** `sys_menu`, `sys_role_menu`, `sys_dict_type`, `sys_dict_data`, `sys_config`
- **明确不做什么:** 本轮严禁执行 `007_dicts.sql` 物理入库;不做动态自定义数据权限过滤规则(一期采用基于角色的标准 RBAC)。
@@ -231,5 +231,5 @@ flowchart TB
- **包含 P0 Issue:**
- `ISSUE-12-01`: 智慧培训全生命周期主链路端到端集成测试与验收 (End-to-End Acceptance Suite)
- **对应 P0 API:**
- 贯穿覆盖全部 49 个 P0 API 的链路式调用与数据验证。
- 贯穿覆盖全部 53 个 P0 API 的链路式调用与数据验证。
- **业务价值:** 彻底杜绝“各个单接口测试通过,连起来无法走通完整培训班”的集成陷阱,作为 Phase 3 研发交付的最终质量闸门。
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
# RailwaySystem Phase 3:Issue 依赖关系有向无环图 (Dependency DAG)
> **设计目标:** 明确各 Issue 之间的前后置依赖关系,清晰识别哪些任务必须串行推进、哪些任务可以无阻塞并行开发,指导团队实现资源利用率最大化。
---
## 1. Issue 全景依赖拓扑图 (Mermaid DAG)
```mermaid
flowchart TB
subgraph Wave0["Wave 0:基线支撑 (可立即无前置并行启动)"]
I1101["ISSUE-11-01<br/>RBAC权限体系"]
I1102["ISSUE-11-02<br/>字典配置方案"]
I0601["ISSUE-06-01<br/>外部Adapter SPI与Mock"]
end
subgraph Wave1["Wave 1:基础主数据与计划立项 (可立即并行启动)"]
I0101["ISSUE-01-01<br/>教师主数据"]
I0102["ISSUE-01-02<br/>学员主档与脱敏"]
I0103["ISSUE-01-03<br/>课程库资料"]
I0104["ISSUE-01-04<br/>空间资源:教室与场地"]
I0105["ISSUE-01-05<br/>宿舍房间与楼宇"]
I0201["ISSUE-02-01<br/>培训计划全生命周期"]
end
subgraph Wave2["Wave 2:计划配课、参训与排课"]
I0202["ISSUE-02-02<br/>本期课程引入"]
I0301["ISSUE-03-01<br/>本期参训名单维护"]
I0302["ISSUE-03-02<br/>名单Excel批量导入"]
I0401["ISSUE-04-01<br/>排课与四维冲突排他锁"]
I0402["ISSUE-04-02<br/>完整课表聚合VO"]
end
subgraph Wave3["Wave 3:住宿、门禁与报到"]
I0501["ISSUE-05-01<br/>批量住宿预分配防超卖"]
I0502["ISSUE-05-02<br/>调房走读管理"]
I0602["ISSUE-06-02<br/>门禁afterCommit解耦同步"]
I0503["ISSUE-05-03<br/>学员报到与时限控制"]
end
subgraph Wave4["Wave 4:课次考勤"]
I0701["ISSUE-07-01<br/>课次打卡与INV-01"]
I0702["ISSUE-07-02<br/>考勤名单与人工修正"]
end
subgraph Wave5["Wave 5:成绩与学习记录"]
I0801["ISSUE-08-01<br/>成绩项目配置与INV-04"]
I0802["ISSUE-08-02<br/>成绩录入台账INV-02/05"]
I0901["ISSUE-09-01<br/>学习记录接入与INV-03"]
end
subgraph Wave6["Wave 6:工作台与成效统计"]
I1001["ISSUE-10-01<br/>培训计划综合工作台"]
I1002["ISSUE-10-02<br/>计划成效统计概览"]
end
subgraph Wave7["Wave 7:全链路端到端验收"]
I1201["ISSUE-12-01<br/>P0主链路端到端集成验收"]
end
%% 跨模块依赖连线
I0103 --> I0202
I0201 --> I0202
I0102 --> I0301
I0201 --> I0301
I0301 --> I0302
I0202 --> I0401
I0101 --> I0401
I0104 --> I0401
I0401 --> I0402
I0301 --> I0501
I0105 --> I0501
I0501 --> I0502
I0501 --> I0602
I0601 --> I0602
I0301 --> I0503
I0401 --> I0701
I0301 --> I0701
I0503 --> I0701
I0701 --> I0702
I0202 --> I0801
I0801 --> I0802
I0301 --> I0802
I0301 --> I0901
I0202 --> I0901
I0601 --> I0901
I0201 --> I1001
I0301 --> I1001
I0401 --> I1001
I0503 --> I1001
I0503 --> I1002
I0701 --> I1002
I0802 --> I1002
I0901 --> I1002
I1001 --> I1201
I1002 --> I1201
I0702 --> I1201
I0502 --> I1201
I0602 --> I1201
style Wave0 fill:#f1f8e9,stroke:#8bc34a,stroke-width:1px
style Wave1 fill:#e0f7fa,stroke:#00bcd4,stroke-width:1px
style Wave2 fill:#e8eaf6,stroke:#3f51b5,stroke-width:1px
style Wave3 fill:#fce4ec,stroke:#e91e63,stroke-width:1px
style Wave4 fill:#fff3e0,stroke:#ff9800,stroke-width:1px
style Wave5 fill:#ede7f6,stroke:#673ab7,stroke-width:1px
style Wave6 fill:#f3e5f5,stroke:#9c27b0,stroke-width:1px
style Wave7 fill:#ffebee,stroke:#f44336,stroke-width:2px
```
---
## 2. 串行依赖关键路径 (Critical Path)
系统的**最短端到端主链路关键路径**(耗时最长、环环相扣的核心串行线)如下:
$$\begin{aligned}
\text{ISSUE-01-03 (课程库)} &\longrightarrow \text{ISSUE-02-01 (创建计划)} \\
&\longrightarrow \text{ISSUE-02-02 (配置本期课程)} \\
&\longrightarrow \text{ISSUE-04-01 (课次排课与冲突锁)} \\
&\longrightarrow \text{ISSUE-07-01 (课次考勤打卡)} \\
&\longrightarrow \text{ISSUE-10-02 (计划综合统计)} \\
&\longrightarrow \text{ISSUE-12-01 (端到端验收)}
\end{aligned}$$
同时并行的辅关键路径为参训与住宿线:
$$\text{ISSUE-01-02 (学员)} \longrightarrow \text{ISSUE-03-01 (名单)} \longrightarrow \text{ISSUE-05-01 (住宿)} \longrightarrow \text{ISSUE-05-03 (报到)} \longrightarrow \text{ISSUE-07-01 (考勤)}$$
---
## 3. 并行开发泳道分析
通过上述 DAG,可以清晰划分出以下 4 条高并发研发泳道:
### 泳道 A:基础设施与主数据泳道 (可完全自由推进)
- `ISSUE-11-01` (RBAC权限)
- `ISSUE-11-02` (字典配置方案)
- `ISSUE-06-01` (外部 Adapter SPI & Mock)
- `ISSUE-01-01` (教师主数据)
- `ISSUE-01-02` (学员主档与脱敏)
- `ISSUE-01-03` (课程库)
- `ISSUE-01-04` (教学空间资源)
- `ISSUE-01-05` (宿舍基础房间)
> **特征:** 相互之间完全解耦,可由多位开发者或 Gemini Flash 代理人并行同时开工,0 依赖等待。
### 泳道 B:排课教学线 (依赖课程与计划)
- `ISSUE-02-01` (计划立项)
- `ISSUE-02-02` (本期课程)
- `ISSUE-04-01` (排课与排他锁)
- `ISSUE-04-02` (完整课表聚合)
> **特征:** 教师与教室准备完毕后,沿开班排课主干顺畅推进。
### 泳道 C:学员生活与报到线 (依赖名单与宿舍)
- `ISSUE-03-01` (名单与档案)
- `ISSUE-03-02` (Excel批量导入)
- `ISSUE-05-01` (住宿智能预分配)
- `ISSUE-05-02` (调房与走读)
- `ISSUE-06-02` (门禁异步下发重试)
- `ISSUE-05-03` (学员报到确认)
> **特征:** 围绕 `enrollment_id` 展开,不依赖具体课次,主要管理学员个人生活保障。
### 泳道 D:成效考核与外部数据接入线
- `ISSUE-08-01` (成绩科目与权重)
- `ISSUE-08-02` (成绩录入与台账矩阵大表)
- `ISSUE-09-01` (外部自主学习记录接入)
> **特征:** 在学员名单和本期课程确定后即可独立推进,与排课考勤互不干扰。
+149
View File
@@ -0,0 +1,149 @@
# RailwaySystem Phase 3:开发批次计划 (Wave Plan)
> **规划目标:** 按照依赖关系与业务闭环推进节奏,将 26 个 P0 Issue 分解为 8 个开发批次(Wave 0 ~ Wave 7),实现敏捷迭代、敏捷交付、敏捷验收。
---
## 1. Wave 推进总览表
| 批次 | 阶段主题 | 核心 Issue 清单 | 预估人日 | 前置准入条件 | 核心产出物 |
|:---|:---|:---|:---|:---|:---|
| **Wave 0** | **工程基线与外部准备** | `ISSUE-11-01`, `ISSUE-11-02`, `ISSUE-06-01` | 2.5 人日 | Phase 3 方案审查通过 | 权限常量基线、字典分类方案、Adapter SPI 及 Mock 桩 |
| **Wave 1** | **基础主数据与计划立项** | `ISSUE-01-01`, `ISSUE-01-02`, `ISSUE-01-03`, `ISSUE-01-04`, `ISSUE-01-05`, `ISSUE-02-01` | 6.0 人日 | Wave 0 权限与工程准备就绪 | 教师/学员/课程/教室/场地/宿舍基础档案、培训计划立项与生命周期 |
| **Wave 2** | **计划配课、参训与排课** | `ISSUE-02-02`, `ISSUE-03-01`, `ISSUE-03-02`, `ISSUE-04-01`, `ISSUE-04-02` | 7.0 人日 | Wave 1 主数据与计划完成 | 本期课程引入、参训名单(含Excel导入)、排课并发四维冲突锁、完整课程表 |
| **Wave 3** | **住宿分配、门禁与报到** | `ISSUE-05-01`, `ISSUE-05-02`, `ISSUE-06-02`, `ISSUE-05-03` | 5.5 人日 | Wave 2 参训名单与 Wave 0 SPI 完成 | 批量住宿智能分配防超卖、调房走读、门禁事务外下发与重试、学员报到时限控制 |
| **Wave 4** | **课次打卡考勤与修正** | `ISSUE-07-01`, `ISSUE-07-02` | 3.0 人日 | Wave 2 排课与 Wave 3 报到完成 | 班牌刷脸签到/签退、INV-01 跨班防护、课次考勤花名册、人工修正留痕 |
| **Wave 5** | **成绩科目、台账与学习记录** | `ISSUE-08-01`, `ISSUE-08-02`, `ISSUE-09-01` | 5.0 人日 | Wave 2 配课与参训名单完成 | 成绩项目权重 100% 校验(INV-04)、成绩录入上限(INV-05)与跨班(INV-02)校验、全班成绩台账大表、外部学习记录幂等三态接入(INV-03) |
| **Wave 6** | **综合工作台与成效统计** | `ISSUE-10-01`, `ISSUE-10-02` | 3.0 人日 | Wave 2~Wave 5 业务数据链路通畅 | 培训计划一站式驾驶舱 (PlanWorkbenchVO)、培训成效统计概览 (PlanStatisticsVO) |
| **Wave 7** | **P0 端到端联调与闭环验收** | `ISSUE-12-01` | 2.5 人日 | Wave 0 ~ Wave 6 全部 Issue 完成 | 17 个 P0 核心用例自动化串联测试、业务全生命周期闭环准入测试报告 |
---
## 2. 各 Wave 详尽实施方案
### Wave 0:工程基线与外部准备 (Wave 0 - Baseline & Adapters)
- **目标:** 统一全系统的 RBAC 权限字符定义,梳理业务字典与系统配置归属,建立外部系统 Mock 桩,为后续各业务模块扫清底层支撑障碍。
- **包含 Issue:**
- `ISSUE-11-01`: RuoYi RBAC 权限体系与按钮授权集成 (Gemini Flash)
- `ISSUE-11-02`: 培训业务数据字典与参数配置初始化方案 (Gemini Flash)
- `ISSUE-06-01`: 外部设备适配器 SPI 体系与 Mock 桩实现 (Gemini Flash)
- **可并行 Issue:** 本 Wave 的 3 个 Issue 彼此完全独立,**可 100% 同步并行开发**。
- **进入条件:** Phase 3 文档技术审查完成。
- **退出准则:**
- 权限字符统一收敛至 `train:*` 前缀;
- 字典分类方案清晰无歧义,静态字典与业务数据彻底隔离;
- 3 大外部 SPI 接口及其 Mock 实现类完成并注入 Spring 上下文。
---
### Wave 1:基础主数据与计划立项 (Wave 1 - Master Data & Plan Creation)
- **目标:** 完成教师、学员(含脱敏与加密)、课程库、空间场地、宿舍基础房间维护,并打通培训计划的基本立项与状态生命周期。
- **包含 Issue:**
- `ISSUE-01-01`: 师资主数据管理能力 (Gemini Flash)
- `ISSUE-01-02`: 学员主档建档与安全脱敏管理 (Codex)
- `ISSUE-01-03`: 课程库基础资料管理 (Gemini Flash)
- `ISSUE-01-04`: 教学空间资源维护(教室与实训场地) (Gemini Flash)
- `ISSUE-01-05`: 宿舍房间与楼宇基础数据维护 (Gemini Flash)
- `ISSUE-02-01`: 培训计划全生命周期创建与管理 (Codex)
- **可并行 Issue:** `ISSUE-01-01` ~ `ISSUE-01-05` 5 个主数据任务可完全并行;`ISSUE-02-01` 可同步启动。
- **进入条件:** Wave 0 权限与字典基准确定。
- **退出准则:**
- 各主数据表单能正常录入与检索;
- 学员身份证号落库已使用安全散列查重且密文存储,查询列表脱敏,日志无明文;
- 培训计划能成功创建、编辑和执行结训状态流转。
---
### Wave 2:计划配课、参训与排课 (Wave 2 - Plan Configuration & Scheduling)
- **目标:** 围绕培训计划开展本期选课、学员进班(支持单增与 Excel 批量导入)、课次排课及稳定主资源行级排他锁冲突检测,生成完整课程表。
- **包含 Issue:**
- `ISSUE-02-02`: 本期培训课程引入与配置 (Gemini Flash)
- `ISSUE-03-01`: 本期参训名单维护与单条录入 (Codex)
- `ISSUE-03-02`: 参训人员 Excel 批量导入与防重校验 (Codex)
- `ISSUE-04-01`: 课次编排与多维度排课并发冲突检测 (Codex)
- `ISSUE-04-02`: 培训计划完整课程表聚合展示 (Gemini Flash)
- **可并行 Issue:**
- `ISSUE-02-02` (配课) 与 `ISSUE-03-01` (名单) 可并行推进;
- `ISSUE-03-02` 在 `ISSUE-03-01` 完成后推进;
- `ISSUE-04-01` 在 `ISSUE-02-02` 完成后推进;`ISSUE-04-02` 紧随其后。
- **进入条件:** Wave 1 基础主数据与培训计划立项完成。
- **退出准则:**
- 课程成功引入计划生成 `plan_course_id`;
- 学员成功入班生成 `enrollment_id`;
- Excel 批量导入支持容错并反馈错误行;
- 排课并发压测下杜绝教师/教室/场地/班级重叠穿透,完整课表 VO 正确呈现。
---
### Wave 3:住宿分配、门禁与报到 (Wave 3 - Accommodation, Door Auth & Report)
- **目标:** 解决学员现场报到与在校住宿生活保障,落实宿舍并发预分配防超卖、单人调房、走读退宿、门禁事务外解耦同步与失败重试,以及报到时限告警控制。
- **包含 Issue:**
- `ISSUE-05-01`: 批量住宿智能预分配与防超卖控制 (Codex)
- `ISSUE-05-02`: 住宿人工调房、走读设置与住宿安排查看 (Codex)
- `ISSUE-06-02`: 门禁授权事务后解耦下发与失败手工重试 (Codex)
- `ISSUE-05-03`: 学员报到确认与生命周期时限控制 (Codex)
- **可并行 Issue:**
- `ISSUE-05-01` (住宿分配) 与 `ISSUE-05-03` (报到确认) 均依赖名单,两者可完全并行开发;
- `ISSUE-05-02` 与 `ISSUE-06-02` 在 `ISSUE-05-01` 完成后开展。
- **进入条件:** Wave 2 参训名单已就绪、Wave 0 门禁 Mock 桩就绪。
- **退出准则:**
- 宿舍分配严格性别隔离,`SELECT FOR UPDATE` 彻底杜绝超卖;
- 门禁下发失败不回滚住宿事务,支持界面一键重试;
- 学员报到状态顺利流转,逾期报到准确提示并支持管理员强制放行。
---
### Wave 4:课次打卡考勤与修正 (Wave 4 - Attendance & Rectification)
- **目标:** 支撑教学过程中按课次组织的出勤打卡,落实 INV-01 跨班一致性硬校验,提供考勤名单实时查看与人工修正留痕。
- **包含 Issue:**
- `ISSUE-07-01`: 课次班牌打卡签到签退与跨班不变量防护 (Codex)
- `ISSUE-07-02`: 课次考勤名单查看与人工考勤状态修正 (Gemini Flash)
- **可并行 Issue:** `ISSUE-07-02` 依赖 `ISSUE-07-01`,为串行推进。
- **进入条件:** Wave 2 课次排课已完成、Wave 3 学员已完成报到。
- **退出准则:**
- 班牌签到/签退成功落库;
- **INV-01 跨班签到拦截测试 100% 阻断**;
- 人工修改考勤结论必须强制标记 `manual_flag = 1` 留痕。
---
### Wave 5:成绩科目、台账与学习记录 (Wave 5 - Assessment & Ingestion)
- **目标:** 完成本期考核科目与权重配置,支持成绩单条/批量录入,落实 INV-02、INV-04、INV-05,输出动态列成绩台账大表,并提供外部实训自主学习流水的三态幂等接入 (INV-03)。
- **包含 Issue:**
- `ISSUE-08-01`: 本期成绩考核项与权重配置 (Codex)
- `ISSUE-08-02`: 学员成绩录入、上限校验与全班成绩台账大表 (Codex)
- `ISSUE-09-01`: 外部实训系统自主学习流水幂等接入 (Codex)
- **可并行 Issue:**
- `ISSUE-08-01` (科目配置) 与 `ISSUE-09-01` (学习记录) 可并行开发;
- `ISSUE-08-02` 在 `ISSUE-08-01` 完成后推进。
- **进入条件:** Wave 2 课程配置与参训名单已就绪。
- **退出准则:**
- 成绩项目总权重严格校验 100% (INV-04);
- 打分录入严格限制在 `[0, fullScore]` 内 (INV-05) 且严禁跨班打分 (INV-02);
- 全班台账大表准确输出加权总分 `weightedTotalScore`,无及格线硬阻断;
- 外部学习记录实现 `accepted / duplicate / invalid` 三态幂等响应与跨班阻断 (INV-03)。
---
### Wave 6:综合工作台与成效统计 (Wave 6 - Workbench & Statistics)
- **目标:** 将前面各业务阶段汇聚沉淀的数据进行跨表只读聚合,输出培训计划综合工作台驾驶舱与全景成效统计概览。
- **包含 Issue:**
- `ISSUE-10-01`: 培训计划综合工作台聚合服务 (Gemini Flash)
- `ISSUE-10-02`: 培训计划综合成效统计概览 (Codex)
- **可并行 Issue:** `ISSUE-10-01` 与 `ISSUE-10-02` **可完全并行开发**。
- **进入条件:** 报到、考勤、成绩与学时业务链路皆已走通。
- **退出准则:**
- 工作台展示开班全景、今日课表与关键待办,响应时间 < 300ms;
- 统计概览计算报到率、出勤率、加权平均分与学时,安全防护除以零异常。
---
### Wave 7:P0 端到端联调与闭环验收 (Wave 7 - End-to-End Acceptance)
- **目标:** 串联 17 个 P0 核心用例(UC-01 ~ UC-29),构建端到端自动化测试流水线,作为 Phase 3 研发交付的最终质量关卡。
- **包含 Issue:**
- `ISSUE-12-01`: 智慧培训全生命周期主链路端到端集成测试与验收 (Codex)
- **进入条件:** Wave 0 ~ Wave 6 全部 25 个 Issue 开发完成并通过独立单测。
- **退出准则:**
- 自动化端到端测试用例 100% 绿灯通过;
- 5 大跨表硬不变量在链路测试中均通过破坏性用例校验;
- 生成最终《P0 业务主链路准入验收报告》。
+110
View File
@@ -0,0 +1,110 @@
# RailwaySystem Phase 3:研发分工与代码生成器策略 (Agent Assignment & Generator Strategy)
> **制定原则:** 最大化研发效能,合理配置 AI 算力与工程资源。
> **算力调度铁律:** **简单基础工作交由 Gemini Flash 高效完成,核心高难度/高并发/高风险架构工作保留给 Codex 集中攻坚,杜绝算力浪费。**
---
## 1. 代码生成器 (Generator V2) 实施策略
系统代码生成器已于 Phase 2 完成 KingbaseES 适配与基线冻结 (`generator-v2-ready-20260914`)。在 Phase 3 Issue 实施中,各 Issue 严格执行以下三级 Generator 策略:
```text
YES (4个 Issue) : 基础主数据单表,完全通过 Generator 快速生成 Entity、基础 Mapper XML 与基础 Service 骨架。
PARTIAL (11个 Issue) : 核心业务实体或敏感表,仅生成 Domain Entity 与基础 Mapper 骨架,Controller、DTO/VO、Application Service 业务编排必须严格按 API Contract 手写。
NO (11个 Issue) : 跨表聚合服务、并发锁算法、SPI防腐接口、系统权限字典方案、纯业务状态流转或端到端测试,严禁使用生成器代码。
```
### 1.1 Generator 策略明细矩阵
| Issue ID | Issue 名称 | Generator 策略 | 策略执行细则与约束 |
|:---|:---|:---:|:---|
| `ISSUE-01-01` | 师资主数据管理能力 | **YES** | 生成 `TrainTeacher` 实体、Mapper 与基础查询;在 Service 层补充手机号全局查重校验。 |
| `ISSUE-01-02` | 学员主档建档与安全脱敏管理 | **PARTIAL** | 生成 `TrainStudent` 实体骨架;**严禁生成明文落库代码**,手写安全散列查重与可逆密文存储逻辑。 |
| `ISSUE-01-03` | 课程库基础资料管理 | **YES** | 生成 `TrainCourse` 实体、Mapper 骨架;Service 层补充课程编码唯一性校验。 |
| `ISSUE-01-04` | 教学空间资源维护(教室与实训场地) | **YES** | 生成 `TrainClassroom` 与 `TrainTrainingSite` 单表骨架;补充容量大于零业务校验。 |
| `ISSUE-01-05` | 宿舍房间与楼宇基础数据维护 | **YES** | 生成 `TrainDormitoryInfo` 基础骨架;手写楼栋/楼层级联下拉树查询逻辑。 |
| `ISSUE-02-01` | 培训计划全生命周期创建与管理 | **PARTIAL** | 生成 `TrainClassPlan` 实体;**严禁使用生成器状态机**,手写状态流转校验与结训学员批量级联更新。 |
| `ISSUE-02-02` | 本期培训课程引入与配置 | **PARTIAL** | 生成 `TrainPlanCourse` 实体骨架;手写从课程库批量选课、防重与生成 `plan_course_id` 逻辑。 |
| `ISSUE-03-01` | 本期参训名单维护与单条录入 | **PARTIAL** | 生成 `TrainEnrollment` 实体;手写招生人数上限并发校验与 `enrollment_id` 核心上下文绑定。 |
| `ISSUE-03-02` | 参训人员 Excel 批量导入与防重校验 | **NO** | 纯业务批量事务与 EasyExcel 监听器,涉及多级错误行收集,全量手写。 |
| `ISSUE-04-01` | 课次编排与多维度排课并发冲突检测 | **PARTIAL** | 生成 `TrainLesson` 实体;**排课稳定主资源行锁 (FOR UPDATE) 与区间冲突算法必须纯手写**。 |
| `ISSUE-04-02` | 培训计划完整课程表聚合展示 | **NO** | 纯聚合只读服务,手写嵌套 `PlanTimetableVO` 结果集映射与周/日维度分组逻辑。 |
| `ISSUE-05-01` | 批量住宿智能预分配与防超卖控制 | **PARTIAL** | 生成 `TrainAccommodation` 实体;**严禁生成分房算法**,手写性别隔离、宿舍主表 `SELECT FOR UPDATE` 防超卖。 |
| `ISSUE-05-02` | 住宿人工调房、走读设置与住宿安排查看 | **NO** | 纯业务调房行锁、走读床位释放与状态流转,直接复用已有实体,严禁依赖生成器。 |
| `ISSUE-05-03` | 学员报到确认与生命周期时限控制 | **PARTIAL** | 生成 `TrainReportRecord` 实体;手写 `report_deadline` 策略核对、告警与强制放行逻辑。 |
| `ISSUE-06-01` | 外部设备适配器 SPI 体系与 Mock 桩实现 | **NO** | 纯架构防腐层设计,定义 3 大 SPI 接口及 Mock 实现类,手写。 |
| `ISSUE-06-02` | 门禁授权事务后解耦下发与失败手工重试 | **NO** | 涉及 Spring `afterCommit` 事务同步器注册与事务外解耦调用,手写。 |
| `ISSUE-07-01` | 课次班牌打卡签到签退与跨班不变量防护 | **PARTIAL** | 生成 `TrainAttendanceRecord` 实体;**硬不变量 INV-01 校验与幂等更新必须纯手写**。 |
| `ISSUE-07-02` | 课次考勤名单查看与人工考勤状态修正 | **PARTIAL** | 手写应到/实到花名册聚合左联 SQL、人工修正强制标记 `manual_flag = 1` 留痕。 |
| `ISSUE-08-01` | 本期成绩考核项与权重配置 | **PARTIAL** | 生成 `TrainScoreItem` 实体;手写总权重 100% 校验与硬不变量 INV-04 课程归属核验。 |
| `ISSUE-08-02` | 学员成绩录入、上限校验与全班成绩台账大表 | **NO** | 手写满分上限校验 (INV-05)、跨班阻断 (INV-02)、动态列行转列与加权总分计算。 |
| `ISSUE-09-01` | 外部实训系统自主学习流水幂等接入 | **PARTIAL** | 生成 `TrainStudyDuration` 实体;手写防重事件 ID 唯一性校验、三态响应与 INV-03 核查。 |
| `ISSUE-10-01` | 培训计划综合工作台聚合服务 | **NO** | 纯复杂 VO 聚合,跨 4 张表组装综合驾驶舱,手写。 |
| `ISSUE-10-02` | 培训计划综合成效统计概览 | **NO** | 纯跨模块业务指标聚合计算,含除零保护与四舍五入,手写。 |
| `ISSUE-11-01` | RuoYi RBAC 权限体系与按钮授权集成 | **NO** | 权限常量定义、`sys_menu` SQL 脚本方案与按钮权限梳理,纯系统基线工程。 |
| `ISSUE-11-02` | 培训业务数据字典与参数配置初始化方案 | **NO** | 16 组字典分类架构方案与静态字典初始化,纯数据架构分析。 |
| `ISSUE-12-01` | 智慧培训全生命周期主链路端到端集成测试与验收 | **NO** | 全链路自动化业务集成测试用例与验收套件,手写。 |
---
## 2. 研发智能体分配 (Gemini Flash vs Codex)
### 2.1 分配原则与算力调优
- **Gemini Flash (共 11 个 Issue):**
- 适用场景:单表结构化 CRUD、VO/DTO 定义、权限常量梳理、数据字典方案、简单 Vue 列表页、单元测试数据构建。
- 核心优势:响应极其敏捷、大上下文处理结构清晰、遵循规范精准。
- **Codex (共 15 个 Issue):**
- 适用场景:稳定主资源行排他锁 (`FOR UPDATE`)、防超卖算法、三类资源区间冲突查询、跨表强一致性硬不变量校验、事务提交后解耦、敏感数据安全脱敏、矩阵行转列与端到端验收。
- 核心优势:深度逻辑推理严密、并发锁死锁识别能力强、复杂领域模型抽象扎实。
### 2.2 详细分工清单
```mermaid
pie title Phase 3 Issue 研发分工比例 (总数 26)
"Gemini Flash (基础CRUD/聚合VO/基线)" : 11
"Codex (高并发锁/硬不变量/事务编排/验收)" : 15
```
| Issue ID | 推荐 Agent | 核心负责模块 / 任务边界 | 推荐理由与关键关注点 |
|:---|:---:|:---|:---|
| `ISSUE-11-01` | **Gemini Flash** | RBAC 权限体系与按钮授权 | 权限常量提取与标准 RuoYi 按钮匹配,规则明确标准。 |
| `ISSUE-11-02` | **Gemini Flash** | 字典分类方案梳理 | 评估 `007_dicts.sql` 语义,输出三类字典归属报告。 |
| `ISSUE-06-01` | **Gemini Flash** | 外部 Adapter SPI 与 Mock 桩 | 标准 Java Interface 与 Mock 类编写,快速高效。 |
| `ISSUE-01-01` | **Gemini Flash** | 教师主数据 CRUD | 标准单表管理,结构简单,适合快速落地。 |
| `ISSUE-01-02` | **Codex** | 学员主档脱敏与安全加密 | 涉及安全散列查重与可逆密文存储,需要严密的安全逻辑。 |
| `ISSUE-01-03` | **Gemini Flash** | 课程库基础资料管理 | 基础主档 CRUD,重点在于去除冗余版本字段。 |
| `ISSUE-01-04` | **Gemini Flash** | 教学空间资源维护 | 教室与场地标准数据维护,校验逻辑清晰。 |
| `ISSUE-01-05` | **Gemini Flash** | 宿舍房间与级联选项 | 楼栋/楼层级联树生成与基础房间录入。 |
| `ISSUE-02-01` | **Codex** | 培训计划生命周期管理 | 核心业务主干,涉及严格的时间校验与结训学员级联更新事务。 |
| `ISSUE-02-02` | **Gemini Flash** | 本期课程引入与配置 | 课程库与计划的关联,排重校验逻辑明确。 |
| `ISSUE-03-01` | **Codex** | 参训名单维护与单条录入 | 涉及 `enrollment_id` 核心领域标识生成与招生上限并发校验。 |
| `ISSUE-03-02` | **Codex** | Excel 批量导入与防重 | 大批量数据容错、身份证号校验、分批事务处理。 |
| `ISSUE-04-01` | **Codex** | 课次排课与稳定资源锁检测 | **高技术难度:** 必须使用教师/场地稳定行锁杜绝时段重叠穿透。 |
| `ISSUE-04-02` | **Gemini Flash** | 完整课表聚合展示 | 纯聚合查询,按日期嵌套展示课表,属于结构化组装。 |
| `ISSUE-05-01` | **Codex** | 批量住宿智能分配防超卖 | **高技术难度:** 锁定宿舍主表行 `SELECT FOR UPDATE` 彻底防并发超卖。 |
| `ISSUE-05-02` | **Codex** | 调房走读管理与锁控制 | 宿舍主表行锁核验容量与释放床位短事务,升序加锁防死锁。 |
| `ISSUE-05-03` | **Codex** | 学员报到与生命周期时限策略 | 涉及 `report_deadline` 弹性策略分支与管理员特批覆盖。 |
| `ISSUE-06-02` | **Codex** | 门禁 afterCommit 解耦下发 | **高架构要求:** 必须使用事务同步器解耦,外部失败严禁回滚。 |
| `ISSUE-07-01` | **Codex** | 课次打卡与 INV-01 防护 | **核心业务防线:** 强制校验学员班级与课次班级一致性。 |
| `ISSUE-07-02` | **Gemini Flash** | 考勤名单聚合与人工修正 | 课次应到实到名单左联与 `manual_flag = 1` 留痕。 |
| `ISSUE-08-01` | **Codex** | 成绩项目配置与 INV-04 | 权重严格总和 100% 校验与课程归属性校验。 |
| `ISSUE-08-02` | **Codex** | 成绩录入台账 INV-02/05 | **核心业务防线:** 满分上限校验、跨班打分阻断、加权总分计算。 |
| `ISSUE-09-01` | **Codex** | 外部学习记录接入与 INV-03 | **高可靠性要求:** 唯一事件 ID 查重三态幂等与跨班课程校验。 |
| `ISSUE-10-01` | **Gemini Flash** | 培训计划综合工作台 VO | 跨表只读数据组装,快速呈现大盘卡片与日程。 |
| `ISSUE-10-02` | **Codex** | 计划成效综合统计概览 | 报到率、出勤率、加权平均分多指标精准聚合与除零保护。 |
| `ISSUE-12-01` | **Codex** | P0 主链路端到端闭环验收 | **终极交付质量把关:** 串联 17 个 UC 完整业务流程验证。 |
---
## 3. 研发协同与代码审查 (Review) 规范
1. **并行开发阶段:**
- Gemini Flash 先行完成 Wave 0 与 Wave 1 中主数据部分 (`ISSUE-01-01`, `01-03`, `01-04`, `01-05`),为后续业务提供数据实体;
- Codex 同步启动 `ISSUE-01-02` (安全脱敏) 与 `ISSUE-02-01` (计划生命周期)。
2. **核心业务攻坚阶段 (Wave 2 ~ Wave 5):**
- Codex 集中精力攻克排课行锁 (`04-01`)、住宿防超卖 (`05-01`)、门禁解耦 (`06-02`) 以及 5 大硬不变量(INV-01 ~ INV-05);
- Gemini Flash 配合完成课表展示 (`04-02`)、配课 (`02-02`)、考勤名单 (`07-02`) 等 VO 聚合及前端视图。
3. **代码合并准入卡点:**
- 任何涉及并发锁与硬不变量的 Codex 产出代码,必须包含对应的多线程并发单元测试或破坏性断言用例;
- 任何 Gemini Flash 产出的代码,严禁私自修改数据库 DDL 与生成器全局模板。
+114
View File
@@ -0,0 +1,114 @@
# RailwaySystem Phase 3:端到端验收矩阵与追踪表 (Acceptance Matrix)
> **设计目标:** 建立从 PRD 业务用例 (UC) → API Contract → Issue → 后端组件 → 前端组件 → 权限字符 → 测试用例 → 验收准则的全链路双向追踪体系,确保 53 个 P0 API 100% 落实且每个硬业务规则有唯一责任人。
---
## 1. 5 大跨表硬业务不变量与 1 项报到生命周期策略责任归属矩阵
系统确立的 5 大跨表拓扑一致性“硬不变量”与 1 项生命周期时限策略,责任全部落实到具体的唯一主责 Issue:
| 编号 | 规则公式 / 核心定义 | 唯一主责 Issue | 主责 Application Service | 拦截错误码 | 破坏性测试用例 |
|:---:|:---|:---:|:---:|:---:|:---|
| **INV-01** | $\text{attendance.enrollment.plan\_id} == \text{lesson.planCourse.plan\_id}$<br/>*(严禁跨班打卡串签)* | `ISSUE-07-01` | `AttendanceAppService` | `ATTENDANCE_PLAN_MISMATCH (40080)` | `AttendanceInvariantTest.testCrossPlanCheckinBlocked` |
| **INV-02** | $\text{studentScore.enrollment.plan\_id} == \text{scoreItem.plan\_id}$<br/>*(严禁跨班打分录入)* | `ISSUE-08-02` | `ScoreAppService` | `SCORE_PLAN_MISMATCH (40093)` | `ScoreInvariantTest.testCrossPlanScoreEntryBlocked` |
| **INV-03** | $\text{study.enrollment.plan\_id} == \text{planCourse.plan\_id}$<br/>*(外部学习记录本期课程与班级一致)* | `ISSUE-09-01` | `LearningAppService` | `STUDY_EVENT_INVALID (40100)` | `LearningInvariantTest.testCrossPlanCourseStudyBlocked` |
| **INV-04** | $\text{scoreItem.planCourse.plan\_id} == \text{scoreItem.plan\_id}$<br/>*(考核科目挂接课程必须属于本期计划)* | `ISSUE-08-01` | `ScoreAppService` | `SCORE_ITEM_COURSE_MISMATCH (40091)` | `ScoreConfigInvariantTest.testExternalCourseBindingBlocked` |
| **INV-05** | $0 \le \text{studentScore.score} \le \text{scoreItem.full\_score}$<br/>*(录入分值非负且不得超过满分上限)* | `ISSUE-08-02` | `ScoreAppService` | `SCORE_EXCEEDS_FULL_SCORE (40092)` | `ScoreEntryValidationTest.testExceedFullScoreBlocked` |
| **POL-01** | $\text{report\_time} \le \text{plan.report\_deadline}$<br/>*(报到生命周期时限策略,支持告警与强制放行)* | `ISSUE-05-03` | `ReportAppService` | `REPORT_DEADLINE_WARNING (40051)` | `ReportLifecyclePolicyTest.testOverdueWarningAndOverride` |
---
## 2. 53 个 P0 API 全生命周期全链路追踪矩阵 (PRD → API → Issue → Test)
> **覆盖率确认:** 涵盖 API Contract 正文全部 53 个 P0 API(包含 Module A 20 个、Module B 17 个、Module C 9 个、Module D 7 个),覆盖率达成 53/53 = 100%。严格杜绝任何未经评审的私自增改。
### 2.1 Module A:人员与基础资源 (16 个 P0 核心 API + 4 个宿舍基础 API)
| 序号 | 来源 UC | HTTP Method & Path | 对应 Issue ID | 后端实现组件 | 前端组件 / 页面 | 权限字符 | 自动化测试类 | 验收判定条件 (Acceptance Criteria) |
|:---:|:---|:---|:---:|:---|:---|:---|:---|:---|
| 1 | - | `GET /train/teacher/list` | `ISSUE-01-01` | `TrainTeacherController.list` | `views/train/teacher/index.vue` | `train:teacher:list` | `TeacherApiTest.testList` | 分页返回教师列表,包含脱敏与职称 |
| 2 | - | `GET /train/teacher/{tId}` | `ISSUE-01-01` | `TrainTeacherController.getInfo` | `views/train/teacher/index.vue` | `train:teacher:query` | `TeacherApiTest.testGet` | 正确回显指定教师详细信息 |
| 3 | - | `POST /train/teacher` | `ISSUE-01-01` | `TrainTeacherController.add` | 教师新增弹窗组件 | `train:teacher:add` | `TeacherApiTest.testAdd` | 手机号重复抛出 40001,正常新增落库 |
| 4 | - | `PUT /train/teacher/{tId}` | `ISSUE-01-01` | `TrainTeacherController.edit` | 教师编辑弹窗组件 | `train:teacher:edit` | `TeacherApiTest.testEdit` | 正确更新教师职务与联系电话 |
| 5 | - | `GET /train/student/list` | `ISSUE-01-02` | `TrainStudentController.list` | `views/train/student/index.vue` | `train:student:list` | `StudentApiTest.testList` | 分页返回学员主档,身份证掩码脱敏展示 |
| 6 | - | `GET /train/student/{sId}` | `ISSUE-01-02` | `TrainStudentController.getInfo` | `views/train/student/index.vue` | `train:student:query` | `StudentApiTest.testGet` | 返回学员完整信息,身份证默认掩码 |
| 7 | - | `POST /train/student` | `ISSUE-01-02` | `TrainStudentController.add` | 学员录入弹窗组件 | `train:student:add` | `StudentApiTest.testAdd` | 安全散列查重防重,可逆密文存入数据库 |
| 8 | - | `GET /train/course/list` | `ISSUE-01-03` | `TrainCourseController.list` | `views/train/course/index.vue` | `train:course:list` | `CourseApiTest.testList` | 分页展示公共课程库,不含冗余版本字段 |
| 9 | - | `GET /train/course/{cId}` | `ISSUE-01-03` | `TrainCourseController.getInfo` | `views/train/course/index.vue` | `train:course:query` | `CourseApiTest.testGet` | 正确获取课程学时与教学目标 |
| 10 | - | `POST /train/course` | `ISSUE-01-03` | `TrainCourseController.add` | 课程录入弹窗组件 | `train:course:add` | `CourseApiTest.testAdd` | 课程编码重复拦截抛 40003,新增成功 |
| 11 | - | `GET /train/classroom/list` | `ISSUE-01-04` | `TrainClassroomController.list` | `views/train/classroom/index.vue` | `train:classroom:list` | `ClassroomApiTest.testList` | 分页返回教室及核定容纳人数 |
| 12 | - | `GET /train/classroom/{classRoomId}` | `ISSUE-01-04` | `TrainClassroomController.getInfo` | `views/train/classroom/index.vue` | `train:classroom:list` | `ClassroomApiTest.testGet` | 回显教室详情及绑定的班牌信息 |
| 13 | - | `POST /train/classroom` | `ISSUE-01-04` | `TrainClassroomController.add` | 教室录入弹窗组件 | `train:classroom:edit` | `ClassroomApiTest.testAdd` | 容量非负校验,正常新增教室 |
| 14 | - | `GET /train/training-site/list` | `ISSUE-01-04` | `TrainTrainingSiteController.list` | `views/train/site/index.vue` | `train:site:list` | `SiteApiTest.testList` | 分页返回实训场地(VR/3D演练区) |
| 15 | - | `GET /train/training-site/{siteId}` | `ISSUE-01-04` | `TrainTrainingSiteController.getInfo` | `views/train/site/index.vue` | `train:site:list` | `SiteApiTest.testGet` | 回显实训场地设备类型与容纳人数 |
| 16 | - | `POST /train/training-site` | `ISSUE-01-04` | `TrainTrainingSiteController.add` | 实训场地录入弹窗组件 | `train:site:edit` | `SiteApiTest.testAdd` | 场地名称查重校验,正常新增场地 |
| *17*| - | `GET /train/dormitory/list` | `ISSUE-01-05` | `TrainDormitoryController.list` | `views/train/dormitory/index.vue`| `train:dormitory:list`| `DormitoryApiTest.testList` | 分页返回房间、核定床位、已住与剩余 |
| *18*| - | `GET /train/dormitory/{dormId}` | `ISSUE-01-05` | `TrainDormitoryController.getInfo` | `views/train/dormitory/index.vue`| `train:dormitory:list`| `DormitoryApiTest.testGet` | 回显房间当前入住学员列表 |
| *19*| - | `POST /train/dormitory` | `ISSUE-01-05` | `TrainDormitoryController.add` | 宿舍录入弹窗组件 | `train:dormitory:edit`| `DormitoryApiTest.testAdd` | 同楼栋房号重复拦截抛 40006,新增成功 |
| *20*| - | `GET /train/dormitory/options` | `ISSUE-01-05` | `TrainDormitoryController.options` | 楼栋楼层级联选择器组件 | `train:dormitory:list`| `DormitoryApiTest.testOptions`| 结构化返回楼栋-楼层-房间级联树 |
*(注:*项为 Module A 中宿舍基础维护接口,经 CG-01 裁决正式确认纳入一期 P0 范围)*
---
### 2.2 Module B:开班计划与排课 (17 个 P0 核心 API)
| 序号 | 来源 UC | HTTP Method & Path | 对应 Issue ID | 后端实现组件 | 前端组件 / 页面 | 权限字符 | 自动化测试类 | 验收判定条件 (Acceptance Criteria) |
|:---:|:---|:---|:---:|:---|:---|:---|:---|:---|
| 17 | - | `GET /train/plans` | `ISSUE-02-01` | `TrainPlanController.list` | `views/train/plan/index.vue` | `train:plan:list` | `PlanLifecycleTest.testList` | 分页返回培训班列表与生命周期状态 |
| 18 | UC-01 | `POST /train/plans` | `ISSUE-02-01` | `PlanAppService.createPlan` | 创建培训计划表单弹窗 | `train:plan:add` | `PlanLifecycleTest.testCreate`| 时间起止校验、自动生成唯一计划编号 |
| 19 | - | `GET /train/plans/{planId}` | `ISSUE-02-01` | `TrainPlanController.getInfo` | 计划详情页基本信息卡片 | `train:plan:query` | `PlanLifecycleTest.testGet` | 回显计划负责人、招生上限与周期 |
| 20 | UC-02 | `PUT /train/plans/{planId}` | `ISSUE-02-01` | `PlanAppService.updatePlan` | 编辑培训计划表单弹窗 | `train:plan:edit` | `PlanLifecycleTest.testUpdate`| 已结训状态禁止修改抛 40012,正常更新 |
| 21 | UC-29 | `POST /train/plans/{planId}/complete` | `ISSUE-02-01` | `PlanAppService.completePlan` | 计划工作台“结训归档”按钮 | `train:plan:complete`| `PlanLifecycleTest.testComplete`| 状态置为已结束,学员状态置为已完成 |
| 22 | - | `GET /train/plans/{planId}/workbench`| `ISSUE-10-01` | `PlanAppService.getWorkbench` | `views/train/plan/workbench.vue` | `train:plan:query` | `PlanWorkbenchTest.testWorkbench`| 聚合返回班级工作台驾驶舱全部指标 |
| 23 | - | `GET /train/plans/{planId}/courses` | `ISSUE-02-02` | `TrainPlanCourseController.list` | 计划详情-本期课程配置 Tab | `train:plan:query` | `PlanCourseTest.testList` | 返回本期已选课程及生成的 planCourseId |
| 24 | UC-03 | `POST /train/plans/{planId}/courses` | `ISSUE-02-02` | `PlanAppService.configureCourses` | 课程库穿梭选择器弹窗 | `train:plan:edit` | `PlanCourseTest.testConfigure`| 重复选课抛 40020,成功引入本期课程 |
| 25 | UC-06 | `GET /train/plans/{planId}/enrollments`| `ISSUE-03-01` | `EnrollmentAppService.list` | 计划详情-参训人员名录表格 | `train:enrollment:list`| `EnrollmentCoreTest.testList` | 分页返回学员参训花名册与报到状态 |
| 26 | UC-04 | `POST /train/plans/{planId}/enrollments`| `ISSUE-03-01` | `EnrollmentAppService.addSingle` | 添加参训学员弹窗组件 | `train:enrollment:add` | `EnrollmentCoreTest.testAdd` | 招生超限抛 40031,生成 enrollmentId |
| 27 | UC-05 | `POST /train/plans/{planId}/enrollments/batch`| `ISSUE-03-02` | `EnrollmentAppService.importBatch` | Excel 名单导入上传模态框 | `train:enrollment:import`| `EnrollmentBatchTest.testImport`| 解析 500 行花名册,错误行明细回显 |
| 28 | - | `GET /train/enrollments/{enrollmentId}` | `ISSUE-03-01` | `EnrollmentAppService.getDetail` | 学员参训全景卡片组件 | `train:enrollment:query`| `EnrollmentCoreTest.testGet` | 聚合回显住宿、考勤、成绩与学时总览 |
| 29 | UC-07 | `POST /train/plans/{planId}/lessons` | `ISSUE-04-01` | `SchedulingAppService.createLesson` | 排课弹窗(教师/场地/时段) | `train:lesson:add` | `LessonSchedulingTest.testCreate`| 四维冲突检测拦截,行排他锁防穿透 |
| 30 | UC-08 | `PUT /train/lessons/{lessonId}` | `ISSUE-04-01` | `SchedulingAppService.updateLesson` | 修改课次弹窗组件 | `train:lesson:edit` | `LessonSchedulingTest.testUpdate`| 已有打卡考勤记录时严禁修改上课时间 |
| 31 | UC-10 | `POST /train/lessons/check-conflict` | `ISSUE-04-01` | `SchedulingAppService.checkConflict` | 排课表单时段失焦预检组件 | `train:lesson:query` | `LessonConflictTest.testCheck` | 实时返回冲突原因与冲突课次列表 |
| 32 | UC-11 | `GET /train/plans/{planId}/timetable` | `ISSUE-04-02` | `SchedulingAppService.getTimetable` | 计划详情-完整课程表日历视图 | `train:plan:query` | `PlanTimetableTest.testTimetable`| 按日历周/日嵌套聚合课表,零失真展示 |
| 33 | UC-15 | `POST /train/enrollments/{enrollmentId}/report`| `ISSUE-05-03` | `ReportAppService.confirmReport` | 学员报到确认操作按钮 / 弹窗 | `train:report:confirm` | `ReportLifecycleTest.testReport` | 逾期未特批提示 40051,状态置为已报到 |
---
### 2.3 Module C:住宿、门禁与考勤 (9 个 P0 核心 API)
| 序号 | 来源 UC | HTTP Method & Path | 对应 Issue ID | 后端实现组件 | 前端组件 / 页面 | 权限字符 | 自动化测试类 | 验收判定条件 (Acceptance Criteria) |
|:---:|:---|:---|:---:|:---|:---|:---|:---|:---|
| 34 | UC-12 | `POST /train/plans/{planId}/accommodations/preallocate`| `ISSUE-05-01` | `AccommodationAppService.preallocate` | 批量预分配宿舍策略弹窗 | `train:accommodation:assign` | `AccommodationLockTest.testPrealloc` | `SELECT FOR UPDATE` 排他锁防超卖,严格性别隔离 |
| 35 | UC-13 | `PUT /train/enrollments/{enrollmentId}/accommodation` | `ISSUE-05-02` | `AccommodationAppService.adjust` | 单人调房 Dialog 弹窗 | `train:accommodation:adjust` | `AccommodationAdjustTest.testAdjust` | 目标房间满员抛 40062,跨性别调房拦截 |
| 36 | - | `PUT /train/enrollments/{enrollmentId}/accommodation/day-student`| `ISSUE-05-02` | `AccommodationAppService.setDayStudent` | 设置走读操作按钮 | `train:accommodation:adjust` | `AccommodationAdjustTest.testDay` | 走读设置成功,立即释放已占用房间床位 |
| 37 | UC-14 | `GET /train/plans/{planId}/accommodations` | `ISSUE-05-02` | `AccommodationAppService.list` | 计划详情-住宿分配台账列表 | `train:accommodation:list` | `AccommodationListTest.testList` | 分页返回全班住宿床位与门禁下发状态 |
| 38 | UC-16 | `POST /train/accommodations/{accommodationId}/auth/sync`| `ISSUE-06-02` | `ExternalIntegrationAppService.syncDoor` | 住宿列表“重试门禁同步”按钮 | `train:dormauth:sync` | `DoorAuthAsyncTest.testRetrySync` | 事务外调用 Adapter,失败更新状态为 2 |
| 39 | UC-17 | `POST /train/attendance/sign-in` | `ISSUE-07-01` | `AttendanceAppService.recordSignIn` | 班牌刷脸模拟回调接口 | `train:attendance:checkin` | `AttendanceInvariantTest.testSignIn` | **强制落实 INV-01 拦截跨班串签**,幂等查重 |
| 40 | UC-18 | `POST /train/attendance/sign-out` | `ISSUE-07-01` | `AttendanceAppService.recordSignOut` | 班牌签退刷脸模拟回调接口 | `train:attendance:checkin` | `AttendanceSignInTest.testSignOut` | 课次签退落库,自动计算出勤考勤结论 |
| 41 | UC-19 | `PUT /train/attendance/{checkinId}` | `ISSUE-07-02` | `AttendanceAppService.adjustAttendance` | 考勤异常修改 Dialog 弹窗 | `train:attendance:edit` | `AttendanceAdjustTest.testAdjust` | 修改考勤结论,强制标记 `manual_flag = 1` |
| 42 | - | `GET /train/lessons/{lessonId}/attendance` | `ISSUE-07-02` | `AttendanceAppService.getLessonRoster` | 教师端-课次考勤名单抽屉组件 | `train:attendance:list` | `AttendanceRosterTest.testRoster` | 左联花名册,准确显示应到/实到与缺勤名单 |
---
### 2.4 Module D:成绩、学习与统计 (7 个 P0 核心 API)
| 序号 | 来源 UC | HTTP Method & Path | 对应 Issue ID | 后端实现组件 | 前端组件 / 页面 | 权限字符 | 自动化测试类 | 验收判定条件 (Acceptance Criteria) |
|:---:|:---|:---|:---:|:---|:---|:---|:---|:---|
| 43 | - | `GET /train/plans/{planId}/score-items` | `ISSUE-08-01` | `ScoreAppService.getScoreItems` | 计划详情-考核科目配置表格 | `train:score:query` | `ScoreConfigTest.testList` | 返回本期考核项目、满分值与百分比权重 |
| 44 | UC-20 | `POST /train/plans/{planId}/score-items` | `ISSUE-08-01` | `ScoreAppService.configureScoreItems` | 成绩项目配置与权重保存按钮 | `train:score:config` | `ScoreConfigInvariantTest.testConfig` | **落实 INV-04 课程归属**,权重总和必须 100% |
| 45 | UC-21 | `POST /train/scores/entry` | `ISSUE-08-02` | `ScoreAppService.recordScores` | 成绩台账矩阵单元格打分输入 | `train:score:edit` | `ScoreInvariantTest.testScoreEntry` | **落实 INV-02 跨班与 INV-05 满分上限拦截** |
| 46 | - | `GET /train/plans/{planId}/scores/matrix` | `ISSUE-08-02` | `ScoreAppService.getScoreMatrix` | 计划详情-全班成绩台账大表 | `train:score:query` | `ScoreMatrixTest.testMatrix` | 动态行转列呈现科目,准确计算加权综合分 |
| 47 | UC-23 | `GET /train/enrollments/{enrollmentId}/scores` | `ISSUE-08-02` | `ScoreAppService.getStudentScoreReport` | 学员个人成绩单卡片弹窗 | `train:score:query` | `ScoreReportTest.testStudentScores` | 呈现单人各科目得分、满分、权重与加权总分 |
| 48 | UC-24 | `POST /train/study-records/receive` | `ISSUE-09-01` | `LearningAppService.receiveRecord` | 外部系统实训学时推送端点 | 系统放行 / 签名验证 | `LearningInvariantTest.testReceive` | **落实 INV-03 课程计划一致**,防重事件 ID 幂等三态响应 |
| 49 | UC-26 | `GET /train/plans/{planId}/statistics/overview` | `ISSUE-10-02` | `StatisticsAppService.getOverview` | 计划详情-培训成效统计看板 | `train:statistics:query` | `PlanStatisticsTest.testOverview` | 聚合报到率、出勤率、加权平均分与总实训学时 |
---
## 3. 验收追踪闭环结论
- **PRD 用例追踪:** 17 个 P0 核心用例全部精确映射到对应的 API、Issue 与自动化测试类;
- **API 覆盖闭环:** 53 个 P0 API 无一遗漏全部被 Issue 认领(53/53,100% 覆盖率);
- **规则硬边界:** 5 项跨表硬不变量由 `ISSUE-07-01`、`ISSUE-08-01`、`ISSUE-08-02`、`ISSUE-09-01` 唯一主责承揽,1 项生命周期时限策略由 `ISSUE-05-03` 唯一承揽;
- **质量闸门保证:** 每一个 Issue 均配备了严格对应的自动化测试类和基于 Given/When/Then 的判定条件,彻底消除了模糊验收隐患。
+112
View File
@@ -0,0 +1,112 @@
# RailwaySystem Phase 3:P1 / P2 需求储备池 (Backlog Register)
> **最高原则:** 本轮研发聚焦于 53 个 P0 API 的最小闭环,**严禁将任何 P1 或 P2 需求混入当前研发排期与测试范围**。
> **文档定位:** 本文档作为系统后续演进阶段(Phase 4 / Phase 5)的正式需求储备池,确保设计资产连续,防止需求流失。
> **数据基线:** 严格对齐 API Contract V1.0.1 真实物理端点定义,P1 共 34 个,P2 共 2 个,合计 36 个非核心接口全部封存入库。
---
## 1. P1 / P2 规模与模块分布统计
API Contract V1.0.1 中定义的 89 个 API 优先级分布为:
- **P0 (核心闭环,本轮必做):** 53 个
- **P1 (辅助支撑,登记备查):** 34 个
- **P2 (待现场确认,远期建设):** 2 个
```mermaid
pie title API 全局物理端点分布 (总数 89)
"P0 核心闭环 (本轮开发)" : 53
"P1 辅助能力 (Backlog 储备)" : 34
"P2 远期待定 (现场确认)" : 2
```
| 业务模块 | 物理总数 | P0 数量 | P1 数量 | P2 数量 | P1/P2 规划定位 |
|:---|:---:|:---:|:---:|:---:|:---|
| **Module A 人员与基础资源** | 38 | 20 | 18 | 0 | 主数据全功能闭环、资源载体绑定、班牌硬件入库 |
| **Module B 开班计划与排课** | 24 | 17 | 6 | 1 | 计划反向删除/移除、教师个人课表、学员移动端门户 |
| **Module C 住宿/门禁/考勤** | 13 | 9 | 4 | 0 | 房态实时看板、门禁下发流水、考勤人工补录、考勤综合汇总 |
| **Module D 成绩/学习/统计/画像** | 14 | 7 | 6 | 1 | 成绩Excel导入、学习记录流水、学员训练详情、学员综合画像、成绩下钻分布 |
| **合计** | **89** | **53** | **34** | **2** | **P1 (34) + P2 (2) = 36 全部封存入库** |
---
## 2. 34 个 P1 辅助能力清单 (Backlog Register)
### 2.1 Module A:人员与基础资源辅助能力 (18 个 API)
| 序号 | HTTP Method & Path | API 业务描述 | 所属领域 | 计划演进阶段 |
|:---:|:---|:---|:---:|:---:|
| 1 | `PUT /train/teacher/{tId}/status` | 修改教师启停用状态 (0停用, 1启用) | 教师管理 | Phase 4 基础功能增强 |
| 2 | `PUT /train/student/{sId}` | 修改学员主档基础信息 | 学员管理 | Phase 4 基础功能增强 |
| 3 | `POST /train/student/import` | 学员库 Excel 批量全局导入 | 学员管理 | Phase 4 基础功能增强 |
| 4 | `PUT /train/course/{cId}` | 修改公共课程库基础资料 | 课程库 | Phase 4 基础功能增强 |
| 5 | `POST /train/course/{cId}/resources` | 课程绑定教学资源 (文档/视频/VR) | 课程资源 | Phase 4 教学资源深化 |
| 6 | `DELETE /train/course/{cId}/resources/{resourceId}` | 课程解绑教学资源 | 课程资源 | Phase 4 教学资源深化 |
| 7 | `GET /train/resource/list` | 分页查询教学资源列表 | 教学资源 | Phase 4 教学资源深化 |
| 8 | `GET /train/resource/{resourceId}` | 获取指定教学资源详情与预览地址 | 教学资源 | Phase 4 教学资源深化 |
| 9 | `POST /train/resource` | 新增教学资源 (支持文档、视频、VR、3D、链接) | 教学资源 | Phase 4 教学资源深化 |
| 10 | `PUT /train/resource/{resourceId}` | 修改教学资源描述与类型 | 教学资源 | Phase 4 教学资源深化 |
| 11 | `DELETE /train/resource/{resourceId}` | 删除教学资源 (引用时校验阻断) | 教学资源 | Phase 4 教学资源深化 |
| 12 | `PUT /train/classroom/{classRoomId}` | 修改教室容纳人数与位置 | 教室管理 | Phase 4 基础功能增强 |
| 13 | `PUT /train/training-site/{siteId}` | 修改实训场地设备类型与容量 | 实训场地 | Phase 4 基础功能增强 |
| 14 | `GET /train/electronic-board/list` | 分页查询电子班牌硬件设备列表 | 电子班牌 | Phase 4 硬件运维管理 |
| 15 | `GET /train/electronic-board/{boardId}` | 获取电子班牌绑定教室与网络配置 | 电子班牌 | Phase 4 硬件运维管理 |
| 16 | `POST /train/electronic-board` | 注册录入电子班牌 SN 与网络端点 | 电子班牌 | Phase 4 硬件运维管理 |
| 17 | `PUT /train/electronic-board/{boardId}` | 修改电子班牌参数配置 | 电子班牌 | Phase 4 硬件运维管理 |
| 18 | `PUT /train/dormitory/{dormId}` | 修改宿舍房间核定容量与状态 (容量不可小于当前已住人数) | 宿舍管理 | Phase 4 基础功能增强 |
---
### 2.2 Module B:开班计划与排课辅助能力 (6 个 API)
| 序号 | HTTP Method & Path | API 业务描述 | 所属领域 | 计划演进阶段 |
|:---:|:---|:---|:---:|:---:|
| 19 | `DELETE /train/plans/{planId}/courses/{planCourseId}` | 移除本期课程 (已有排课时强校验阻断) | 选课管理 | Phase 4 计划运维 |
| 20 | `PUT /train/enrollments/{enrollmentId}/status` | 手工变更参训状态 (2请假, 3放弃) | 参训管理 | Phase 4 学员生命周期 |
| 21 | `DELETE /train/enrollments/{enrollmentId}` | 移除参训人员 (已有打卡或成绩时阻断) | 参训管理 | Phase 4 学员生命周期 |
| 22 | `DELETE /train/lessons/{lessonId}` | 删除排课课次 (已有考勤打卡记录时阻断) | 排课管理 | Phase 4 教学日程调整 |
| 23 | `GET /train/teacher/{tId}/timetable` | 获取指定教师跨班个人周课表日历 | 课表视图 | Phase 4 教师协同服务 |
| 24 | `GET /train/plans/{planId}/reports` | 查询全班学员报到流水详细明细列表 | 报到管理 | Phase 4 报到精细运维 |
---
### 2.3 Module C:住宿、门禁与考勤辅助能力 (4 个 API)
| 序号 | HTTP Method & Path | API 业务描述 | 所属领域 | 计划演进阶段 |
|:---:|:---|:---|:---:|:---:|
| 25 | `GET /train/dormitory/occupancy-board` | 宿舍楼层网格实时房态占用看板 | 住宿管理 | Phase 4 宿管大屏可视化 |
| 26 | `GET /train/plans/{planId}/dorm-auths` | 查询全班门禁授权下发流水与下发状态 | 门禁同步 | Phase 4 硬件运维管理 |
| 27 | `POST /train/attendance/manual-add` | 人工直接补录课次漏打卡考勤记录 | 考勤管理 | Phase 4 考勤特批运维 |
| 28 | `GET /train/plans/{planId}/attendance/summary` | 查询计划考勤汇总概况与缺勤率统计 | 考勤统计 | Phase 4 统计报表增强 |
---
### 2.4 Module D:成绩、学习、统计与画像辅助能力 (6 个 API)
| 序号 | HTTP Method & Path | API 业务描述 | 所属领域 | 计划演进阶段 |
|:---:|:---|:---|:---:|:---:|
| 29 | `POST /train/plans/{planId}/scores/import` | Excel 批量导入全班单科成绩 | 成绩管理 | Phase 4 成绩快速录入 |
| 30 | `GET /train/plans/{planId}/study-records` | 多维分页查询全班外部自主实训流水 | 学习记录 | Phase 4 实训记录审计 |
| 31 | `GET /train/enrollments/{enrollmentId}/study-records` | 查询单学员自主实训时长明细流水 | 学习记录 | Phase 4 学员档案深化 |
| 32 | `GET /train/enrollments/{enrollmentId}/training-detail` | 查询学员个人全流程训练详情 (出勤/单科成绩/实训流水) | 综合成效 | Phase 4 学员档案深化 |
| 33 | `POST /train/enrollments/{enrollmentId}/portrait/generate` | 结课前后手动触发 / 重新刷新生成学员简单画像 | 学员画像 | Phase 4 画像计算引擎 |
| 34 | `GET /train/enrollments/{enrollmentId}/portrait` | 查询学员综合画像数据 (弹性指标集与标签集展示) | 学员画像 | Phase 4 学员多维分析 |
---
## 3. 2 个 P2 远期/待现场确认能力 (Deferred Register)
以下 2 个 API 明确属于 PRD V-01 ~ V-14 现场确认范围或面向移动端/学员端的远期接口,**严禁在一期管理端闭环中盲目推进**:
| 序号 | HTTP Method & Path | 接口名称与业务目的 | 暂缓原因与现场确认事项 | 激活准入条件 |
|:---:|:---|:---|:---|:---|
| **P2-01** | `GET /train/student/my-training` | 学员端移动端个人当前培训日程与成绩查看 | 属于面向学员独立移动端/小程序的门户接口,目前一期定位于基地管理端运转。 | 现场完成学员端 UI 规划与移动端技术选型确认。 |
| **P2-02** | `GET /train/plans/{planId}/statistics/score-distribution` | 计划成绩分段直方图与下钻区间统计 | PRD V-07/V-08 明确提出“是否设立固定分数线或区间待现场确认”,避免过度预设区间。 | 现场最终确认成绩分段规则与及格线标准。 |
---
## 4. Backlog 演进策略建议
1. **版本冻结铁律:** 在 Phase 3 研发及后续集成验收通过之前,上述 36 个 P1/P2 项严禁立项开工;
2. **Phase 4 准入:** 当 Phase 3 验收通过,且基地管理员试运行 P0 主链路顺畅后,优先开放 Phase 4 中的“基础数据修改”与“Excel 批量导入/流水查询”类辅助项;
3. **Phase 5 准入:** 学员画像深度计算与学员移动端入口统一在 Phase 5 根据甲方现场业务实测反馈决定上线节奏。
+177
View File
@@ -0,0 +1,177 @@
# RailwaySystem Phase 3:技术与业务风险登记册 (Risk Register)
> **编制目标:** 系统性识别 Phase 3 实施过程中涉及的高并发锁冲突、事务一致性、外部系统防腐解耦、权限收敛以及契约偏差风险,制定清晰的预防策略与应急预案。
---
## 1. 核心风险总览矩阵
| 风险编号 | 风险类别 | 风险描述与潜在影响 | 发生概率 | 影响程度 | 风险等级 | 应对策略 | 唯一责任 Issue |
|:---:|:---:|:---|:---:|:---:|:---:|:---|:---:|
| **RSK-01** | 并发冲突 | 多管理员同时对同一位教师/场地排课,导致教学冲突穿透 | 中 | 高 | **重大** | 稳定主资源行锁 (`Teacher` + `Classroom/Site` `SELECT ... FOR UPDATE`) + 三类资源时段区间重叠强校验 | `ISSUE-04-01` |
| **RSK-02** | 并发超卖 | 住宿分房并发请求先查空房再插入,导致房间超载超员 | 中 | 高 | **重大** | 锁定稳定宿舍主表行 (`train_dormitory_info FOR UPDATE`) + 周期重叠明细统计,杜绝空房 phantom 超售 | `ISSUE-05-01` |
| **RSK-03** | 事务长阻塞 | 门禁外部 HTTP 调用置于数据库事务内,网络抖动拖垮连接池 | 高 | 极高 | **严重** | 严禁大事务包裹外部调用,采用 `afterCommit` 事务外解耦触发 | `ISSUE-06-02` |
| **RSK-04** | 数据串线 | 跨班学员考勤/打分/学习记录串线,破坏多班级拓扑一致性 | 低 | 极高 | **严重** | Service 层硬编码落实 5 大跨表硬不变量 (INV-01~05) | `ISSUE-07-01` 等 |
| **RSK-05** | 权限扩散 | 历史遗留 `system:teacher:*` 权限字符扩散,导致鉴权混乱 | 中 | 中 | **中等** | 统一收敛为 `train:*`,前端与后端严格对齐 | `ISSUE-11-01` |
| **RSK-06** | 字典冲突 | 盲目执行 `007_dicts.sql` 导致与生产/若依内置字典主键冲突 | 低 | 高 | **重大** | 严禁在本轮物理执行 007,输出架构分类方案并采用 NOT EXISTS 幂等 | `ISSUE-11-02` |
| **RSK-07 (CG-01)** | 契约偏差 | Module A 头部声明 16 个与正文 20 个 P0 标注存在矛盾 | 高 | 低 | **RESOLVED** | 技术负责人已裁决:详细API标记优先于错误摘要统计;P0正式集合以53个逐API标记为准。 | `ISSUE-01-05` (已完整承揽) |
| **RSK-08 (CG-02)** | 契约偏差 | 课次详情单查接口缺失,PlanTimetableVO 字段不足支撑编辑回显 | 中 | 低 | **RESOLVED** | 技术负责人已裁决:通过扩展 TimetableLessonItemVO 补齐回显字段解决;不新增 Lesson 详情 API。 | `ISSUE-04-01` / `04-02` |
---
## 2. 关键技术风险深度分析与技术对策
### 2.1 排课稳定主资源行锁与冲突检测策略 (RSK-01)
- **业务场景:** 两个培训班在同一时间段内均选聘了同一位教师授课,或者两名教务管理员在不同终端同时为不同课程预约了同一间普通教室(或实训场地)。
- **技术隐患:** 普通行锁无法锁住“尚不存在的时段重叠 Lesson 行”。若直接执行 `SELECT ... FROM train_lesson WHERE ... FOR UPDATE`,在未冲突时返回空集,普通行锁失效;事务 A 与事务 B 同时读取空结果,随后分别 INSERT,导致物理冲突穿透。
- **强制工程对策:**
1. 排课事务必须标注 `@Transactional(rollbackFor = Exception.class)`;
2. **锁定稳定主资源:** 创建/修改 Lesson 时,事务内必须先对所涉及的实际主资源行加排他锁(通过主键锁定已存在的物理主表行):
```sql
-- 锁定授课教师主表行
SELECT t_id FROM train_teacher WHERE t_id = :teacherId FOR UPDATE;
-- 锁定场地主表行(根据场地类型锁定教室或实训场地)
SELECT class_room_id FROM train_classroom WHERE class_room_id = :classroomId FOR UPDATE;
-- 或 SELECT site_id FROM train_training_site WHERE site_id = :siteId FOR UPDATE;
```
3. **三类资源上的时间区间重叠冲突查询:** 在持有稳定主资源锁的同一事务内,针对教师、教室、实训场地三类资源执行区间重叠检测:
```sql
existing.start_time < :newEndTime AND existing.end_time > :newStartTime
```
4. **编辑时的新旧资源锁顺序:** 修改 Lesson 若发生教师或场地变更,必须同时对旧资源与新资源加锁;为防止交叉死锁,强制按 `resourceType + ":" + resourceId` 字典序升序依次获取行锁;
5. **课次编辑硬前置拦截:** 修改课次时,若该课次已有学员考勤签到记录,系统强行阻断时段修改。
---
### 2.2 宿舍容量防超卖与稳定宿舍主表行锁策略 (RSK-02)
- **业务场景:** 宿舍 201 核定容量 4 人,当前剩余床位 1 个;或全新空房首次分配。两位管理员并发为两名学员执行分房或调房。
- **技术隐患:** 若依赖“当前已有住宿明细”加锁,在空房或少人场景下查询结果不足以封锁并发写入,导致幻读并发穿透,造成房间超员或性别混住。
- **强制工程对策:**
1. 严禁以前端查询结果作为容量判断依据;严禁仅对 `train_accommodation` 明细行加锁;
2. **锁定稳定宿舍主表行:** 批量预分配或单人调房事务开始,必须先对目标宿舍主表行加排他行锁:
```sql
SELECT dorm_id, capacity, gender_type
FROM train_dormitory_info
WHERE dorm_id = :dormId FOR UPDATE;
```
3. **计算培训周期重叠内的有效占用人数:**
```sql
SELECT COUNT(*) FROM train_accommodation
WHERE dorm_id = :dormId
AND accommodation_status IN (0, 1)
AND NOT (end_date <= :planStartDate OR start_date >= :planEndDate);
```
4. 校验 `(occupiedCount + requestCount) <= capacity`,一旦超员立即抛出 `DORM_ROOM_FULL (40062)` 并回滚;
5. 严格核验学员性别与房间 `gender_type`,杜绝男女混住隐患。
---
### 2.3 外部第三方网络调用与数据库连接池隔离 (RSK-03)
- **业务场景:** 门禁下发需要向外部物理门禁控制机发送 HTTP 请求;电子班牌需推送课程日程;外部实训系统推送学时。
- **技术隐患:** 外部硬件网络经常存在几秒甚至几十秒的超时。若在 `@Transactional` 本地事务中发起长网络 HTTP 请求,将导致本地数据库连接被长时间挂起占用,瞬间耗尽 HikariCP 连接池,引发系统雪崩。
- **强制工程对策:**
1. **铁律:数据库短事务严禁包裹外部第三方 HTTP/网络长调用!**
2. 使用 Spring 事务同步器(`TransactionSynchronizationManager`)在本地事务 COMMIT 成功后,通过 `afterCommit` 回调异步或在事务外触发 Adapter:
```java
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doorAccessAdapter.grantAccess(cmd);
}
});
```
3. 单次外部请求设置硬超时阈值(5 秒);
4. 外部调用失败绝对不可回滚已提交的本地业务事务,而是更新 `face_sync_status = 2` 并记录 `last_error`,提供管理页面手动重试按钮;
5. 一期明确禁止引入 MQ、Kafka、Outbox 模式,保持架构极简可靠。
---
### 2.4 跨班硬一致性破坏与串线风险 (RSK-04)
- **业务场景:** 学员 A 参加 1 班培训,打卡时误刷了 2 班的课次;或教师录入成绩时将 1 班科目的分值打在了 2 班学员的档案上。
- **技术隐患:** 数据库物理外键仅约束了 ID 的存在性,无法感知“班级”拓扑。一旦串线,将导致整个统计报表与学员档案全面污染。
- **强制工程对策:**
1. 全面落实 5 大跨表硬业务不变量(INV-01 ~ INV-05);
2. 每一条不变量由唯一的 Application Service 负责强制校验(详见 [`06_acceptance_matrix.md`](file:///Users/wzb/Desktop/%E9%A1%B9%E7%9B%AE/RailwaySystem/docs/issues-v1/06_acceptance_matrix.md));
3. 任何违背不变量的操作直接抛出全局业务异常(如 `ATTENDANCE_PLAN_MISMATCH (40080)`),从根源杜绝脏数据。
---
## 3. 契约偏差 (Contract Gap) 正式裁决与闭环
### 3.1 CG-01 正式裁决落地 (Module A P0 统计矛盾)
- **最终裁决状态:** **RESOLVED**
- **技术负责人正式裁决结论:**
- **详细 API 标记优先于错误摘要统计;P0 正式集合以 53 个逐 API 标记为准。**
- 宿舍 4 个基础维护 API(列表、详情、新增、选项)属于一期 P0,因为 Accommodation 住宿主流程强依赖可维护的宿舍房间基础资源、核定容量与性别隔离属性。
- **实施落实:**
- Module A P0 正式确认修正为 **20 个**;
- 全局 P0 正式集合确定为 **53 个**(Module A: 20, Module B: 17, Module C: 9, Module D: 7);
- `ISSUE-01-05` 完整承揽宿舍基础管理 4 个 API,架构与 Issue 数量保持 26 个完全不变;
- P0 API 覆盖率达成:**53 / 53 = 100%**。
#### Module A 38 个 API 裁决执行对照表
| 序号 | Method | Path | Contract中的P等级 | 是否主流程前置必须 | 关联Issue | 正式P等级 | 裁决依据与落地说明 |
|:---:|:---|:---|:---:|:---:|:---:|:---:|:---|
| 1 | GET | `/train/teacher/list` | P0 | 是 | `ISSUE-01-01` | **P0** | 排课选教师前置必须,主链路不可缺少 |
| 2 | GET | `/train/teacher/{tId}` | P0 | 是 | `ISSUE-01-01` | **P0** | 教师详情回显,编辑与主档核查必须 |
| 3 | POST | `/train/teacher` | P0 | 是 | `ISSUE-01-01` | **P0** | 录入基础教师数据必须 |
| 4 | PUT | `/train/teacher/{tId}` | P0 | 是 | `ISSUE-01-01` | **P0** | 教师联系方式/职务维护必须 |
| 5 | PUT | `/train/teacher/{tId}/status` | P1 | 否 | - | P1 | 启停用管理,非 MVP 开班排课必经路径 |
| 6 | GET | `/train/student/list` | P0 | 是 | `ISSUE-01-02` | **P0** | 参训选人、学员检索主链路必须 |
| 7 | GET | `/train/student/{sId}` | P0 | 是 | `ISSUE-01-02` | **P0** | 学员详情回显必须 |
| 8 | POST | `/train/student` | P0 | 是 | `ISSUE-01-02` | **P0** | 学员主数据手工建档必须 |
| 9 | PUT | `/train/student/{sId}` | P1 | 否 | - | P1 | 学员主档修改,可由名单导入覆盖 |
| 10 | POST | `/train/student/import` | P1 | 否 | - | P1 | 全局学员库导入,培训班级直接用花名册批量导入 |
| 11 | GET | `/train/course/list` | P0 | 是 | `ISSUE-01-03` | **P0** | 培训计划引入课程必须 |
| 12 | GET | `/train/course/{cId}` | P0 | 是 | `ISSUE-01-03` | **P0** | 课程详情回显必须 |
| 13 | POST | `/train/course` | P0 | 是 | `ISSUE-01-03` | **P0** | 录入公共课程必须 |
| 14 | PUT | `/train/course/{cId}` | P1 | 否 | - | P1 | 课程修改,属于运营维护功能 |
| 15 | POST | `/train/course/{cId}/resources` | P1 | 否 | - | P1 | 课程关联资源,非开班排课硬依赖 |
| 16 | DELETE | `/train/course/{cId}/resources/{resourceId}` | P1 | 否 | - | P1 | 课程解绑资源,非开班排课硬依赖 |
| 17 | GET | `/train/resource/list` | P1 | 否 | - | P1 | 教学资源库列表,辅助教学功能 |
| 18 | GET | `/train/resource/{resourceId}` | P1 | 否 | - | P1 | 教学资源详情,辅助教学功能 |
| 19 | POST | `/train/resource` | P1 | 否 | - | P1 | 新增教学资源,辅助教学功能 |
| 20 | PUT | `/train/resource/{resourceId}` | P1 | 否 | - | P1 | 修改教学资源,辅助教学功能 |
| 21 | DELETE | `/train/resource/{resourceId}` | P1 | 否 | - | P1 | 删除教学资源,辅助教学功能 |
| 22 | GET | `/train/classroom/list` | P0 | 是 | `ISSUE-01-04` | **P0** | 排课选普通教室必须 |
| 23 | GET | `/train/classroom/{classRoomId}` | P0 | 是 | `ISSUE-01-04` | **P0** | 教室详情与核定人数核查必须 |
| 24 | POST | `/train/classroom` | P0 | 是 | `ISSUE-01-04` | **P0** | 录入教室基础数据必须 |
| 25 | PUT | `/train/classroom/{classRoomId}` | P1 | 否 | - | P1 | 教室维护,非首批开班硬依赖 |
| 26 | GET | `/train/training-site/list` | P0 | 是 | `ISSUE-01-04` | **P0** | 排课选实训演练场地必须 |
| 27 | GET | `/train/training-site/{siteId}` | P0 | 是 | `ISSUE-01-04` | **P0** | 实训场地详情回显必须 |
| 28 | POST | `/train/training-site` | P0 | 是 | `ISSUE-01-04` | **P0** | 录入实训场地必须 |
| 29 | PUT | `/train/training-site/{siteId}` | P1 | 否 | - | P1 | 实训场地维护,非首批硬依赖 |
| 30 | GET | `/train/electronic-board/list` | P1 | 否 | - | P1 | 班牌设备管理,非业务流程必须 |
| 31 | GET | `/train/electronic-board/{boardId}` | P1 | 否 | - | P1 | 班牌详情,非业务流程必须 |
| 32 | POST | `/train/electronic-board` | P1 | 否 | - | P1 | 注册班牌,非业务流程必须 |
| 33 | PUT | `/train/electronic-board/{boardId}` | P1 | 否 | - | P1 | 修改班牌,非业务流程必须 |
| 34 | GET | `/train/dormitory/list` | P0 | 是 | `ISSUE-01-05` | **P0** | 宿舍房态列表,分房/调房选择必须,裁决确认 P0 |
| 35 | GET | `/train/dormitory/{dormId}` | P0 | 是 | `ISSUE-01-05` | **P0** | 宿舍详情与当前住户回显,裁决确认 P0 |
| 36 | POST | `/train/dormitory` | P0 | 是 | `ISSUE-01-05` | **P0** | 录入宿舍房间基础数据必须,裁决确认 P0 |
| 37 | PUT | `/train/dormitory/{dormId}` | P1 | 否 | - | P1 | 修改宿舍房间,维持 P1 |
| 38 | GET | `/train/dormitory/options` | P0 | 是 | `ISSUE-01-05` | **P0** | 楼栋-楼层-房间级联下拉,分房交互必须,裁决确认 P0 |
---
### 3.2 CG-02 正式裁决落地 (课次详情单查接口缺失评估)
- **最终裁决状态:** **RESOLVED**
- **技术负责人正式裁决结论:**
- **通过扩展 TimetableLessonItemVO 解决;不新增 Lesson 详情 API。**
- 严禁私自新增 `GET /train/lessons/{lessonId}` 端点,保持 API Contract 接口数量稳定。
- **实施落实:**
- 已在 `docs/api-v2/06_dto_vo_catalog.md` 中补齐 `TimetableLessonItemVO` 的关键外键关联字段:`planCourseId`、`tId`、`venueType`、`classRoomId`、`classRoomName`、`siteId`、`siteName` 与 `remark`;
- 前端通过已有的 `GET /train/plans/{planId}/timetable`(完整课表 VO)读取当前 Lesson 项并完整回显编辑表单,随后直接调用既有的 `PUT /train/lessons/{lessonId}` 完成更新,主链路完全闭环。
---
## 4. 风险审查与日常跟踪机制
1. **每日构建与自动化测试:** 每日 CI 流水线必须执行 `P0BusinessFlowIntegrationTest`,自动验证 5 大硬不变量及并发冲突破坏性用例;
2. **代码审查红线卡点:**
- 审查排课代码必须核对是否包含主资源 `FOR UPDATE` 行锁;
- 审查住宿代码必须核对是否包含稳定宿舍主表行锁与原子容量核验;
- 审查外部调用代码必须核对是否位于事务外(`afterCommit` 触发)。
3. **现场确认事项隔离:** 涉及及格线判定、学员画像复杂标签、报到逾期模式等待现场确认事项,后端一律预留弹性枚举与参数配置,严禁硬编码。
+195
View File
@@ -0,0 +1,195 @@
# RailwaySystem Phase 3:Issue Review Round 1 评审整改报告 (Issue Review Round 1 Report)
> **编制说明:** 针对技术负责人在 Phase 3 初审中作出的 `Phase 3 Issue Review = B` 判定与整改要求,本报告逐项记录 Git 分支基线修正 (IR-01)、P0 API 集合一致性核验 (IR-02)、代码生成器策略修正 (IR-03)、身份证安全算法边界规范 (IR-04)、排课/住宿并发锁模型强化 (IR-05) 以及两项契约偏差 (CG-01, CG-02) 的核查与落地整改结果。
---
## 一、整改事项与评审状态总览表
| 编号 | 评审项类别 | 事项描述 | 整改前状态 | 本轮整改判定 | 核心结论与处置动作 |
|:---:|:---:|:---|:---:|:---:|:---|
| **IR-01** | 基线管理 | 分支基线错误(原在 `feat/cicd-pipeline`) | 错误 | **RESOLVED** | 切回 `main` 分支,快进至 `67543e04` (`api-v2-contract-v1.0`),确认其为 HEAD 祖先,工作区保持纯未跟踪文档状态。 |
| **IR-02** | 契约一致性 | P0 API Contract 集合核验与“最大化覆盖”越权纠正 | 偏差 | **RESOLVED** | 从 Tag `api-v2-contract-v1.0` 抽取 53 处正文 P0 标注,比对 `comm -3` 差异为 0;废止主观定性,声明覆盖率待 CG-01 裁决。 |
| **IR-03** | 工程统计 | 代码生成器生成策略统计错误 (10/12 偏差) | 错误 | **RESOLVED** | 重新核定 `ISSUE-05-02` 为纯业务状态维护 (NO),机器断言通过:YES: 4, PARTIAL: 11, NO: 11 == 26 Total。 |
| **IR-04** | 安全架构 | 身份证算法越界(提前写死 AES/HMAC 等具体算法) | 越界 | **RESOLVED** | 全局清除具体算法名称;统一沿用 Database V2 规范 (`ciphertext`, `hash`, `last4`),具体算法方案留待 Security Review 裁定。 |
| **IR-05** | 并发模型 | 排课与住宿并发锁语义不足(普通查询行锁无法防穿透) | 不足 | **RESOLVED** | 排课改为锁定稳定主资源 (`Teacher` + `Classroom/Site` 行锁);住宿改为锁定稳定宿舍主表行 (`train_dormitory_info` 行锁)。 |
| **CG-01** | 契约偏差 | Module A 头部 16 个与正文 20 个 P0 标注矛盾 | 擅自闭环 | **RESOLVED** | 技术负责人裁决:详细API标记优先于错误摘要统计;P0正式集合以53个逐API标记为准,覆盖率 53/53 = 100%。 |
| **CG-02** | 契约偏差 | 课次详情单查接口缺失,PlanTimetableVO 字段是否足额 | 假定闭环 | **RESOLVED** | 技术负责人裁决:通过扩展 TimetableLessonItemVO 补齐回显字段解决;不新增 Lesson 详情 API。 |
| **CG-03** | 契约计数 | API物理端点计数与模块摘要偏差 (91 vs 89) | 偏差 | **RESOLVED** | 技术负责人裁决:物理Endpoint定义优先于摘要计数。API Contract V1.0.1正式总数 = 89 (P0: 53, P1: 34, P2: 2)。 |
---
## 二、IR-01:Git 分支基线修正 (Branch Baseline)
### 2.1 历史问题复盘
- 此前工作在 `feat/cicd-pipeline` 分支上推进,该分支包含了 `e0abe884`(CI/CD 流水线配置)等未合并至主干的提交,且在此分支上曾对 `00_readme.md` 与 `01_epic_overview.md` 产生了已跟踪版本,违背了“正式 Phase 3 必须基于 `main` 且必须以 `api-v2-contract-v1.0` (`67543e04`) 为开发基线”的铁律。
### 2.2 整改执行步骤
1. **备份工作区 Issue 文档:**
在仓库外完整备份至 `/tmp/railwaysystem-issues-v1-review/issues-v1`,验证 9 份文档均完整存在;
2. **切换并对齐 `main` 分支:**
- 切换到 `main` 分支后,执行 `git merge --ff-only 67543e04` 将本地 `main` 分支推进到冻结的 API Contract 基线;
- 执行 `git merge-base --is-ancestor 67543e04 HEAD`,验证返回值必须且确实为 `0`;
3. **工作区恢复与洁净度检查:**
- 执行 `git ls-tree -r --name-only main docs/issues-v1/`,确认 `main` 分支历史中不存在任何 `docs/issues-v1/` 历史记录;
- 将 9 篇已修正的 Issue 文档重新复制回工作区,作为当前未跟踪文件(untracked files)保留;
- 严禁任何业务代码修改,严禁提前 `git commit`。
---
## 三、IR-02:P0 API Contract 集合核验 (P0 Contract Consistency)
### 3.1 Tag 来源抽取与双向比对
- **唯一真理来源:** 直接从 Git Tag `api-v2-contract-v1.0` 读取正文 API 定义,通过提取逐一抽取 `HTTP METHOD + PATH`,导出至 `/tmp/railway-p0-contract.sorted`;
- **Issue 映射抽取:** 从 `docs/issues-v1/02_p0_issue_backlog.md` 及 `06_acceptance_matrix.md` 中抽取全部认领的 P0 API,导出至 `/tmp/railway-p0-issues.sorted`;
- **差集核验命令:**
```bash
comm -3 /tmp/railway-p0-contract.sorted /tmp/railway-p0-issues.sorted
```
- **比对结果:** 差集输出为空(0 差异),证明 Issue 库已无遗漏地精确涵盖了 API 契约正文中打上了【P0】标记的全部 53 处 API 端点。
### 3.2 契约裁决与 100% 覆盖闭环
- 技术负责人已正式裁决:**详细 API 标记优先于错误摘要统计;P0 正式集合以 53 个逐 API 标记为准**;
- 经双向比对验证,Issue 库对 53 个 P0 API 的覆盖率为 **53 / 53 = 100%**,Missing = 0, Extra = 0。P0 核心需求边界完全冻结闭环。
---
## 四、IR-03:代码生成器生成策略统计修正 (Generator Count)
### 4.1 策略分类重新定性
- **纠偏事实:** 初稿中统计数据为 `PARTIAL = 10, NO = 12`,存在策略归属与实际 Issue 责任判定口径不统一的问题;
- **定性依据:**
- `ISSUE-05-02`(住宿调房与走读):该 Issue 不涉及新物理表或字段的新增建表,其核心逻辑是在现有 `train_accommodation` 表上进行原子行锁获取、房间容量校验、男女隔离核验、`accommodation_status` 状态流转以及床位释放,完全属于复杂的业务状态机与并发防超卖控制,应严格定性为 **Generator NO**;
- **最终准确统计分布:**
- **Generator YES (4 个):** `ISSUE-01-01`, `ISSUE-01-03`, `ISSUE-01-04`, `ISSUE-01-05`
- **Generator PARTIAL (11 个):** `ISSUE-01-02`, `ISSUE-02-01`, `ISSUE-02-02`, `ISSUE-03-01`, `ISSUE-04-01`, `ISSUE-05-01`, `ISSUE-07-01`, `ISSUE-07-02`, `ISSUE-08-01`, `ISSUE-08-02`, `ISSUE-09-01`
- **Generator NO (11 个):** `ISSUE-03-02`, `ISSUE-04-02`, `ISSUE-05-02`, `ISSUE-05-03`, `ISSUE-06-01`, `ISSUE-06-02`, `ISSUE-10-01`, `ISSUE-10-02`, `ISSUE-11-01`, `ISSUE-11-02`, `ISSUE-11-03`
- **机器校验等式断言:**
$$\text{YES}(4) + \text{PARTIAL}(11) + \text{NO}(11) = 26 \quad (\text{P0 Issue 总数 } 26)$$
已在 `00_readme.md`、`02_p0_issue_backlog.md`、`05_agent_assignment.md` 中完成一致性更新并通过自动化校验。
---
## 五、IR-04:身份证安全算法边界规范 (Security Algorithm Boundary)
### 5.1 违规内容清除
- 针对 `ISSUE-01-02`、`04_wave_plan.md`、`05_agent_assignment.md` 等文档中提及的 `AES`、`HMAC-SHA256`、`国密` 等具体加密算法与散列实现名称进行了全局搜索与彻底清除。
### 5.2 正式安全规范定义
统一收敛为标准安全持久化语义,严格沿用 Database V2 的字段模型:
1. `id_card_ciphertext`:可逆密文存储;
2. `id_card_hash`:用于确定性查重/检索的安全散列值;
3. `id_card_last4`:展示末四位;
4. **决策边界锁闭:** 具体可逆加密算法(如 AES-GCM 或 SM4)、密钥管理与轮转方案、确定性散列/HMAC 算法留待 Security Implementation Review 专项评审确定,Issue 阶段严禁提前写死;
5. **脱敏与防御边界:** 系统日志、全局异常提示、API 响应 VO、Excel 导出等任何对外呈现场景,严禁输出完整明文身份证号。
---
## 六、IR-05:排课与住宿并发锁模型加固 (Concurrency Locking)
### 6.1 排课并发模型加固 (ISSUE-04-01)
- **缺陷剖析:** 若仅对“时段冲突的已有课次”做 `SELECT ... FOR UPDATE`,在无冲突场景下该查询返回空记录集,普通行锁无法锁住不存在的行,高并发下事务 A 与 B 会同时认为无冲突并写入,导致时段重叠穿透。
- **稳定主资源行锁机制:**
1. 事务开始后,先通过主键对实际涉及的稳定主资源行加排他锁:
```sql
SELECT t_id FROM train_teacher WHERE t_id = :teacherId FOR UPDATE;
SELECT class_room_id FROM train_classroom WHERE class_room_id = :classroomId FOR UPDATE;
-- 或实训场地:SELECT site_id FROM train_training_site WHERE site_id = :siteId FOR UPDATE;
```
2. 在持有稳定主资源锁的同一事务内,针对教师、教室、实训场地三类资源执行时间区间重叠冲突检测:
```sql
existing.start_time < :newEndTime AND existing.end_time > :newStartTime
```
3. **死锁防御:** 课次编辑若涉及教师或场地变更,必须同时锁定旧资源与新资源;为防止交叉死锁,强制要求按 `resourceType + ":" + resourceId` 字典序升序依次获取行锁;
4. **状态硬约束:** 若课次已有学员考勤打卡记录,强行阻断时段修改。
### 6.2 住宿并发模型加固 (ISSUE-05-01 / 05-02)
- **缺陷剖析:** 若仅锁定当前已有住宿明细,在空房或少人入住场景下无法封锁并发的批量分配,容易发生幻读导致超核定人数入住或男女混住。
- **稳定宿舍主表行锁机制:**
1. 分房与调房事务开始后,必须先锁定目标房间的稳定物理主表行:
```sql
SELECT dorm_id, capacity, gender_type
FROM train_dormitory_info
WHERE dorm_id = :dormId FOR UPDATE;
```
2. 统计与目标培训周期存在重叠的有效住宿人数(预分配 0 与已入住 1):
```sql
SELECT COUNT(*) FROM train_accommodation
WHERE dorm_id = :dormId
AND accommodation_status IN (0, 1)
AND NOT (end_date <= :planStartDate OR start_date >= :planEndDate);
```
3. 校验 `(occupiedCount + requestCount) <= capacity`,一旦超员立即抛出 `DORM_ROOM_FULL (40062)` 并回滚;
4. 严格校验学员性别与房间 `gender_type`,杜绝男女混住隐患。
---
## 七、CG-01:Module A P0 统计矛盾 (Module A P0 Count)
- **最终裁决状态:** **RESOLVED**
- **技术负责人正式裁决结论:**
- **详细 API 标记优先于错误摘要统计;P0 正式集合以 53 个逐 API 标记为准**;
- 宿舍 4 个基础维护 API 属于一期 P0,因为 Accommodation 住宿主流程强依赖可维护的宿舍房间基础资源、核定容量与性别隔离属性;
- **实施落实:**
- `docs/api-v2/02_module_a_base_resource_api.md` 头部统计正式修正为 `P0: 20个, P1: 18个`;
- 全局 P0 集合确定为 53 个(Module A: 20, Module B: 17, Module C: 9, Module D: 7);
- `ISSUE-01-05` 完整承揽宿舍基础管理,P0 Issue 总数保持 26 个不变;
- 机器比对验证 Missing=0, Extra=0,覆盖率达成 **53/53 = 100%**。
---
## 八、CG-02:课次详情单查接口缺失评估 (Lesson Detail API)
- **最终裁决状态:** **RESOLVED**
- **技术负责人正式裁决结论:**
- **通过扩展 TimetableLessonItemVO 解决;不新增 Lesson 详情 API**;
- 严禁在 Issue 阶段或契约中新增 `GET /train/lessons/{lessonId}` 端点,保持 API 契约稳定;
- **实施落实:**
- 已在 `docs/api-v2/06_dto_vo_catalog.md` 中补齐 `TimetableLessonItemVO` 的关键外键关联字段(`planCourseId`、`tId`、`venueType`、`classRoomId`、`classRoomName`、`siteId`、`siteName`、`remark`);
- 前端通过完整课表 `PlanTimetableVO` 直接回显 Lesson 编辑表单,随后调用既有的 `PUT /train/lessons/{lessonId}`,流程完全自洽闭环。
---
## 九、CG-03:API 物理端点计数一致性修复 (Physical Endpoint Consistency)
- **最终裁决状态:** **RESOLVED**
- **技术负责人正式裁决结论:**
- **API 总数必须以正文中真实定义了 HTTP Method + Path 的物理 Endpoint 为准。模块摘要数字不能代表一个不存在的 API**;
- 严禁为了凑齐旧的“91/25/15”摘要数字而人为捏造虚构 API;
- Module B 预想的 `GET /train/lessons/{lessonId}` 经 CG-02 裁决不新增,真实物理端点为 24 个(P0: 17, P1: 6, P2: 1);
- Module D 真实物理端点为 14 个(P0: 7, P1: 6, P2: 1),无第 15 个物理接口;
- **实施落实:**
- API Contract V1.0.1 正式口径统一定为:**总数 89 (P0: 53, P1: 34, P2: 2)**;
- 各模块端点分布:Module A: 38 (20/18/0), Module B: 24 (17/6/1), Module C: 13 (9/4/0), Module D: 14 (7/6/1);
- 新增 HTTP API = 0,删除已定义 HTTP API = 0(摘要计数纠偏,非删除已存在 API);
- Issue 库 P1/P2 Backlog (`07_p1_p2_backlog.md`) 重新全量机器对齐,严禁虚构占位项,实际登记 P1: 34 个,P2: 2 个。
---
## 十、计划结束与业务边界核查 (ISSUE-02-01)
- 再次核对 `ISSUE-02-01`:
- 确认仅包含允许的:`Plan 结训归档` $\rightarrow$ `Enrollment 标记 completed` $\rightarrow$ `记录 complete_time` $\rightarrow$ `历史数据归档保留`;
- 严格排查并确认未引入任何“及格才可结业”、“证书自动生成”、“结业审批流”、“成绩发布前置审批”等未冻结逻辑;
- 严格将此类待定规则隔离在现场确认事项之外,遵循 PRD V1.0 冻结边界。
---
## 十一、文档完整性与 Whitespace 规范校验
- 对工作区中的全部 Issue 文档执行 `git add -N docs/issues-v1/*.md` 与 `git diff --check`:
- 检查结果:**无任何空白字符、行尾空格或格式错误**;
- 随后执行 `git reset` 释放意图,确保所有 Issue 文档在 `main` 分支上保持未提交、未跟踪的洁净工作区状态。
---
## 十二、最终评审判定 (Final Assessment)
- **综合整改评定:**
- IR-01 ~ IR-05 全部整改完毕并达标(RESOLVED);
- CG-01, CG-02, CG-03 三项裁决全部闭环落实(RESOLVED);
- P0 需求覆盖率 53/53 = 100%,Missing = 0, Extra = 0;
- Generator 策略保持 YES: 4, PARTIAL: 11, NO: 11 (Total 26);
- API 物理契约总数 89 (53/34/2) 与 Backlog 严格一致。
- **最终判定结论:**
$$\mathbf{Issue\ Review\ Round\ 1\ Final = A}$$