xucaiqin 1 hete
szülő
commit
cfe06c710d
19 módosított fájl, 1350 hozzáadás és 175 törlés
  1. 77 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/controller/app/TrainingsController.java
  2. 0 13
      sckw-modules/sckw-system/src/main/java/com/sckw/system/dao/TTrainUserMapper.java
  3. 0 41
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/TTrainUser.java
  4. 42 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainAppExamSubmitReqVo.java
  5. 27 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainAppListReqVo.java
  6. 25 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainCourseStudyReqVo.java
  7. 80 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppDetailResVo.java
  8. 75 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamPaperResVo.java
  9. 40 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamStartResVo.java
  10. 28 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamSubmitResVo.java
  11. 26 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppListResVo.java
  12. 31 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppTaskCourseResVo.java
  13. 51 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppTaskResVo.java
  14. 0 2
      sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainDetailResVo.java
  15. 4 49
      sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsCourseService.java
  16. 839 0
      sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsTrainAppService.java
  17. 5 39
      sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsTrainService.java
  18. 0 16
      sckw-modules/sckw-system/src/main/java/com/sckw/system/service/TTrainUserService.java
  19. 0 15
      sckw-modules/sckw-system/src/main/resources/mapper/TTrainUserMapper.xml

+ 77 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/controller/app/TrainingsController.java

@@ -0,0 +1,77 @@
+package com.sckw.system.controller.app;
+
+import com.sckw.core.web.response.BaseResult;
+import com.sckw.system.model.vo.req.TrainAppExamSubmitReqVo;
+import com.sckw.system.model.vo.req.TrainAppListReqVo;
+import com.sckw.system.model.vo.req.TrainCourseStudyReqVo;
+import com.sckw.system.model.vo.res.*;
+import com.sckw.system.service.KwsTrainAppService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/train")
+@Tag(name = "培训学习(App)")
+@RequiredArgsConstructor
+public class TrainingsController {
+
+    private final KwsTrainAppService kwsTrainAppService;
+
+    @PostMapping("/list")
+    @Operation(summary = "培训任务列表", description = "分页查询当前司机的培训任务,包含整体学习进度与任务列表")
+    public BaseResult<TrainAppListResVo> list(@RequestBody @Valid TrainAppListReqVo reqVo) {
+        return BaseResult.success(kwsTrainAppService.listTrainTasks(reqVo));
+    }
+
+    @GetMapping("/detail")
+    @Operation(summary = "培训任务详情", description = "查询当前司机指定培训任务详情,包含课程学习状态与考试信息")
+    public BaseResult<TrainAppDetailResVo> detail(@RequestParam("trainId") Long trainId) {
+        return BaseResult.success(kwsTrainAppService.trainDetail(trainId));
+    }
+
+    @GetMapping("/course/detail")
+    @Operation(summary = "培训课程详情", description = "查询培训课程详情")
+    public BaseResult<CourseDetailResVo> courseDetail(@RequestParam("courseId") Long courseId) {
+        return BaseResult.success(kwsTrainAppService.courseDetail(courseId));
+    }
+
+    @PostMapping("/course/start")
+    @Operation(summary = "开始学习课程", description = "写入课程学习开始记录,并维护培训记录状态")
+    public BaseResult<Boolean> startCourse(@RequestBody @Valid TrainCourseStudyReqVo reqVo) {
+        return BaseResult.success(kwsTrainAppService.startCourse(reqVo));
+    }
+
+    @PostMapping("/course/finish")
+    @Operation(summary = "完成课程学习", description = "写入课程学习完成记录,并维护培训记录完成课程数/状态")
+    public BaseResult<Boolean> finishCourse(@RequestBody @Valid TrainCourseStudyReqVo reqVo) {
+        return BaseResult.success(kwsTrainAppService.finishCourse(reqVo));
+    }
+
+    @GetMapping("/exam/paper")
+    @Operation(summary = "考试试卷", description = "查询培训对应考试试卷(不包含正确答案)")
+    public BaseResult<TrainAppExamPaperResVo> examPaper(@RequestParam("trainId") Long trainId, @RequestParam(required = false) Long logId) {
+        return BaseResult.success(kwsTrainAppService.examPaper(trainId, logId));
+    }
+
+    @PostMapping("/exam/start")
+    @Operation(summary = "开始考试", description = "创建考试日志,返回考试基本信息与日志ID")
+    public BaseResult<TrainAppExamStartResVo> startExam(@RequestParam("trainId") Long trainId) {
+        return BaseResult.success(kwsTrainAppService.startExam(trainId));
+    }
+
+
+    @PostMapping("/exam/submit")
+    @Operation(summary = "提交考试", description = "提交考试答案,写入考试记录并更新考试日志/培训记录")
+    public BaseResult<TrainAppExamSubmitResVo> submitExam(@RequestBody @Valid TrainAppExamSubmitReqVo reqVo) {
+        return BaseResult.success(kwsTrainAppService.submitExam(reqVo));
+    }
+}
+

+ 0 - 13
sckw-modules/sckw-system/src/main/java/com/sckw/system/dao/TTrainUserMapper.java

@@ -1,13 +0,0 @@
-package com.sckw.system.dao;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.sckw.system.model.TTrainUser;
-import org.apache.ibatis.annotations.Mapper;
-
-/**
-* @date 2026-06-09 10:50:04
-* @author xucaiqin
-*/
-@Mapper
-public interface TTrainUserMapper extends BaseMapper<TTrainUser> {
-}

+ 0 - 41
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/TTrainUser.java

@@ -1,41 +0,0 @@
-package com.sckw.system.model;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Data;
-
-/**
-* @date 2026-06-09 10:50:04
-* @author xucaiqin
-*/
-/**
- * 培训用户
- */
-@Schema(description="培训用户")
-@Data
-@TableName(value = "t_train_user")
-public class TTrainUser {
-    /**
-     * id
-     */
-    @TableId(value = "id", type = IdType.AUTO)
-    @Schema(description="id")
-    private Long id;
-
-    /**
-     * 培训计划id
-     */
-    @TableField(value = "train_id")
-    @Schema(description="培训计划id")
-    private Long trainId;
-
-    /**
-     * 司机id
-     */
-    @TableField(value = "driver_id")
-    @Schema(description="司机id")
-    private Long driverId;
-}

+ 42 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainAppExamSubmitReqVo.java

@@ -0,0 +1,42 @@
+package com.sckw.system.model.vo.req;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+@Schema(description = "App提交考试请求")
+public class TrainAppExamSubmitReqVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "logId不能为空")
+    @Schema(description = "考试日志ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    private Long logId;
+
+    @NotEmpty(message = "answers不能为空")
+    @Schema(description = "答题列表", requiredMode = Schema.RequiredMode.REQUIRED)
+    private List<AnswerItem> answers;
+
+    @Data
+    @Schema(description = "单题作答")
+    public static class AnswerItem implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        @NotNull(message = "questionId不能为空")
+        @Schema(description = "题目ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+        private Long questionId;
+
+        @Schema(description = "用户选择的答案,单选: A,多选: A,B", example = "A")
+        private String answer;
+    }
+}
+

+ 27 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainAppListReqVo.java

@@ -0,0 +1,27 @@
+package com.sckw.system.model.vo.req;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Schema(description = "App培训任务列表查询请求")
+public class TrainAppListReqVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "任务状态筛选:0-全部 1-未开始 2-进行中 3-已结束", example = "0")
+    @NotNull(message = "status不能为空")
+    private Integer status = 0;
+
+    @Schema(description = "当前页码", example = "1")
+    private Integer pageNum = 1;
+
+    @Schema(description = "每页数量", example = "10")
+    private Integer pageSize = 10;
+}
+

+ 25 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/req/TrainCourseStudyReqVo.java

@@ -0,0 +1,25 @@
+package com.sckw.system.model.vo.req;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Schema(description = "App课程学习请求")
+public class TrainCourseStudyReqVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "trainId不能为空")
+    @Schema(description = "培训计划ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    private Long trainId;
+
+    @NotNull(message = "courseId不能为空")
+    @Schema(description = "课程ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    private Long courseId;
+}
+

+ 80 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppDetailResVo.java

@@ -0,0 +1,80 @@
+package com.sckw.system.model.vo.res;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Schema(description = "App培训任务详情响应")
+public class TrainAppDetailResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "培训计划ID", example = "1")
+    private Long trainId;
+
+    @Schema(description = "培训主题", example = "2026年安全培训")
+    private String trainName;
+
+    @Schema(description = "开始日期", example = "2026-06-01")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private LocalDate startDate;
+
+    @Schema(description = "结束日期", example = "2026-06-30")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private LocalDate endDate;
+
+    @Schema(description = "任务状态 1-未开始 2-进行中 3-已结束", example = "2")
+    private Integer status;
+
+    @Schema(description = "任务状态名称", example = "进行中")
+    private String statusName;
+
+    @Schema(description = "已学课程数", example = "1")
+    private Integer learnedCourse;
+
+    @Schema(description = "总课程数", example = "2")
+    private Integer sumCourse;
+
+    @Schema(description = "课程列表")
+    private List<TrainAppTaskCourseResVo> courses;
+
+    @Schema(description = "考试信息")
+    private Exam exam;
+
+    @Schema(description = "是否显示去考试按钮", example = "true")
+    private Boolean showExam;
+
+    @Schema(description = "考试是否已通过", example = "false")
+    private Boolean examPassed;
+
+    @Data
+    @Schema(description = "考试信息")
+    public static class Exam implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        @Schema(description = "考试ID", example = "1")
+        private Long examId;
+
+        @Schema(description = "考试名称")
+        private String name;
+
+        @Schema(description = "考试时长(分钟)", example = "60")
+        private Integer examDuration;
+
+        @Schema(description = "及格率(0-100)", example = "60")
+        private Integer passRate;
+
+        @Schema(description = "题目数量", example = "20")
+        private Integer amount;
+    }
+}
+

+ 75 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamPaperResVo.java

@@ -0,0 +1,75 @@
+package com.sckw.system.model.vo.res;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+@Schema(description = "App考试试卷响应")
+public class TrainAppExamPaperResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "考试日志ID", example = "1")
+    private Long logId;
+
+    @Schema(description = "考试ID", example = "1")
+    private Long examId;
+
+    @Schema(description = "考试名称")
+    private String examName;
+
+    @Schema(description = "考试时长(分钟)", example = "60")
+    private Integer examDuration;
+
+    @Schema(description = "及格率(0-100)", example = "60")
+    private Integer passRate;
+
+    @Schema(description = "题目数量", example = "20")
+    private Integer amount;
+
+    @Schema(description = "题目列表")
+    private List<Question> questions;
+
+    @Data
+    @Schema(description = "题目")
+    public static class Question implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        @Schema(description = "题目ID", example = "1")
+        private Long questionId;
+
+        @Schema(description = "题目名称")
+        private String name;
+
+        @Schema(description = "类型 1-单选 2-多选 3-问答", example = "1")
+        private Integer type;
+
+        @Schema(description = "类型名称", example = "单选")
+        private String typeName;
+
+        @Schema(description = "选项列表")
+        private List<Option> options;
+    }
+
+    @Data
+    @Schema(description = "选项")
+    public static class Option implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        @Schema(description = "选项名称 A/B/C/D", example = "A")
+        private String name;
+
+        @Schema(description = "选项内容", example = "安全帽")
+        private String content;
+    }
+}
+

+ 40 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamStartResVo.java

@@ -0,0 +1,40 @@
+package com.sckw.system.model.vo.res;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@Schema(description = "App开始考试响应")
+public class TrainAppExamStartResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "考试日志ID", example = "1")
+    private Long logId;
+
+    @Schema(description = "考试ID", example = "1")
+    private Long examId;
+
+    @Schema(description = "考试名称")
+    private String examName;
+
+    @Schema(description = "考试时长(分钟)", example = "60")
+    private Integer examDuration;
+
+    @Schema(description = "及格率(0-100)", example = "60")
+    private Integer passRate;
+
+    @Schema(description = "题目数量", example = "20")
+    private Integer amount;
+
+    @Schema(description = "开始时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime startTime;
+}
+

+ 28 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppExamSubmitResVo.java

@@ -0,0 +1,28 @@
+package com.sckw.system.model.vo.res;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Schema(description = "App提交考试响应")
+public class TrainAppExamSubmitResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "正确题目数", example = "17")
+    private Integer rightAmount;
+
+    @Schema(description = "总题目数", example = "20")
+    private Integer amount;
+
+    @Schema(description = "正确率(0-100)", example = "85")
+    private Integer rightRate;
+
+    @Schema(description = "是否通过", example = "true")
+    private Boolean passed;
+}
+

+ 26 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppListResVo.java

@@ -0,0 +1,26 @@
+package com.sckw.system.model.vo.res;
+
+import com.sckw.core.web.response.result.PageDataResult;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Schema(description = "App培训任务列表响应")
+public class TrainAppListResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "整体学习进度(0-100)", example = "50")
+    private Integer progressRate;
+
+    @Schema(description = "任务总数", example = "2")
+    private Integer totalTask;
+
+    @Schema(description = "分页任务列表")
+    private PageDataResult<TrainAppTaskResVo> page;
+}
+

+ 31 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppTaskCourseResVo.java

@@ -0,0 +1,31 @@
+package com.sckw.system.model.vo.res;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Schema(description = "App培训任务内课程项")
+public class TrainAppTaskCourseResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "课程ID", example = "1")
+    private Long courseId;
+
+    @Schema(description = "课程名称")
+    private String courseName;
+
+    @Schema(description = "课程类型 1-图文课程 2-视频课程", example = "1")
+    private Integer courseType;
+
+    @Schema(description = "课程类型名称", example = "图文课程")
+    private String courseTypeName;
+
+    @Schema(description = "课程学习状态 0-待学习 1-已学习", example = "0")
+    private Integer learnStatus;
+}
+

+ 51 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainAppTaskResVo.java

@@ -0,0 +1,51 @@
+package com.sckw.system.model.vo.res;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Schema(description = "App培训任务列表项")
+public class TrainAppTaskResVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "培训计划ID", example = "1")
+    private Long trainId;
+
+    @Schema(description = "培训主题", example = "2026年安全培训")
+    private String trainName;
+
+    @Schema(description = "开始日期", example = "2026-06-01")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private LocalDate startDate;
+
+    @Schema(description = "结束日期", example = "2026-06-30")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private LocalDate endDate;
+
+    @Schema(description = "任务状态 1-未开始 2-进行中 3-已结束", example = "2")
+    private Integer status;
+
+    @Schema(description = "任务状态名称", example = "进行中")
+    private String statusName;
+
+    @Schema(description = "已学课程数", example = "1")
+    private Integer learnedCourse;
+
+    @Schema(description = "总课程数", example = "2")
+    private Integer sumCourse;
+
+    @Schema(description = "课程列表")
+    private List<TrainAppTaskCourseResVo> courses;
+
+    @Schema(description = "是否显示去考试按钮", example = "true")
+    private Boolean showExam;
+}
+

+ 0 - 2
sckw-modules/sckw-system/src/main/java/com/sckw/system/model/vo/res/TrainDetailResVo.java

@@ -57,8 +57,6 @@ public class TrainDetailResVo implements Serializable {
     @Schema(description = "考试信息")
     private Exam exam;
 
-    @Schema(description = "参与司机ID列表")
-    private List<Long> driverIds;
 
     @Schema(description = "培训进度汇总")
     private ProgressSummary progressSummary;

+ 4 - 49
sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsCourseService.java

@@ -24,10 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Objects;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 @Service
@@ -42,10 +40,6 @@ public class KwsCourseService {
                 .eq(TCourse::getStatus, 0)
                 .orderByDesc(TCourse::getCreateTime);
 
-        Set<Long> entIds = resolveAuthorizedEntIds();
-        if (!entIds.isEmpty()) {
-            wrapper.in(TCourse::getEntId, entIds);
-        }
 
         List<TCourse> courses = tCourseService.list(wrapper);
         return courses.stream().map(this::toManageItem).collect(Collectors.toList());
@@ -57,10 +51,6 @@ public class KwsCourseService {
         wrapper.eq(TCourse::getDelFlag, Global.UN_DELETED)
                 .orderByDesc(TCourse::getCreateTime);
 
-        Set<Long> entIds = resolveAuthorizedEntIds();
-        if (!entIds.isEmpty()) {
-            wrapper.in(TCourse::getEntId, entIds);
-        }
 
         if (StrUtil.isNotBlank(reqVo.getCourseName())) {
             wrapper.like(TCourse::getCourseName, reqVo.getCourseName().trim());
@@ -81,9 +71,7 @@ public class KwsCourseService {
         if (course == null || !Objects.equals(course.getDelFlag(), Global.UN_DELETED)) {
             throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "课程不存在");
         }
-        if (!isAuthorizedEnt(course.getEntId())) {
-            throw new SystemException(HttpStatus.AUTHORITY_NO_CODE, HttpStatus.ACCESS_FIAL);
-        }
+
         return toDetail(course);
     }
 
@@ -128,9 +116,7 @@ public class KwsCourseService {
         if (course == null || !Objects.equals(course.getDelFlag(), Global.UN_DELETED)) {
             throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "课程不存在");
         }
-        if (!isAuthorizedEnt(course.getEntId())) {
-            throw new SystemException(HttpStatus.AUTHORITY_NO_CODE, HttpStatus.ACCESS_FIAL);
-        }
+
 
         LocalDateTime now = LocalDateTime.now();
         Long userId = LoginUserHolder.getUserId();
@@ -166,9 +152,7 @@ public class KwsCourseService {
         if (course == null || !Objects.equals(course.getDelFlag(), Global.UN_DELETED)) {
             throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "课程不存在");
         }
-        if (!isAuthorizedEnt(course.getEntId())) {
-            throw new SystemException(HttpStatus.AUTHORITY_NO_CODE, HttpStatus.ACCESS_FIAL);
-        }
+
 
         LocalDateTime now = LocalDateTime.now();
         Long userId = LoginUserHolder.getUserId();
@@ -196,18 +180,12 @@ public class KwsCourseService {
             throw new SystemException(HttpStatus.PARAMETERS_MISSING_CODE, "ids不能为空");
         }
 
-        Set<Long> entIds = resolveAuthorizedEntIds();
-        if (!LoginUserHolder.isManager() && entIds.isEmpty()) {
-            throw new SystemException(HttpStatus.AUTHORITY_NO_CODE, HttpStatus.ACCESS_FIAL);
-        }
-
         LocalDateTime now = LocalDateTime.now();
         Long userId = LoginUserHolder.getUserId();
 
         boolean updated = tCourseService.lambdaUpdate()
                 .in(TCourse::getId, ids)
                 .eq(TCourse::getDelFlag, Global.UN_DELETED)
-                .in(!entIds.isEmpty(), TCourse::getEntId, entIds)
                 .set(TCourse::getDelFlag, Global.DELETED)
                 .set(TCourse::getUpdateBy, userId)
                 .set(TCourse::getUpdateTime, now)
@@ -293,7 +271,7 @@ public class KwsCourseService {
             if (StrUtil.isBlank(reqVo.getContent())) {
                 throw new SystemException(HttpStatus.PARAMETERS_MISSING_CODE, "图文课程内容不能为空");
             }
-        } else if (Objects.equals(reqVo.getCourseType(),2)) {
+        } else if (Objects.equals(reqVo.getCourseType(), 2)) {
             if (StrUtil.isBlank(reqVo.getFileUrl())) {
                 throw new SystemException(HttpStatus.PARAMETERS_MISSING_CODE, "视频课程文件不能为空");
             }
@@ -303,27 +281,4 @@ public class KwsCourseService {
         }
     }
 
-    private static Set<Long> resolveAuthorizedEntIds() {
-        if (LoginUserHolder.isManager()) {
-            return Collections.emptySet();
-        }
-        Set<Long> result = new LinkedHashSet<>();
-        if (LoginUserHolder.getEntId() != null) {
-            result.add(LoginUserHolder.getEntId());
-        }
-        result.addAll(LoginUserHolder.getAuthEntIdList());
-        result.addAll(LoginUserHolder.getChildEntList());
-        return result;
-    }
-
-    private static boolean isAuthorizedEnt(Long entId) {
-        if (LoginUserHolder.isManager()) {
-            return true;
-        }
-        if (entId == null) {
-            return false;
-        }
-        Set<Long> entIds = resolveAuthorizedEntIds();
-        return entIds.contains(entId);
-    }
 }

+ 839 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsTrainAppService.java

@@ -0,0 +1,839 @@
+package com.sckw.system.service;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sckw.core.exception.SystemException;
+import com.sckw.core.model.constant.Global;
+import com.sckw.core.web.constant.HttpStatus;
+import com.sckw.core.web.context.LoginUserHolder;
+import com.sckw.core.web.response.result.PageDataResult;
+import com.sckw.system.dao.KwsUserDao;
+import com.sckw.system.model.*;
+import com.sckw.system.model.pojo.CourseTypeEnum;
+import com.sckw.system.model.pojo.QuestionTypeEnum;
+import com.sckw.system.model.pojo.TrainStatusEnum;
+import com.sckw.system.model.vo.req.TrainAppExamSubmitReqVo;
+import com.sckw.system.model.vo.req.TrainAppListReqVo;
+import com.sckw.system.model.vo.req.TrainCourseStudyReqVo;
+import com.sckw.system.model.vo.res.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KwsTrainAppService {
+
+    private final TTrainService tTrainService;
+    private final TTrainRecordService tTrainRecordService;
+    private final TTrainCourseService tTrainCourseService;
+    private final TTrainExamService tTrainExamService;
+    private final TCourseService tCourseService;
+    private final TCourseRecordService tCourseRecordService;
+    private final TExamService tExamService;
+    private final TExamLogService tExamLogService;
+    private final TExamRecordService tExamRecordService;
+    private final TQuestionService tQuestionService;
+    private final TQuestionItemService tQuestionItemService;
+    private final KwsCourseService kwsCourseService;
+    private final KwsUserDao kwsUserDao;
+
+    private Long getDriverId() {
+        KwsUser kwsUser = kwsUserDao.selectById(LoginUserHolder.getUserId());
+        if (Objects.nonNull(kwsUser)) {
+            return Long.parseLong(kwsUser.getDriverId());
+        }
+        return null;
+    }
+
+    public TrainAppListResVo listTrainTasks(TrainAppListReqVo reqVo) {
+        Long driverId = getDriverId();
+        if (Objects.isNull(driverId)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "未找到司机id");
+        }
+        LambdaQueryWrapper<TTrainRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(TTrainRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TTrainRecord::getDriverId, driverId)
+                .orderByDesc(TTrainRecord::getCreateTime);
+        if (reqVo.getStatus() != null && reqVo.getStatus() > 0) {
+            wrapper.eq(TTrainRecord::getStatus, reqVo.getStatus());
+        }
+
+        Page<TTrainRecord> page = new Page<>(reqVo.getPageNum(), reqVo.getPageSize());
+        Page<TTrainRecord> recordPage = tTrainRecordService.page(page, wrapper);
+        List<TTrainRecord> records = recordPage.getRecords();
+
+        Map<Long, TTrain> trainMap = loadTrainMap(records.stream().map(TTrainRecord::getTrainId).toList());
+        Map<Long, List<Long>> trainCourseIdsMap = loadTrainCourseIdsMap(trainMap.keySet());
+        Map<Long, TCourse> courseMap = loadCourseMap(trainCourseIdsMap.values().stream().flatMap(List::stream).distinct().toList());
+        Map<String, Integer> learnStatusMap = loadLearnStatusMap(driverId, trainMap.keySet());
+
+        List<TrainAppTaskResVo> taskVos = records.stream().map(r -> toTask(r, trainMap.get(r.getTrainId()), trainCourseIdsMap.getOrDefault(r.getTrainId(), Collections.emptyList()), courseMap, learnStatusMap)).toList();
+
+        TrainAppListResVo resVo = new TrainAppListResVo();
+        resVo.setProgressRate(calcOverallProgressRate(driverId));
+        resVo.setTotalTask((int) recordPage.getTotal());
+        resVo.setPage(PageDataResult.of(page, taskVos));
+        return resVo;
+    }
+
+    public TrainAppDetailResVo trainDetail(Long trainId) {
+        Long driverId = getDriverId();
+
+        TTrainRecord trainRecord = getDriverTrainRecord(trainId, driverId);
+        TTrain train = tTrainService.getById(trainId);
+        if (train == null || !Objects.equals(train.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "培训计划不存在");
+        }
+
+        List<Long> courseIds = tTrainCourseService.lambdaQuery()
+                .select(TTrainCourse::getCourseId)
+                .eq(TTrainCourse::getTrainId, trainId)
+                .list()
+                .stream()
+                .map(TTrainCourse::getCourseId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .toList();
+
+        Map<Long, TCourse> courseMap = loadCourseMap(courseIds);
+        Map<String, Integer> learnStatusMap = loadLearnStatusMap(driverId, Set.of(trainId));
+        List<TrainAppTaskCourseResVo> courses = buildCourseList(courseIds, courseMap, learnStatusMap, trainId);
+
+        TrainAppDetailResVo res = new TrainAppDetailResVo();
+        res.setTrainId(trainId);
+        res.setTrainName(train.getName());
+        res.setStartDate(train.getStartDate());
+        res.setEndDate(train.getEndDate());
+        res.setStatus(trainRecord.getStatus());
+        res.setStatusName(TrainStatusEnum.getNameByCode(trainRecord.getStatus()));
+        res.setLearnedCourse(defaultInt(trainRecord.getFinishCourse()));
+        res.setSumCourse(defaultInt(trainRecord.getSumCourse()));
+        res.setCourses(courses);
+
+        TrainAppDetailResVo.Exam exam = resolveTrainExam(trainId);
+        res.setExam(exam);
+
+        boolean allCourseDone = Objects.equals(res.getLearnedCourse(), res.getSumCourse()) && res.getSumCourse() > 0;
+        res.setShowExam(Objects.equals(train.getExamFlag(),1) && allCourseDone && exam != null);
+
+        if (Objects.equals(train.getExamFlag(),1) && exam != null) {
+            boolean passed = checkExamPassed(trainId, exam.getExamId(), driverId, exam.getPassRate());
+            res.setExamPassed(passed);
+        } else {
+            res.setExamPassed(false);
+        }
+
+        return res;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean startCourse(TrainCourseStudyReqVo reqVo) {
+        Long driverId = getDriverId();
+        ensureCourseInTrain(reqVo.getTrainId(), reqVo.getCourseId());
+
+        TTrainRecord trainRecord = getDriverTrainRecord(reqVo.getTrainId(), driverId);
+        if (Objects.equals(trainRecord.getStatus(), 3)) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "培训任务已结束");
+        }
+
+        TCourseRecord record = tCourseRecordService.lambdaQuery()
+                .eq(TCourseRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TCourseRecord::getTrainId, reqVo.getTrainId())
+                .eq(TCourseRecord::getCourseId, reqVo.getCourseId())
+                .eq(TCourseRecord::getDriverId, driverId)
+                .last("limit 1")
+                .one();
+
+        LocalDateTime now = LocalDateTime.now();
+        Long userId = LoginUserHolder.getUserId();
+
+        if (record == null) {
+            record = new TCourseRecord();
+            record.setTrainId(reqVo.getTrainId());
+            record.setCourseId(reqVo.getCourseId());
+            record.setDriverId(driverId);
+            record.setStartTime(now);
+            record.setEndTime(null);
+            record.setStatus(0);
+            record.setCreateBy(userId);
+            record.setCreateTime(now);
+            record.setUpdateBy(userId);
+            record.setUpdateTime(now);
+            record.setDelFlag(Global.UN_DELETED);
+            if (!tCourseRecordService.save(record)) {
+                throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "开始学习失败");
+            }
+        } else if (Objects.equals(record.getStatus(), 1)) {
+            return true;
+        } else {
+            boolean updated = tCourseRecordService.lambdaUpdate()
+                    .eq(TCourseRecord::getId, record.getId())
+                    .set(TCourseRecord::getStartTime, record.getStartTime() == null ? now : record.getStartTime())
+                    .set(TCourseRecord::getUpdateBy, userId)
+                    .set(TCourseRecord::getUpdateTime, now)
+                    .update();
+            if (!updated) {
+                throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "开始学习失败");
+            }
+        }
+
+        updateTrainRecordStatusOnLearning(reqVo.getTrainId(), driverId);
+        return true;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean finishCourse(TrainCourseStudyReqVo reqVo) {
+        Long driverId = getDriverId();
+//        ensureDriverInTrain(reqVo.getTrainId(), driverId);
+        ensureCourseInTrain(reqVo.getTrainId(), reqVo.getCourseId());
+
+        LocalDateTime now = LocalDateTime.now();
+        Long userId = LoginUserHolder.getUserId();
+
+        TCourseRecord record = tCourseRecordService.lambdaQuery()
+                .eq(TCourseRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TCourseRecord::getTrainId, reqVo.getTrainId())
+                .eq(TCourseRecord::getCourseId, reqVo.getCourseId())
+                .eq(TCourseRecord::getDriverId, driverId)
+                .last("limit 1")
+                .one();
+
+        if (record == null) {
+            record = new TCourseRecord();
+            record.setTrainId(reqVo.getTrainId());
+            record.setCourseId(reqVo.getCourseId());
+            record.setDriverId(driverId);
+            record.setStartTime(now);
+            record.setEndTime(now);
+            record.setStatus(1);
+            record.setCreateBy(userId);
+            record.setCreateTime(now);
+            record.setUpdateBy(userId);
+            record.setUpdateTime(now);
+            record.setDelFlag(Global.UN_DELETED);
+            if (!tCourseRecordService.save(record)) {
+                throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "完成课程失败");
+            }
+        } else if (!Objects.equals(record.getStatus(), 1)) {
+            boolean updated = tCourseRecordService.lambdaUpdate()
+                    .eq(TCourseRecord::getId, record.getId())
+                    .set(TCourseRecord::getStatus, 1)
+                    .set(TCourseRecord::getEndTime, now)
+                    .set(TCourseRecord::getUpdateBy, userId)
+                    .set(TCourseRecord::getUpdateTime, now)
+                    .update();
+            if (!updated) {
+                throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "完成课程失败");
+            }
+        }
+
+        syncTrainProgress(reqVo.getTrainId(), driverId, now, userId);
+        return true;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public TrainAppExamStartResVo startExam(Long trainId) {
+        Long driverId = getDriverId();
+//        ensureDriverInTrain(trainId, driverId);
+
+        TTrain train = tTrainService.getById(trainId);
+        if (train == null || !Objects.equals(train.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "培训计划不存在");
+        }
+        if (!Objects.equals(train.getExamFlag(), 1)) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "当前培训无需考试");
+        }
+
+        TTrainRecord trainRecord = getDriverTrainRecord(trainId, driverId);
+        if (!Objects.equals(trainRecord.getFinishCourse(), trainRecord.getSumCourse())) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "请先完成全部课程");
+        }
+
+        Long examId = resolveTrainExamId(trainId);
+        if (examId == null) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "培训考试未配置");
+        }
+
+        TExam exam = tExamService.getById(examId);
+        if (exam == null || !Objects.equals(exam.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "考试不存在");
+        }
+
+        TExamLog existing = tExamLogService.lambdaQuery()
+                .eq(TExamLog::getDelFlag, Global.UN_DELETED)
+                .eq(TExamLog::getTrainId, trainId)
+                .eq(TExamLog::getExamId, examId)
+                .eq(TExamLog::getDriverId, driverId)
+                .eq(TExamLog::getExamFlag, 1)
+                .orderByDesc(TExamLog::getId)
+                .last("limit 1")
+                .one();
+
+        LocalDateTime now = LocalDateTime.now();
+        Long userId = LoginUserHolder.getUserId();
+
+        TExamLog log = existing;
+        if (log == null) {
+            log = new TExamLog();
+            log.setTrainId(trainId);
+            log.setExamId(examId);
+            log.setDriverId(driverId);
+            log.setExamFlag(1);
+            log.setStartTime(now);
+            log.setEndTime(null);
+            log.setRightAmount(0);
+            log.setAmount(exam.getAmount());
+            log.setRightRate(0);
+            log.setCreateBy(userId);
+            log.setCreateTime(now);
+            log.setUpdateBy(userId);
+            log.setUpdateTime(now);
+            log.setDelFlag(Global.UN_DELETED);
+            if (!tExamLogService.save(log)) {
+                throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "开始考试失败");
+            }
+        }
+
+        TrainAppExamStartResVo res = new TrainAppExamStartResVo();
+        res.setLogId(log.getId());
+        res.setExamId(examId);
+        res.setExamName(exam.getName());
+        res.setExamDuration(exam.getExamDuration());
+        res.setPassRate(exam.getPassRate());
+        res.setAmount(exam.getAmount());
+        res.setStartTime(log.getStartTime());
+        return res;
+    }
+
+    public TrainAppExamPaperResVo examPaper(Long trainId, Long logId) {
+        Long driverId = getDriverId();
+//        ensureDriverInTrain(trainId, driverId);
+
+        Long examId = resolveTrainExamId(trainId);
+        if (examId == null) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "培训考试未配置");
+        }
+        TExam exam = tExamService.getById(examId);
+        if (exam == null || !Objects.equals(exam.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "考试不存在");
+        }
+
+        Long resolvedLogId = logId;
+        if (resolvedLogId == null) {
+            TExamLog newest = tExamLogService.lambdaQuery()
+                    .eq(TExamLog::getDelFlag, Global.UN_DELETED)
+                    .eq(TExamLog::getTrainId, trainId)
+                    .eq(TExamLog::getExamId, examId)
+                    .eq(TExamLog::getDriverId, driverId)
+                    .orderByDesc(TExamLog::getId)
+                    .last("limit 1")
+                    .one();
+            resolvedLogId = newest == null ? null : newest.getId();
+        }
+
+        List<TQuestion> questions = tQuestionService.lambdaQuery()
+                .eq(TQuestion::getDelFlag, Global.UN_DELETED)
+                .eq(TQuestion::getExamId, examId)
+                .orderByAsc(TQuestion::getId)
+                .list();
+
+        Map<Long, List<TQuestionItem>> optionMap;
+        if (CollUtil.isNotEmpty(questions)) {
+            List<Long> qIds = questions.stream().map(TQuestion::getId).filter(Objects::nonNull).toList();
+            List<TQuestionItem> items = tQuestionItemService.lambdaQuery()
+                    .eq(TQuestionItem::getDelFlag, Global.UN_DELETED)
+                    .in(TQuestionItem::getQuestionId, qIds)
+                    .orderByAsc(TQuestionItem::getSort)
+                    .orderByAsc(TQuestionItem::getId)
+                    .list();
+            optionMap = items.stream().collect(Collectors.groupingBy(TQuestionItem::getQuestionId));
+        } else {
+            optionMap = Collections.emptyMap();
+        }
+
+        List<TrainAppExamPaperResVo.Question> qVos = questions.stream().map(q -> {
+            TrainAppExamPaperResVo.Question qVo = new TrainAppExamPaperResVo.Question();
+            qVo.setQuestionId(q.getId());
+            qVo.setName(q.getName());
+            qVo.setType(q.getType());
+            qVo.setTypeName(QuestionTypeEnum.getNameByCode(q.getType()));
+            List<TQuestionItem> items = optionMap.getOrDefault(q.getId(), Collections.emptyList());
+            List<TrainAppExamPaperResVo.Option> opts = items.stream().map(item -> {
+                TrainAppExamPaperResVo.Option opt = new TrainAppExamPaperResVo.Option();
+                opt.setName(item.getName());
+                opt.setContent(item.getContent());
+                return opt;
+            }).toList();
+            qVo.setOptions(opts);
+            return qVo;
+        }).toList();
+
+        TrainAppExamPaperResVo res = new TrainAppExamPaperResVo();
+        res.setLogId(resolvedLogId);
+        res.setExamId(examId);
+        res.setExamName(exam.getName());
+        res.setExamDuration(exam.getExamDuration());
+        res.setPassRate(exam.getPassRate());
+        res.setAmount(exam.getAmount());
+        res.setQuestions(qVos);
+        return res;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public TrainAppExamSubmitResVo submitExam(TrainAppExamSubmitReqVo reqVo) {
+        Long driverId = getDriverId();
+
+        TExamLog log = tExamLogService.getById(reqVo.getLogId());
+        if (log == null || !Objects.equals(log.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "考试日志不存在");
+        }
+        if (!Objects.equals(log.getDriverId(), driverId)) {
+            throw new SystemException(HttpStatus.AUTHORITY_NO_CODE, HttpStatus.ACCESS_FIAL);
+        }
+        if (!Objects.equals(log.getExamFlag(), 1)) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "考试已结束");
+        }
+
+        Long trainId = log.getTrainId();
+        Long examId = log.getExamId();
+//        ensureDriverInTrain(trainId, driverId);
+
+        TExam exam = tExamService.getById(examId);
+        if (exam == null || !Objects.equals(exam.getDelFlag(), Global.UN_DELETED)) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "考试不存在");
+        }
+
+        List<TQuestion> questions = tQuestionService.lambdaQuery()
+                .eq(TQuestion::getDelFlag, Global.UN_DELETED)
+                .eq(TQuestion::getExamId, examId)
+                .list();
+        Map<Long, Integer> questionTypeMap = questions.stream().filter(q -> q.getId() != null).collect(Collectors.toMap(TQuestion::getId, TQuestion::getType, (a, b) -> a));
+
+        List<Long> qIds = questions.stream().map(TQuestion::getId).filter(Objects::nonNull).toList();
+        Map<Long, Set<String>> correctAnswerMap = buildCorrectAnswerMap(qIds);
+
+        Map<Long, String> userAnswerMap = reqVo.getAnswers().stream()
+                .filter(a -> a != null && a.getQuestionId() != null)
+                .collect(Collectors.toMap(TrainAppExamSubmitReqVo.AnswerItem::getQuestionId, a -> normalizeAnswer(a.getAnswer()), (a, b) -> a));
+
+        tExamRecordService.lambdaUpdate()
+                .eq(TExamRecord::getLogId, log.getId())
+                .eq(TExamRecord::getDelFlag, Global.UN_DELETED)
+                .set(TExamRecord::getDelFlag, Global.DELETED)
+                .update();
+
+        LocalDateTime now = LocalDateTime.now();
+        Long userId = LoginUserHolder.getUserId();
+
+        List<TExamRecord> recordEntities = new ArrayList<>();
+        int rightAmount = 0;
+        for (Long questionId : qIds) {
+            Integer type = questionTypeMap.get(questionId);
+            Set<String> correct = correctAnswerMap.getOrDefault(questionId, Collections.emptySet());
+            String userAns = userAnswerMap.getOrDefault(questionId, "");
+            int correctFlag = judgeCorrectFlag(type, userAns, correct);
+            if (correctFlag == 1) {
+                rightAmount++;
+            }
+
+            TExamRecord record = new TExamRecord();
+            record.setLogId(log.getId());
+            record.setQuestionId(questionId);
+            record.setAnswer(userAns);
+            record.setCorrectFlag(correctFlag);
+            record.setCreateBy(userId);
+            record.setCreateTime(now);
+            record.setUpdateBy(userId);
+            record.setUpdateTime(now);
+            record.setDelFlag(Global.UN_DELETED);
+            recordEntities.add(record);
+        }
+
+        if (!recordEntities.isEmpty() && !tExamRecordService.saveBatch(recordEntities)) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "保存考试记录失败");
+        }
+
+        int amount = exam.getAmount() == null ? qIds.size() : exam.getAmount();
+        int rightRate = amount == 0 ? 0 : (int) Math.round(rightAmount * 100.0 / amount);
+        boolean passed = rightRate >= (exam.getPassRate() == null ? 0 : exam.getPassRate());
+
+        boolean updated = tExamLogService.lambdaUpdate()
+                .eq(TExamLog::getId, log.getId())
+                .set(TExamLog::getExamFlag, 2)
+                .set(TExamLog::getEndTime, now)
+                .set(TExamLog::getRightAmount, rightAmount)
+                .set(TExamLog::getAmount, amount)
+                .set(TExamLog::getRightRate, rightRate)
+                .set(TExamLog::getUpdateBy, userId)
+                .set(TExamLog::getUpdateTime, now)
+                .update();
+        if (!updated) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "更新考试日志失败");
+        }
+
+        syncTrainExamResult(trainId, driverId, rightRate, passed, now, userId);
+
+        TrainAppExamSubmitResVo resVo = new TrainAppExamSubmitResVo();
+        resVo.setRightAmount(rightAmount);
+        resVo.setAmount(amount);
+        resVo.setRightRate(rightRate);
+        resVo.setPassed(passed);
+        return resVo;
+    }
+
+    private Map<Long, TTrain> loadTrainMap(List<Long> trainIds) {
+        if (CollUtil.isEmpty(trainIds)) {
+            return Collections.emptyMap();
+        }
+        List<TTrain> trains = tTrainService.listByIds(trainIds.stream().filter(Objects::nonNull).distinct().toList());
+        return trains.stream()
+                .filter(t -> t != null && Objects.equals(t.getDelFlag(), Global.UN_DELETED))
+                .collect(Collectors.toMap(TTrain::getId, t -> t, (a, b) -> a));
+    }
+
+    private Map<Long, List<Long>> loadTrainCourseIdsMap(Set<Long> trainIds) {
+        if (CollUtil.isEmpty(trainIds)) {
+            return Collections.emptyMap();
+        }
+        List<TTrainCourse> relations = tTrainCourseService.lambdaQuery()
+                .in(TTrainCourse::getTrainId, trainIds)
+                .list();
+        Map<Long, List<Long>> result = new LinkedHashMap<>();
+        for (TTrainCourse r : relations) {
+            if (r.getTrainId() == null || r.getCourseId() == null) {
+                continue;
+            }
+            result.computeIfAbsent(r.getTrainId(), k -> new ArrayList<>()).add(r.getCourseId());
+        }
+        result.values().forEach(list -> {
+            List<Long> distinct = list.stream().distinct().toList();
+            list.clear();
+            list.addAll(distinct);
+        });
+        return result;
+    }
+
+    private Map<Long, TCourse> loadCourseMap(List<Long> courseIds) {
+        if (CollUtil.isEmpty(courseIds)) {
+            return Collections.emptyMap();
+        }
+        List<TCourse> courses = tCourseService.listByIds(courseIds.stream().filter(Objects::nonNull).distinct().toList());
+        return courses.stream()
+                .filter(c -> c != null && Objects.equals(c.getDelFlag(), Global.UN_DELETED))
+                .collect(Collectors.toMap(TCourse::getId, c -> c, (a, b) -> a));
+    }
+
+    private Map<String, Integer> loadLearnStatusMap(Long driverId, Set<Long> trainIds) {
+        if (CollUtil.isEmpty(trainIds)) {
+            return Collections.emptyMap();
+        }
+        List<TCourseRecord> records = tCourseRecordService.lambdaQuery()
+                .select(TCourseRecord::getTrainId, TCourseRecord::getCourseId, TCourseRecord::getStatus)
+                .eq(TCourseRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TCourseRecord::getDriverId, driverId)
+                .in(TCourseRecord::getTrainId, trainIds)
+                .list();
+        Map<String, Integer> map = new HashMap<>();
+        for (TCourseRecord r : records) {
+            if (r.getTrainId() == null || r.getCourseId() == null) {
+                continue;
+            }
+            String key = r.getTrainId() + "_" + r.getCourseId();
+            map.put(key, r.getStatus());
+        }
+        return map;
+    }
+
+    private List<TrainAppTaskCourseResVo> buildCourseList(List<Long> courseIds, Map<Long, TCourse> courseMap, Map<String, Integer> learnStatusMap, Long trainId) {
+        if (CollUtil.isEmpty(courseIds)) {
+            return Collections.emptyList();
+        }
+        List<TrainAppTaskCourseResVo> list = new ArrayList<>();
+        for (Long courseId : courseIds) {
+            TCourse course = courseMap.get(courseId);
+            if (course == null) {
+                continue;
+            }
+            TrainAppTaskCourseResVo vo = new TrainAppTaskCourseResVo();
+            vo.setCourseId(courseId);
+            vo.setCourseName(course.getCourseName());
+            vo.setCourseType(course.getCourseType());
+            vo.setCourseTypeName(CourseTypeEnum.getNameByCode(course.getCourseType()));
+            Integer status = learnStatusMap.get(trainId + "_" + courseId);
+            vo.setLearnStatus(Objects.equals(status, 1) ? 1 : 0);
+            list.add(vo);
+        }
+        return list;
+    }
+
+    private TrainAppTaskResVo toTask(TTrainRecord record, TTrain train, List<Long> courseIds, Map<Long, TCourse> courseMap, Map<String, Integer> learnStatusMap) {
+        TrainAppTaskResVo vo = new TrainAppTaskResVo();
+        vo.setTrainId(record.getTrainId());
+        vo.setStatus(record.getStatus());
+        vo.setStatusName(TrainStatusEnum.getNameByCode(record.getStatus()));
+        vo.setLearnedCourse(defaultInt(record.getFinishCourse()));
+        vo.setSumCourse(defaultInt(record.getSumCourse()));
+
+        if (train != null) {
+            vo.setTrainName(train.getName());
+            vo.setStartDate(train.getStartDate());
+            vo.setEndDate(train.getEndDate());
+        }
+
+        vo.setCourses(buildCourseList(courseIds, courseMap, learnStatusMap, record.getTrainId()));
+        boolean allCourseDone = Objects.equals(vo.getLearnedCourse(), vo.getSumCourse()) && vo.getSumCourse() > 0;
+        vo.setShowExam(Boolean.TRUE.equals(train != null ? train.getExamFlag() : null) && allCourseDone);
+        return vo;
+    }
+
+    private Integer calcOverallProgressRate(Long driverId) {
+        List<TTrainRecord> records = tTrainRecordService.lambdaQuery()
+                .select(TTrainRecord::getFinishCourse, TTrainRecord::getSumCourse)
+                .eq(TTrainRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TTrainRecord::getDriverId, driverId)
+                .list();
+        int learned = records.stream().mapToInt(r -> defaultInt(r.getFinishCourse())).sum();
+        int sum = records.stream().mapToInt(r -> defaultInt(r.getSumCourse())).sum();
+        if (sum == 0) {
+            return 0;
+        }
+        return (int) Math.round(learned * 100.0 / sum);
+    }
+
+    private static int defaultInt(Integer value) {
+        return value == null ? 0 : value;
+    }
+
+
+    private void ensureCourseInTrain(Long trainId, Long courseId) {
+        boolean exists = tTrainCourseService.lambdaQuery()
+                .eq(TTrainCourse::getTrainId, trainId)
+                .eq(TTrainCourse::getCourseId, courseId)
+                .exists();
+        if (!exists) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "课程不属于该培训");
+        }
+    }
+
+    private TTrainRecord getDriverTrainRecord(Long trainId, Long driverId) {
+        TTrainRecord record = tTrainRecordService.lambdaQuery()
+                .eq(TTrainRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TTrainRecord::getTrainId, trainId)
+                .eq(TTrainRecord::getDriverId, driverId)
+                .last("limit 1")
+                .one();
+        if (record == null) {
+            throw new SystemException(HttpStatus.QUERY_FAIL_CODE, "培训任务不存在");
+        }
+        return record;
+    }
+
+    private void updateTrainRecordStatusOnLearning(Long trainId, Long driverId) {
+        TTrainRecord record = getDriverTrainRecord(trainId, driverId);
+        if (Objects.equals(record.getStatus(), 1)) {
+            LocalDateTime now = LocalDateTime.now();
+            Long userId = LoginUserHolder.getUserId();
+            tTrainRecordService.lambdaUpdate()
+                    .eq(TTrainRecord::getId, record.getId())
+                    .set(TTrainRecord::getStatus, 2)
+                    .set(TTrainRecord::getUpdateBy, userId)
+                    .set(TTrainRecord::getUpdateTime, now)
+                    .update();
+        }
+    }
+
+    private void syncTrainProgress(Long trainId, Long driverId, LocalDateTime now, Long userId) {
+        TTrainRecord trainRecord = getDriverTrainRecord(trainId, driverId);
+        int sumCourse = defaultInt(trainRecord.getSumCourse());
+        int learned = countLearnedCourse(trainId, driverId);
+        Integer newStatus = trainRecord.getStatus();
+        if (learned > 0 && Objects.equals(newStatus, 1)) {
+            newStatus = 2;
+        }
+
+        TTrain train = tTrainService.getById(trainId);
+        boolean hasExam = train != null && Objects.equals(train.getExamFlag(), 1);
+        if (sumCourse > 0 && learned >= sumCourse) {
+            if (!hasExam) {
+                newStatus = 3;
+            } else {
+                newStatus = 2;
+            }
+        }
+
+        boolean updated = tTrainRecordService.lambdaUpdate()
+                .eq(TTrainRecord::getId, trainRecord.getId())
+                .set(TTrainRecord::getFinishCourse, learned)
+                .set(TTrainRecord::getStatus, newStatus)
+                .set(TTrainRecord::getUpdateBy, userId)
+                .set(TTrainRecord::getUpdateTime, now)
+                .update();
+        if (!updated) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "更新培训记录失败");
+        }
+    }
+
+    private int countLearnedCourse(Long trainId, Long driverId) {
+        Long count = tCourseRecordService.lambdaQuery()
+                .eq(TCourseRecord::getDelFlag, Global.UN_DELETED)
+                .eq(TCourseRecord::getTrainId, trainId)
+                .eq(TCourseRecord::getDriverId, driverId)
+                .eq(TCourseRecord::getStatus, 1)
+                .count();
+        return count == null ? 0 : count.intValue();
+    }
+
+    private Long resolveTrainExamId(Long trainId) {
+        return tTrainExamService.lambdaQuery()
+                .select(TTrainExam::getExamId)
+                .eq(TTrainExam::getTrainId, trainId)
+                .orderByDesc(TTrainExam::getId)
+                .last("limit 1")
+                .oneOpt()
+                .map(TTrainExam::getExamId)
+                .orElse(null);
+    }
+
+    private TrainAppDetailResVo.Exam resolveTrainExam(Long trainId) {
+        Long examId = resolveTrainExamId(trainId);
+        if (examId == null) {
+            return null;
+        }
+        TExam exam = tExamService.getById(examId);
+        if (exam == null || !Objects.equals(exam.getDelFlag(), Global.UN_DELETED)) {
+            return null;
+        }
+        TrainAppDetailResVo.Exam vo = new TrainAppDetailResVo.Exam();
+        vo.setExamId(examId);
+        vo.setName(exam.getName());
+        vo.setExamDuration(exam.getExamDuration());
+        vo.setPassRate(exam.getPassRate());
+        vo.setAmount(exam.getAmount());
+        return vo;
+    }
+
+    private boolean checkExamPassed(Long trainId, Long examId, Long driverId, Integer passRate) {
+        TExamLog newest = tExamLogService.lambdaQuery()
+                .eq(TExamLog::getDelFlag, Global.UN_DELETED)
+                .eq(TExamLog::getTrainId, trainId)
+                .eq(TExamLog::getExamId, examId)
+                .eq(TExamLog::getDriverId, driverId)
+                .eq(TExamLog::getExamFlag, 2)
+                .orderByDesc(TExamLog::getId)
+                .last("limit 1")
+                .one();
+        if (newest == null) {
+            return false;
+        }
+        Integer rate = newest.getRightRate();
+        if (rate == null) {
+            return false;
+        }
+        int pass = passRate == null ? 0 : passRate;
+        return rate >= pass;
+    }
+
+    private Map<Long, Set<String>> buildCorrectAnswerMap(List<Long> questionIds) {
+        if (CollUtil.isEmpty(questionIds)) {
+            return Collections.emptyMap();
+        }
+        List<TQuestionItem> items = tQuestionItemService.lambdaQuery()
+                .select(TQuestionItem::getQuestionId, TQuestionItem::getName, TQuestionItem::getAnswerFlag)
+                .eq(TQuestionItem::getDelFlag, Global.UN_DELETED)
+                .in(TQuestionItem::getQuestionId, questionIds)
+                .list();
+        Map<Long, Set<String>> map = new HashMap<>();
+        for (TQuestionItem item : items) {
+            if (item.getQuestionId() == null) {
+                continue;
+            }
+            if (!Objects.equals(item.getAnswerFlag(), 1)) {
+                continue;
+            }
+            if (StrUtil.isBlank(item.getName())) {
+                continue;
+            }
+            String opt = item.getName().trim().toUpperCase();
+            map.computeIfAbsent(item.getQuestionId(), k -> new LinkedHashSet<>()).add(opt);
+        }
+        return map;
+    }
+
+    private static String normalizeAnswer(String answer) {
+        if (answer == null) {
+            return "";
+        }
+        return Arrays.stream(answer.split(","))
+                .map(String::trim)
+                .filter(StrUtil::isNotBlank)
+                .map(s -> s.toUpperCase())
+                .distinct()
+                .collect(Collectors.joining(","));
+    }
+
+    private static int judgeCorrectFlag(Integer questionType, String userAnswer, Set<String> correct) {
+        if (questionType == null) {
+            return 2;
+        }
+        if (questionType == 3) {
+            return 2;
+        }
+
+        Set<String> userSet = Arrays.stream(userAnswer.split(","))
+                .map(String::trim)
+                .filter(StrUtil::isNotBlank)
+                .map(String::toUpperCase)
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+
+        if (questionType == 1) {
+            if (userSet.size() != 1 || correct.size() != 1) {
+                return 2;
+            }
+            return Objects.equals(userSet.iterator().next(), correct.iterator().next()) ? 1 : 2;
+        }
+
+        if (userSet.isEmpty()) {
+            return 2;
+        }
+        if (userSet.equals(correct)) {
+            return 1;
+        }
+        boolean subset = correct.containsAll(userSet);
+        return subset ? 3 : 2;
+    }
+
+    private void syncTrainExamResult(Long trainId, Long driverId, Integer rightRate, boolean passed, LocalDateTime now, Long userId) {
+        TTrainRecord record = getDriverTrainRecord(trainId, driverId);
+        Integer newStatus = record.getStatus();
+        if (passed && Objects.equals(record.getFinishCourse(), record.getSumCourse())) {
+            newStatus = 3;
+        } else if (Objects.equals(newStatus, 1)) {
+            newStatus = 2;
+        }
+
+        boolean updated = tTrainRecordService.lambdaUpdate()
+                .eq(TTrainRecord::getId, record.getId())
+                .set(TTrainRecord::getRightRate, rightRate)
+                .set(TTrainRecord::getStatus, newStatus)
+                .set(TTrainRecord::getUpdateBy, userId)
+                .set(TTrainRecord::getUpdateTime, now)
+                .update();
+        if (!updated) {
+            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "更新培训记录失败");
+        }
+    }
+
+    public CourseDetailResVo courseDetail(Long courseId) {
+        return kwsCourseService.manageCourseDetail(courseId);
+    }
+}
+

+ 5 - 39
sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsTrainService.java

@@ -9,13 +9,7 @@ import com.sckw.core.model.constant.Global;
 import com.sckw.core.web.constant.HttpStatus;
 import com.sckw.core.web.context.LoginUserHolder;
 import com.sckw.core.web.response.result.PageDataResult;
-import com.sckw.system.model.TCourse;
-import com.sckw.system.model.TExam;
-import com.sckw.system.model.TTrain;
-import com.sckw.system.model.TTrainCourse;
-import com.sckw.system.model.TTrainExam;
-import com.sckw.system.model.TTrainRecord;
-import com.sckw.system.model.TTrainUser;
+import com.sckw.system.model.*;
 import com.sckw.system.model.pojo.CourseTypeEnum;
 import com.sckw.system.model.pojo.TrainStatusEnum;
 import com.sckw.system.model.vo.req.IdReqVo;
@@ -29,13 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
+import java.util.*;
 import java.util.stream.Collectors;
 
 @Service
@@ -45,7 +33,6 @@ public class KwsTrainService {
     private final TTrainService tTrainService;
     private final TTrainCourseService tTrainCourseService;
     private final TTrainExamService tTrainExamService;
-    private final TTrainUserService tTrainUserService;
     private final TTrainRecordService tTrainRecordService;
     private final TCourseService tCourseService;
     private final TExamService tExamService;
@@ -109,16 +96,6 @@ public class KwsTrainService {
             }
         }
 
-        List<Long> driverIds = tTrainUserService.lambdaQuery()
-                .select(TTrainUser::getDriverId)
-                .eq(TTrainUser::getTrainId, trainId)
-                .list()
-                .stream()
-                .map(TTrainUser::getDriverId)
-                .filter(Objects::nonNull)
-                .distinct()
-                .toList();
-        detail.setDriverIds(driverIds);
 
         List<TTrainRecord> records = tTrainRecordService.lambdaQuery()
                 .eq(TTrainRecord::getDelFlag, Global.UN_DELETED)
@@ -228,7 +205,6 @@ public class KwsTrainService {
 
         tTrainCourseService.lambdaUpdate().in(TTrainCourse::getTrainId, trainIds).remove();
         tTrainExamService.lambdaUpdate().in(TTrainExam::getTrainId, trainIds).remove();
-        tTrainUserService.lambdaUpdate().in(TTrainUser::getTrainId, trainIds).remove();
 
         tTrainRecordService.lambdaUpdate()
                 .in(TTrainRecord::getTrainId, trainIds)
@@ -246,12 +222,12 @@ public class KwsTrainService {
             return Collections.emptyMap();
         }
         List<Long> trainIds = trains.stream().map(TTrain::getId).filter(Objects::nonNull).toList();
-        List<TTrainUser> trainUsers = tTrainUserService.lambdaQuery()
-                .in(TTrainUser::getTrainId, trainIds)
+        List<TTrainRecord> trainUsers = tTrainRecordService.lambdaQuery()
+                .in(TTrainRecord::getTrainId, trainIds)
                 .list();
         return trainUsers.stream()
                 .filter(u -> u.getTrainId() != null)
-                .collect(Collectors.groupingBy(TTrainUser::getTrainId, Collectors.counting()));
+                .collect(Collectors.groupingBy(TTrainRecord::getTrainId, Collectors.counting()));
     }
 
     private TrainManageItemResVo toManageItem(TTrain train, long driverCount) {
@@ -340,15 +316,6 @@ public class KwsTrainService {
             throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "保存培训课程失败");
         }
 
-        List<TTrainUser> trainUsers = driverIds.stream().distinct().map(driverId -> {
-            TTrainUser tu = new TTrainUser();
-            tu.setTrainId(trainId);
-            tu.setDriverId(driverId);
-            return tu;
-        }).toList();
-        if (!trainUsers.isEmpty() && !tTrainUserService.saveBatch(trainUsers)) {
-            throw new SystemException(HttpStatus.CRUD_FAIL_CODE, "保存培训司机失败");
-        }
 
         if (Objects.equals(examFlag, 1)) {
             TTrainExam te = new TTrainExam();
@@ -363,7 +330,6 @@ public class KwsTrainService {
     private void deleteRelations(Long trainId) {
         tTrainCourseService.lambdaUpdate().eq(TTrainCourse::getTrainId, trainId).remove();
         tTrainExamService.lambdaUpdate().eq(TTrainExam::getTrainId, trainId).remove();
-        tTrainUserService.lambdaUpdate().eq(TTrainUser::getTrainId, trainId).remove();
     }
 
     private void saveTrainRecords(Long entId, Long trainId, List<Long> driverIds, int sumCourse, Long userId, LocalDateTime now) {

+ 0 - 16
sckw-modules/sckw-system/src/main/java/com/sckw/system/service/TTrainUserService.java

@@ -1,16 +0,0 @@
-package com.sckw.system.service;
-
-import org.springframework.stereotype.Service;
-import org.springframework.beans.factory.annotation.Autowired;
-import java.util.List;
-import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.sckw.system.model.TTrainUser;
-import com.sckw.system.dao.TTrainUserMapper;
-/**
-* @date 2026-06-09 10:50:04
-* @author xucaiqin
-*/
-@Service
-public class TTrainUserService extends ServiceImpl<TTrainUserMapper, TTrainUser> {
-
-}

+ 0 - 15
sckw-modules/sckw-system/src/main/resources/mapper/TTrainUserMapper.xml

@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-<mapper namespace="com.sckw.system.dao.TTrainUserMapper">
-  <resultMap id="BaseResultMap" type="com.sckw.system.model.TTrainUser">
-    <!--@mbg.generated-->
-    <!--@Table t_train_user-->
-    <id column="id" jdbcType="BIGINT" property="id" />
-    <result column="train_id" jdbcType="BIGINT" property="trainId" />
-    <result column="driver_id" jdbcType="BIGINT" property="driverId" />
-  </resultMap>
-  <sql id="Base_Column_List">
-    <!--@mbg.generated-->
-    id, train_id, driver_id
-  </sql>
-</mapper>