chore(sync): 同步当前工作区全部变更(学员学号可编辑并留空自动生成等)

This commit is contained in:
buliping committed 2026-11-10 13:34:14 +08:00
1 parent 01e949093a
commit f5e8d7f564
531 files changed
+72978 -2720

No files matched your search

+11
View File
@@ -61,6 +61,12 @@
<version>3.9.2</version>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-device</artifactId>
<version>3.9.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -79,6 +85,11 @@
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
@@ -0,0 +1,57 @@
package com.ruoyi.web.controller.api;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 开放接口统一响应(code 0 表示成功,与《外部实训系统数据接口规范》一致)
*
* <p>注意:不能复用 {@link com.ruoyi.common.core.domain.AjaxResult},其成功码为 200。</p>
*
* @author ruoyi
* @date 2026-11-05
*/
public class OpenApiResult extends LinkedHashMap<String, Object>
{
private static final long serialVersionUID = 1L;
/** 成功 */
public static final int SUCCESS = 0;
/** 服务端内部错误 */
public static final int INTERNAL_ERROR = 4000;
public static OpenApiResult success(Object data, String reqId)
{
OpenApiResult result = new OpenApiResult();
result.put("code", SUCCESS);
result.put("msg", "success");
result.put("data", data);
result.put("reqId", reqId);
result.put("ts", System.currentTimeMillis());
return result;
}
public static OpenApiResult error(int code, String msg, String reqId)
{
OpenApiResult result = new OpenApiResult();
result.put("code", code);
result.put("msg", msg);
result.put("data", null);
result.put("reqId", reqId);
result.put("ts", System.currentTimeMillis());
return result;
}
/** 分页数据包装:{ total, pageNo, pageSize, list } */
public static Map<String, Object> page(long total, int pageNo, int pageSize, Object list)
{
Map<String, Object> data = new HashMap<>(4);
data.put("total", total);
data.put("pageNo", pageNo);
data.put("pageSize", pageSize);
data.put("list", list);
return data;
}
}
@@ -0,0 +1,253 @@
package com.ruoyi.web.controller.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.vo.SignageClassroomVO;
import com.ruoyi.train.domain.vo.SignageCourseVO;
import com.ruoyi.train.domain.vo.SignageSeatVO;
import com.ruoyi.train.domain.vo.SignageStudentCardVO;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainSignageMapper;
import com.ruoyi.train.service.AttendanceAppService;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.ITrainPlanCourseService;
import com.ruoyi.train.service.ITrainStudentCheckinService;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 电子班牌对外接口(班牌后台调用)。
*
* BasePath: /api/board/v1/signageService
* 安全约束:走 RuoYi Bearer 鉴权(教学数据 Token)。
* 说明:本控制器仅负责 HTTP 暴露;取数逻辑见 ruoyi-train 的 TrainSignageMapper(仅新增,未改动既有方法)。
* 文档:学员系统取数与安卓下发接口文档(/signageService/* 前四个外部接口)。
*
* 注:文档字段 id / classId / classroomId / courseId / teacherId / stuId / studentId
* 约定为 Long,但本系统对应表以业务编码为主键(字符串),故这些字段返回字符串;
* 若班牌后台严格要求 Long,可后续为相关表补充数值 id 列并在 Mapper 中映射。
*
* @author ruoyi
* @date 2026-10-26
*/
@RestController
@Tag(name = "电子班牌接口", description = "从服务器查询教室信息、指定教室当天课程、指定班级在指定教室的座位、查询班级学员卡号,走 RuoYi Bearer 鉴权。")
@RequestMapping("/api/board/v1")
public class TrainBoardApiController
{
@Autowired
private TrainSignageMapper signageMapper;
@Autowired
private TrainLessonMapper trainLessonMapper;
@Autowired
private ITrainPlanCourseService trainPlanCourseService;
@Autowired
private ITrainEnrollmentService trainEnrollmentService;
@Autowired
private ITrainStudentCheckinService trainStudentCheckinService;
@Autowired
private AttendanceAppService attendanceAppService;
/**
* 1. 查询教室信息
*/
@Operation(summary = "查询教室信息", description = "返回全部教学教室列表,供班牌后台定位终端绑定教室。")
@GetMapping("/signageService/selectClassroom")
public AjaxResult selectClassroom()
{
return AjaxResult.success(signageMapper.selectSignageClassrooms());
}
/**
* 2. 查询指定教室当天课程
*/
@Operation(summary = "查询指定教室当天课程", description = "按教室与日期范围返回排课课程列表(beginDate=endDate=当天)。")
@GetMapping("/signageService/coursesInClassroom")
public AjaxResult coursesInClassroom(
@Parameter(description = "教学教室ID") @RequestParam String classroomId,
@Parameter(description = "开始日期 yyyy-MM-dd") @RequestParam String beginDate,
@Parameter(description = "结束日期 yyyy-MM-dd") @RequestParam String endDate)
{
return wrapRows(signageMapper.selectSignageCourses(classroomId, beginDate, endDate));
}
/**
* 3. 查询指定班级在指定教室的座位
*/
@Operation(summary = "查询指定班级在指定教室的座位", description = "按教室与班级返回座位网格。")
@GetMapping("/signageService/studentInClassroom")
public AjaxResult studentInClassroom(
@Parameter(description = "教学教室ID") @RequestParam String classroomId,
@Parameter(description = "班级ID") @RequestParam String classId)
{
return wrapRows(signageMapper.selectSignageSeats(classroomId, classId));
}
/**
* 4. 查询班级学员卡号(含班级信息,用于刷卡校验;文档名为 studentCard)
*/
@Operation(summary = "查询班级学员卡号", description = "按班级返回学员卡号与班级信息,用于刷卡校验。")
@GetMapping("/signageService/studentCard")
public AjaxResult studentCard(
@Parameter(description = "班级ID") @RequestParam String classId)
{
return wrapRows(signageMapper.selectSignageCards(classId));
}
/**
* 5. 班牌上报签到数据(新增接口,未改动既有接口)。
* <p>
* 请求方式:POST,Content-Type 必须为 application/json,参数全部通过 JSON 请求体传递。
* 请求体:{@link BoardCheckinUploadRequest}
* (studentId / boardId / lessonId / signInTime),不含任何文件上传或表单字段。
* enrollmentId 由 studentId 在课次所属培训班内查询 train_enrollment 得到;
* 落库复用 AttendanceAppService.signIn(含幂等、参训生命周期与计划一致性校验)。
*/
@Operation(summary = "上报签到数据", description = "班牌上报学员签到(JSON 请求体):按 studentId 查询 train_enrollment 得到 enrollmentId 后写入 train_student_checkin,需 Bearer 鉴权。")
@PostMapping("/signageService/checkin/upload")
public AjaxResult uploadCheckin(
@Parameter(description = "签到上报请求体(JSON)") @RequestBody BoardCheckinUploadRequest req)
{
if (req == null)
{
return AjaxResult.error("请求体不能为空");
}
if (req.studentId == null || req.studentId.trim().isEmpty())
{
return AjaxResult.error("studentId 不能为空");
}
if (req.lessonId == null)
{
return AjaxResult.error("lessonId 不能为空");
}
String sid = req.studentId.trim();
Long lessonId = req.lessonId;
String boardId = req.boardId;
Date signIn = null;
if (req.signInTime != null && !req.signInTime.trim().isEmpty())
{
signIn = DateUtils.parseDate(req.signInTime.trim());
if (signIn == null)
{
return AjaxResult.error("signInTime 格式不正确,应为 yyyy-MM-dd HH:mm:ss");
}
}
// 课次 -> 计划课程 -> 培训班,用于定位该学员在本班的参训档案
TrainLesson lesson = trainLessonMapper.selectTrainLessonById(lessonId);
if (lesson == null)
{
return AjaxResult.error("课程(课次)不存在: " + lessonId);
}
TrainPlanCourse planCourse = trainPlanCourseService.selectTrainPlanCourseById(lesson.getPlanCourseId());
if (planCourse == null || planCourse.getPlanId() == null || planCourse.getPlanId().trim().isEmpty())
{
return AjaxResult.error("课程(课次)[" + lessonId + "]未关联有效的培训计划");
}
TrainEnrollment enrollment = trainEnrollmentService
.selectTrainEnrollmentByPlanIdAndStudentId(planCourse.getPlanId(), sid);
if (enrollment == null)
{
return AjaxResult.error("未找到学员[" + sid + "]在班级[" + planCourse.getPlanId() + "]的参训记录");
}
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(enrollment.getEnrollmentId());
dto.setLessonId(lessonId);
dto.setStudentId(sid);
dto.setBoardId(boardId != null && !boardId.trim().isEmpty() ? boardId.trim() : null);
dto.setCheckinWay(1); // 班牌设备上报,按设备打卡(1)记
dto.setSignInTime(signIn);
TrainStudentCheckin checkin = attendanceAppService.signIn(dto, SecurityUtils.getUsername());
Map<String, Object> data = new HashMap<>();
data.put("checkinId", checkin.getCheckinId());
data.put("studentId", checkin.getStudentId() != null ? checkin.getStudentId() : sid);
data.put("enrollmentId", checkin.getEnrollmentId());
data.put("lessonId", checkin.getLessonId());
data.put("boardId", checkin.getBoardId());
data.put("signInTime", checkin.getSignInTime() != null
? DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, checkin.getSignInTime()) : null);
return AjaxResult.success(data);
}
/**
* 6. 查询本节课签到记录(新增接口,未改动既有接口)。
* <p>
* 按 lessonId 返回 train_student_checkin 签到流水;每条记录的 studentName
* 由 Mapper 按 studentId 关联 train_student.s_name 一并返回。
*/
@Operation(summary = "查询本节课签到记录", description = "按 lessonId 返回 train_student_checkin 签到流水,并按 studentId 关联 train_student 返回学员姓名 sName。")
@GetMapping("/signageService/checkin/list")
public AjaxResult checkinList(
@Parameter(description = "课程(课次)ID") @RequestParam Long lessonId)
{
List<TrainStudentCheckin> rows = trainStudentCheckinService.selectCheckinsByLessonId(lessonId);
return wrapRows(rows);
}
/**
* 7. 查询班级座位布局(新增接口,仅读取 train_class_seat 单表,不影响其它接口)。
* <p>
* 入参:planId(班级ID,对应 train_class_seat.plan_id)、classroomId(教室ID,对应 train_class_seat.classroom_id)。
*/
@Operation(summary = "查询座位布局", description = "按班级ID(planId)与教室ID(classroomId)查询 train_class_seat 座位列表,仅读取该单表,需 Bearer 鉴权。")
@GetMapping("/signageService/seatLayout")
public AjaxResult seatLayout(
@Parameter(description = "班级ID(train_class_seat.plan_id)") @RequestParam String planId,
@Parameter(description = "教室ID(train_class_seat.classroom_id)") @RequestParam String classroomId)
{
return wrapRows(signageMapper.selectSeatLayout(planId, classroomId));
}
private AjaxResult wrapRows(List<?> rows)
{
Map<String, Object> data = new HashMap<>();
data.put("total", rows != null ? rows.size() : 0);
data.put("rows", rows != null ? rows : Collections.emptyList());
return AjaxResult.success(data);
}
/**
* 班牌签到上报请求体(JSON,Content-Type: application/json)。
* <p>
* 示例:{"studentId":"STU_1","boardId":"BP001","lessonId":1001,"signInTime":"2026-11-02 08:30:00"}
*/
public static class BoardCheckinUploadRequest
{
/** 学员ID,必填 */
public String studentId;
/** 门牌(班牌)ID,可选 */
public String boardId;
/** 课程(课次)ID,必填,对应 train_student_checkin.lesson_id */
public Long lessonId;
/** 签到时间 yyyy-MM-dd HH:mm:ss,可选,缺省取当前时间 */
public String signInTime;
}
}
@@ -0,0 +1,643 @@
package com.ruoyi.web.controller.api;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.device.domain.*;
import com.ruoyi.device.service.*;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.query.StudentQuery;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.*;
import java.io.*;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import com.ruoyi.train.domain.TrainScoreInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.ITrainPlanCourseService;
import com.ruoyi.train.service.ITrainScoreInfoService;
import com.ruoyi.train.service.ITrainStudentService;
//import com.ruoyi.system.domain.SysUser;
/**
* 通用对外接口(设备端调用)
*
* BasePath: /api/common/v1
* 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。
* 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@Tag(name = "iot通用接口", description = "从服务器获取学员信息、班级信息、班级课程信息、iot设备上传考勤记录、上传实训成绩 走 RuoYi Bearer 鉴权。")
@RequestMapping("/api/common/v1")
public class TrainCommonApiController extends BaseController
{
@Autowired
private ITrainIotDeviceService iotDeviceService;
@Autowired
private ITrainUserSessionService userSessionService;
@Autowired
private ITrainReadingDurationLogService readingLogService;
@Autowired
private ITrainReadingCurrentProgressService currentProgressService;
@Autowired
private ITrainReadingAlternateProgressService alternateProgressService;
@Autowired
private ITrainBookSnapshotService bookSnapshotService;
@Autowired
private ITrainBookDownloadRecordService bookDownloadRecordService;
@Autowired
private TokenService tokenService;
@Autowired
private ISysUserService sysUserService;
@Autowired
private ITrainStudentService studentService;
@Autowired
private ITrainClassPlanService classPlanService;
@Autowired
private ITrainPlanCourseService planCourseService;
@Autowired
private ITrainScoreInfoService scoreInfoService;
@Autowired
private TrainStudentCheckinMapper checkinMapper;
@Autowired
private ITrainEnrollmentService enrollmentService;
@Autowired
private TrainLessonMapper lessonMapper;
@Value("${ruoyi.profile:}")
private String profileRoot;
// ================= 通用数据接口(走 RuoYi Bearer 鉴权;Token 来源于标准 /login) =================
/**
* 1. 获取学员信息列表
*/
@Operation(summary = "获取学员信息列表", description = "从服务器获取学员主档列表,需 Bearer 鉴权。")
@GetMapping("/student/list")
public AjaxResult studentList()
{
List<StudentItemVO> list = studentService.selectStudentList(new StudentQuery());
return AjaxResult.success(list);
}
/**
* 2. 获取班级(培训计划)信息列表
*/
@Operation(summary = "获取班级信息列表", description = "从服务器获取开班计划(班级)列表,需 Bearer 鉴权。")
@GetMapping("/plan/list")
public AjaxResult planList()
{
List<PlanItemVO> list = classPlanService.selectTrainClassPlanList(new PlanQuery());
return AjaxResult.success(list);
}
/**
* 3. 获取指定班级的本期课程列表
*/
@Operation(summary = "获取班级课程信息", description = "根据班级ID(planId)返回该班级配置的本期课程列表,需 Bearer 鉴权。")
@GetMapping("/course/list")
public AjaxResult courseList(@Parameter(description = "班级ID(planId)") @RequestParam String classId)
{
if (classId == null || classId.trim().isEmpty())
{
return AjaxResult.error("classId 不能为空");
}
List<PlanCourseItemVO> list = planCourseService.selectPlanCourseListByPlanId(classId.trim());
return AjaxResult.success(list);
}
/**
* 4. 获取单个学员所在班级(培训计划)信息
*/
@Operation(summary = "获取学员所在班级信息", description = "根据 studentId 返回该学员参训的所有班级(培训计划)列表,需 Bearer 鉴权。")
@GetMapping("/student/class/info")
public AjaxResult studentClassInfo(@Parameter(description = "学员ID") @RequestParam String studentId)
{
if (studentId == null || studentId.trim().isEmpty())
{
return AjaxResult.error("studentId 不能为空");
}
List<PlanItemVO> list = classPlanService.selectClassPlanListByStudentId(studentId.trim());
return AjaxResult.success(list);
}
/**
* 13. IoT 设备上传考勤记录
*/
@Operation(summary = "上传考勤记录", description = "IoT 设备上报学员考勤(签到/签退),落库 train_student_checkin,需 Bearer 鉴权。")
@PostMapping("/checkin/upload")
public AjaxResult checkinUpload(@RequestBody CheckinUploadRequest req)
{
if (req.planId == null || req.planId.trim().isEmpty() || req.studentId == null || req.studentId.trim().isEmpty())
{
return AjaxResult.error("planId 与 studentId 不能为空");
}
String planId = req.planId.trim();
String studentId = req.studentId.trim();
// V2 约束:train_student_checkin.enrollment_id / lesson_id 均为 NOT NULL,必须由 plan/student 解析后写入
TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId);
if (enrollment == null)
{
return AjaxResult.error("未找到学员[" + studentId + "]在班级[" + planId + "]的参训记录,请先建立参训档案");
}
Long lessonId = (req.courseName != null && !req.courseName.trim().isEmpty())
? lessonMapper.selectFirstLessonIdByPlanAndCourse(planId, req.courseName.trim())
: null;
if (lessonId == null)
{
return AjaxResult.error("未找到班级[" + planId + "]课程[" + req.courseName + "]对应的课次(lesson),请先完成排课");
}
TrainStudentCheckin checkin = new TrainStudentCheckin();
checkin.setPlanId(planId);
checkin.setPlanName(req.planName);
// student_id 以参训档案为准落库,避免设备上报的 studentId 与 enrollment 不一致
checkin.setStudentId(enrollment.getStudentId());
checkin.setStudentName(req.studentName);
checkin.setCourseName(req.courseName);
checkin.setEnrollmentId(enrollment.getEnrollmentId());
checkin.setLessonId(lessonId);
// V2 必填列补默认值,避免 insert 因 NOT NULL 且无 DEFAULT 生效(显式 NULL 会覆盖列 DEFAULT)而失败
checkin.setCheckinWay(req.checkinWay != null ? req.checkinWay : 1);
checkin.setSignInTime(toDate(req.signInTime));
checkin.setSignOutTime(toDate(req.signOutTime));
checkin.setAttendanceStatus(req.attendanceStatus != null ? req.attendanceStatus : 1);
checkin.setManualFlag(req.manualFlag != null ? req.manualFlag : 0);
checkin.setBoardSn(req.boardSn);
checkin.setBoardName(req.boardName);
checkin.setCreateBy(getUsername());
checkin.setCreateTime(new Date());
int rows = checkinMapper.insertTrainStudentCheckin(checkin);
CheckinUploadData data = new CheckinUploadData();
data.checkinId = checkin.getCheckinId();
data.rows = rows;
return AjaxResult.success(data);
}
/**
* 14. 上传实训成绩
*/
@Operation(summary = "上传实训成绩", description = "IoT 设备上报实训/课程/考核成绩,落库 train_student_score,需 Bearer 鉴权。")
@PostMapping("/score/upload")
public AjaxResult scoreUpload(@RequestBody ScoreUploadRequest req)
{
TrainScoreInfo score = new TrainScoreInfo();
score.setPlanId(req.planId);
score.setcId(req.cId);
score.setStudentId(req.studentId);
score.setCourseScore(req.courseScore);
score.setTrainScore(req.trainScore);
score.setExamScore(req.examScore);
// 外部接口自动采集
score.setSourceType(req.sourceType != null ? req.sourceType : 3);
score.setSourceSystem(req.sourceSystem != null ? req.sourceSystem : "IOT");
score.setImportTime(toDate(req.importTime));
score.setCreateBy(getUsername());
score.setCreateTime(new Date());
int rows = scoreInfoService.insertTrainScoreInfo(score);
ScoreUploadData data = new ScoreUploadData();
data.scoreId = score.getScoreId();
data.rows = rows;
return AjaxResult.success(data);
}
private Date toDate(Long epochMillis)
{
return epochMillis != null ? new Date(epochMillis) : null;
}
private void writeJsonError(HttpServletResponse response, String msg) throws IOException
{
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}");
}
private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.setHeader("Accept-Ranges", "bytes");
if (md5 != null && !md5.isEmpty())
{
response.setHeader("Content-MD5", md5);
}
// 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问)
response.setContentType(guessContentType(url));
String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf";
try
{
String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc);
}
catch (Exception ignored) {}
boolean isHttp = url.startsWith("http://") || url.startsWith("https://");
InputStream in = null;
long total = -1;
if (isHttp)
{
// 完整 http(s) 远程地址(如 OSS):直接拉取
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestProperty("User-Agent", "ruoyi-device");
in = conn.getInputStream();
String cl = conn.getHeaderField("Content-Length");
if (cl != null && !cl.isEmpty())
{
try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {}
}
}
else if (url.startsWith("/"))
{
// 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401)
if (url.startsWith("/profile/"))
{
// /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致
File f = new File(profileRoot, url.substring("/profile".length()));
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
else
{
// 其他相对路径(测试用 /books/...)从 classpath 静态资源读取
ClassPathResource res = new ClassPathResource("static" + url);
if (!res.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = res.contentLength();
in = res.getInputStream();
}
}
else
{
// 绝对本地路径(遗留用法)
File f = new File(url);
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
try
{
String range = request.getHeader("Range");
long start = 0;
long end = total - 1;
boolean hasRange = false;
if (range != null && range.startsWith("bytes="))
{
String[] parts = range.substring(6).split("-");
try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {}
if (parts.length > 1 && !parts[1].isEmpty())
{
try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {}
}
hasRange = true;
}
if (hasRange)
{
if (end < 0 || end >= total)
{
end = total - 1;
}
if (start > end)
{
start = end;
}
long skipped = in.skip(start);
while (skipped < start)
{
long s = in.skip(start - skipped);
if (s <= 0)
{
break;
}
skipped += s;
}
long len = end - start + 1;
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total);
response.setContentLengthLong(len);
copy(in, response.getOutputStream(), len);
}
else
{
response.setStatus(HttpServletResponse.SC_OK);
if (total > 0)
{
response.setContentLengthLong(total);
}
copy(in, response.getOutputStream(), -1);
}
}
finally
{
if (in != null)
{
in.close();
}
}
}
private String guessContentType(String url)
{
if (url != null && url.toLowerCase().endsWith(".pdf"))
{
return "application/pdf";
}
return "application/octet-stream";
}
private void copy(InputStream in, OutputStream out, long limit) throws IOException
{
byte[] buf = new byte[8192];
long remaining = limit;
int n;
while ((n = in.read(buf)) != -1)
{
if (limit > 0)
{
if (remaining <= 0)
{
break;
}
int toWrite = (int) Math.min(n, remaining);
out.write(buf, 0, toWrite);
remaining -= toWrite;
}
else
{
out.write(buf, 0, n);
}
}
out.flush();
}
// ================= 请求/响应 DTO(内嵌,避免新增文件) =================
public static class LoginRequest
{
public String deviceId;
public Long userId;
public String firmware;
public String appVersion;
public NetInfo netInfo;
public Long timestamp;
public String nonce;
}
public static class NetInfo
{
public String ip;
public String mac;
public Integer rssi;
}
public static class LogoutRequest
{
public String deviceId;
public String reason;
public Long timestamp;
public String nonce;
}
public static class HeartbeatRequest
{
public String deviceId;
public String status;
public Metrics metrics;
public String configVersion;
public String personVersion;
public Long timestamp;
public String nonce;
}
public static class Metrics
{
public Integer cpu;
public Integer mem;
public Long diskFree;
}
public static class ReadingRecordRequest
{
public String deviceId;
public String recordId;
public String bookId;
public String bookVersion;
public String mode;
public String recordType;
public Long startTime;
public Long endTime;
public Integer durationSec;
public Integer pageFrom;
public Integer pageTo;
public BigDecimal progress;
}
public static class ReadingRecordBatchRequest
{
public String deviceId;
public String batchId;
public Long timestamp;
public String nonce;
public List<ReadingRecordRequest> records;
}
public static class DownloadAckRequest
{
public String deviceId;
public String bookId;
public String bookVersion;
public String fileMd5;
public String result;
public String errMsg;
public Long timestamp;
public String nonce;
}
public static class LoginData
{
public String accessToken;
public String sessionId;
public Integer heartbeatInterval;
}
public static class LogoutData
{
public Boolean result;
public Boolean tokenRevoked;
}
public static class HeartbeatData
{
public Integer nextInterval;
public List<Object> commands;
}
public static class RecordData
{
public String result;
public Integer studyMinutes;
public Integer totalMinutes;
}
public static class ReadingRecordItemResult
{
public String recordId;
public String result;
public ReadingRecordItemResult(String recordId, String result)
{
this.recordId = recordId;
this.result = result;
}
}
public static class BatchData
{
public List<ReadingRecordItemResult> itemResults;
}
public static class MainProgress
{
public String mode;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long updateTime;
}
public static class AlternateProgress
{
public String mode;
public String bookVersion;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long createTime;
}
public static class BookRecord
{
public String bookId;
public String bookVersion;
public MainProgress mainProgress;
public List<AlternateProgress> alternateProgressList;
}
public static class ProgressData
{
public Long userId;
public long totalReadSeconds;
public long totalAudioSeconds;
public List<BookRecord> bookRecords;
}
public static class BookItem
{
public String bookId;
public String bookTitle;
public String bookCategory;
public String bookSummary;
public String bookVersion;
public Long fileSize;
public String fileMd5;
public Boolean isOnShelf;
public Long publishTime;
}
public static class BookListData
{
public int total;
public List<BookItem> list;
}
public static class DownloadAckData
{
public Boolean deviceReady;
public Long recordId;
}
public static class CheckinUploadRequest
{
public String planId;
public String planName;
public String studentId;
public String studentName;
public String courseName;
public Integer checkinWay;
public Long signInTime;
public Long signOutTime;
public Integer attendanceStatus;
/** 是否人工签到:0=设备自动(默认),1=教师代签/人工录入 */
public Integer manualFlag;
public String boardSn;
public String boardName;
}
public static class CheckinUploadData
{
public Long checkinId;
public int rows;
}
public static class ScoreUploadRequest
{
public String planId;
public String cId;
public String studentId;
public BigDecimal courseScore;
public BigDecimal trainScore;
public BigDecimal examScore;
public Integer sourceType;
public String sourceSystem;
public Long importTime;
}
public static class ScoreUploadData
{
public Long scoreId;
public int rows;
}
}
@@ -0,0 +1,424 @@
package com.ruoyi.web.controller.api;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.query.StudentQuery;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainTeacherService;
/**
* 人脸接口(调用人脸识别端API)
*
* BasePath: /api/face/v1
* 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。
* 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。
*
* 本控制器封装了文档中的 5 个人脸识别设备接口(4.3 / 4.4 / 4.5 / 4.6 / 4.7)。
* 设备地址前缀由 application.yml 的 face.api-base-url 配置;face.enabled=false 时仅返回模拟响应,不实际调用设备。
*
* @author ruoyi
* @date 2026-10-22
*/
@RestController
@Tag(name = "face接口", description = "调用人脸识别终端API为终端提供人脸管理服务:为员工新增一张人脸、删除指定员工人脸数据、批量删除员工人脸数据、按文件夹批量导入人脸数据、清空全部人脸数据。")
@RequestMapping("/api/face/v1")
public class TrainFaceApiController {
@Value("${face.api-base-url:http://127.0.0.1:9000}")
private String faceApiBaseUrl;
@Value("${face.enabled:false}")
private boolean faceEnabled;
/** 开放接口密钥,写入请求头 X-API-Key */
@Value("${iot.face.face_api_key:}")
private String apiKey;
@Autowired
private ITrainStudentService studentService;
private final RestTemplate restTemplate = new RestTemplate();
private static final Logger log = LoggerFactory.getLogger(TrainFaceApiController.class);
/**
* 4.3 POST /employees 新增员工人脸
* 按 employee_id(即学员 sId)查询 train_student,取头像转 Base64 data URL 后调用设备 /employees。
*/
@Operation(summary = "新增员工人脸", description = "按 employee_id(即学员 sId) 查询 train_student,取头像转 Base64 data URL 后调用人脸识别设备 /employees。")
@PostMapping("/employees")
public AjaxResult addEmployeeFace(@RequestBody Map<String, Object> payload)
{
log.info("addEmployeeFace 收到请求 payload={}", payload);
String employeeId = (String) payload.get("employee_id");
if (employeeId == null || employeeId.isEmpty())
{
return AjaxResult.error("参数 employee_id 不能为空");
}
// 分支判定:先判断 "_teacher" 后缀,命中则进入教师分支;否则沿用原学员逻辑
final String teacherSuffix = "_teacher";
String name;
String avatar;
String entityLabel;
if (employeeId.endsWith(teacherSuffix))
{
// 教师分支:仅移除末尾 "_teacher" 后缀得到 tId(不做 trim 或其他裁剪)
String tId = employeeId.substring(0, employeeId.length() - teacherSuffix.length());
TrainTeacher teacher = SpringUtils.getBean(ITrainTeacherService.class).selectTrainTeacherByTId(tId);
if (teacher == null)
{
// 教师记录不存在:不更新头像,直接返回
return AjaxResult.error("教师不存在:" + tId);
}
name = teacher.gettName();
avatar = teacher.getAvatar();
entityLabel = "教师";
}
else
{
StudentDetailVO detail = studentService.selectStudentDetail(employeeId);
if (detail == null)
{
return AjaxResult.error("学员不存在:" + employeeId);
}
name = detail.getsName();
avatar = detail.getAvatar();
entityLabel = "学员";
}
String imageBase64 = toImageDataUrl(avatar);
if (imageBase64 == null)
{
return AjaxResult.error(entityLabel + "头像缺失或无法读取:" + employeeId);
}
Map<String, Object> body = new HashMap<>(4);
body.put("employee_id", employeeId);
body.put("name", name);
body.put("image_base64", imageBase64);
//body.put("image_path", avatar);
log.info("addEmployeeFace 收到请求 body={}", body);
if (!faceEnabled)
{
return AjaxResult.success("人脸服务未启用,返回模拟响应", buildMockAddEmployee(employeeId, name));
}
return doPost("/employees", body);
}
/**
* 4.4 DELETE /employees/{employee_id} 删除指定员工
*/
@Operation(summary = "删除指定员工人脸数据", description = "调用人脸识别设备 DELETE /employees/{employee_id}。")
@DeleteMapping("/employees/{employee_id}")
public AjaxResult deleteEmployee(@PathVariable("employee_id") String employeeId)
{
log.info("deleteEmployee 收到请求 body={}", employeeId);
if (!faceEnabled)
{
Map<String, Object> data = new HashMap<>(2);
data.put("deleted_count", 1);
data.put("employee_id", employeeId);
return AjaxResult.success("人脸服务未启用,返回模拟响应", data);
}
return doDelete("/employees/" + employeeId);
}
/**
* 4.5 POST /employees/delete-batch 批量删除
*/
@Operation(summary = "批量删除员工人脸数据", description = "调用人脸识别设备 POST /employees/delete-batch。")
@PostMapping("/employees/delete-batch")
public AjaxResult deleteEmployeesBatch(@RequestBody Map<String, Object> payload)
{
@SuppressWarnings("unchecked")
List<String> employeeIds = (List<String>) payload.get("employee_ids");
log.info("deleteEmployeesBatch 收到请求 body={}", payload);
if (!faceEnabled)
{
Map<String, Object> data = new HashMap<>(4);
int n = employeeIds != null ? employeeIds.size() : 0;
data.put("requested_count", n);
data.put("deleted_employee_count", n);
data.put("deleted_face_count", n);
data.put("deleted", new ArrayList<>());
data.put("not_found", new ArrayList<>());
return AjaxResult.success("人脸服务未启用,返回模拟响应", data);
}
return doPost("/employees/delete-batch", payload);
}
/**
* 4.6 POST /employees/import-batch 批量导入
* 从 train_student 取 sId/sName/avatar,按 employee_id_name_index.jpg 规则整理成本地目录后调用设备 import-batch。
*/
@Operation(summary = "批量导入人脸(按文件夹)", description = "从 train_student 取 sId/sName/avatar,按 employee_id_name_index.jpg 规则整理成本地目录后调用设备 import-batch。")
@PostMapping("/employees/import-batch")
public AjaxResult importEmployeesBatch()
{
Map<String, Object> organized = organizeFaceImportFolder();
String folderPath = (String) organized.get("folder_path");
@SuppressWarnings("unchecked")
List<Map<String, Object>> imported = (List<Map<String, Object>>) organized.get("imported");
log.info("importEmployeesBatch 收到请求 body={}", imported);
if (!faceEnabled)
{
Map<String, Object> data = new HashMap<>(4);
data.put("folder_path", folderPath);
data.put("imported_count", imported != null ? imported.size() : 0);
data.put("failed_count", 0);
data.put("imported", imported != null ? imported : new ArrayList<>());
data.put("failed", new ArrayList<>());
return AjaxResult.success("人脸服务未启用,返回模拟响应", data);
}
Map<String, Object> body = new HashMap<>(2);
body.put("folder_path", folderPath);
return doPost("/employees/import-batch", body);
}
/**
* 4.7 DELETE /employees 清空全部
*/
@Operation(summary = "清空全部人脸数据", description = "调用人脸识别设备 DELETE /employees。")
@DeleteMapping("/employees")
public AjaxResult clearAllEmployees()
{
log.info("clearAllEmployees 收到请求");
if (!faceEnabled)
{
Map<String, Object> data = new HashMap<>(1);
data.put("deleted_count", 7);
return AjaxResult.success("人脸服务未启用,返回模拟响应", data);
}
return doDelete("/employees");
}
// ===================== 内部:真实调用人脸设备 =====================
private AjaxResult doPost(String path, Map<String, Object> body)
{
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-API-Key", apiKey);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> resp = restTemplate.exchange(faceApiBaseUrl + path, HttpMethod.POST, entity, Map.class);
if (resp.getStatusCode().is2xxSuccessful()) {
log.info("doPost 收到请求 resp={}", resp.getBody());
return AjaxResult.success(resp.getBody());
}
log.info("doPost 收到请求 resp={}", resp.getStatusCode());
return AjaxResult.error("人脸设备返回非成功状态:" + resp.getStatusCode());
} catch (HttpStatusCodeException ex) {
log.info("doPost 收到请求 resp={}", "人脸设备返回 " + ex.getStatusCode() + ":" +ex.getResponseBodyAsString());
return AjaxResult.error("人脸设备返回 " + ex.getStatusCode() + ":" + ex.getResponseBodyAsString());
} catch (Exception e) {
log.info("doPost 收到请求 resp={}", "调用人脸识别设备失败:" + e.getMessage());
return AjaxResult.error("调用人脸识别设备失败:" + e.getMessage());
}
}
private AjaxResult doDelete(String path)
{
try
{
log.info("doDelete 收到请求 faceApiBaseUrl_path={}", faceApiBaseUrl + path);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-API-Key", apiKey);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<Map> resp = restTemplate.exchange(faceApiBaseUrl + path, HttpMethod.DELETE, entity, Map.class);
log.info("doDelete 响应 status={} body={}", resp.getStatusCode(), resp.getBody());
if (resp.getStatusCode().is2xxSuccessful())
{
return AjaxResult.success(resp.getBody());
}
return AjaxResult.error("人脸设备返回非成功状态:" + resp.getStatusCode());
}
catch (HttpStatusCodeException ex)
{
log.error("doDelete 人脸设备失败 status={} body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
return AjaxResult.error("人脸设备返回 " + ex.getStatusCode() + ":" + ex.getResponseBodyAsString());
}
catch (Exception e)
{
log.error("doDelete 调用人脸识别设备失败", e);
return AjaxResult.error("调用人脸识别设备失败:" + e.getMessage());
}
}
// ===================== 内部:头像转 Base64 data URL =====================
private String toImageDataUrl(String avatar)
{
if (avatar == null || avatar.isEmpty())
{
return null;
}
byte[] bytes = readImageBytes(avatar);
if (bytes == null)
{
return null;
}
String mime = detectMime(avatar);
return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(bytes);
}
private byte[] readImageBytes(String avatar)
{
try
{
if (avatar.startsWith("http://") || avatar.startsWith("https://"))
{
return restTemplate.getForObject(avatar, byte[].class);
}
// 与文件下载接口同源:使用 RuoYiConfig.getProfile()(如 ./uploadPath)
Path profile = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize();
String path = avatar;
// 去掉虚拟前缀 /profile,兼容可能带网关/上下文前缀(如 /dev-api/profile)
int idx = path.indexOf("/profile");
if (idx >= 0)
{
path = path.substring(idx + "/profile".length());
}
// 去掉剥离后开头的分隔符,避免 Path.resolve 将其识别为绝对路径而丢失 profile 前缀
while (path.startsWith("/") || path.startsWith(File.separator))
{
path = path.substring(1);
}
Path file;
if (new File(path).isAbsolute())
{
file = Paths.get(path);
}
else
{
file = profile.resolve(path).normalize();
}
File f = file.toFile();
if (!f.exists())
{
return null;
}
return Files.readAllBytes(f.toPath());
}
catch (Exception e)
{
return null;
}
}
private static String detectMime(String avatar)
{
String lower = avatar.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".bmp")) return "image/bmp";
if (lower.endsWith(".webp")) return "image/webp";
return "image/jpeg";
}
// ===================== 内部:从 train_student 整理导入目录 =====================
private Map<String, Object> organizeFaceImportFolder()
{
Map<String, Object> result = new HashMap<>(4);
List<Map<String, Object>> imported = new ArrayList<>();
try
{
String folderPath = RuoYiConfig.getProfile() + File.separator + "face_import_" + System.currentTimeMillis();
File folder = new File(folderPath);
folder.mkdirs();
List<StudentItemVO> students = studentService.selectStudentList(new StudentQuery());
if (students != null)
{
for (StudentItemVO s : students)
{
String sId = s.getsId();
String sName = s.getsName();
String avatar = s.getAvatar();
byte[] bytes = readImageBytes(avatar);
if (bytes == null || sId == null)
{
continue;
}
String fileName = sId + "_" + (sName != null ? sName : "") + "_1.jpg";
File out = new File(folder, fileName);
Files.write(out.toPath(), bytes);
Map<String, Object> item = new HashMap<>(5);
item.put("file", fileName);
item.put("employee_id", sId);
item.put("name", sName);
item.put("index", "1");
item.put("image_path", "data/known_faces_photos/" + sId + "_1.jpg");
item.put("feature_dimension", 512);
imported.add(item);
}
}
result.put("folder_path", folderPath);
result.put("imported", imported);
}
catch (Exception e)
{
result.put("folder_path", null);
result.put("imported", imported);
}
return result;
}
// ===================== 内部:模拟 4.3 响应 =====================
private Map<String, Object> buildMockAddEmployee(String employeeId, String name)
{
Map<String, Object> data = new HashMap<>(8);
data.put("employee_id", employeeId);
data.put("name", name);
data.put("face_id", 1);
data.put("face_count", 1);
data.put("bbox", new int[] { 198, 152, 512, 553 });
data.put("confidence", 0.912345);
data.put("image_path", "data/known_faces_photos/" + employeeId + "_1.jpg");
data.put("feature_dimension", 512);
data.put("feature_vector", new ArrayList<>());
return data;
}
}
@@ -0,0 +1,534 @@
package com.ruoyi.web.controller.api;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.service.ITrainAccommodationService;
/**
* 房间门禁开放服务代理控制器。
*
* <p>对接外部「对外开放房间门禁服务」(入住 / 退房 / 续住 / 开锁记录查询):
* 基础地址取 {@code iot.face.lock_endpoint},每个请求均携带请求头
* {@code X-API-Key}(值取 {@code iot.face.api_key}),并按文档的请求方法、路径、
* 参数与统一返回结构({@code code/msg/requestId/data})进行转发与映射。
*
* <p>并发与异常:使用线程安全的 {@link HttpClient} 单例,连接超时 3s、请求超时 10s;
* 配置缺失、网络超时/中断、响应为空均转换为文档约定的错误结构返回,不向调用方抛异常。
*/
@RestController
@RequestMapping("/api/lock/v1")
public class TrainLockApiController
{
private static final Logger log = LoggerFactory.getLogger(TrainLockApiController.class);
/** 用于构造与解析统一 JSON 结构 */
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 统一响应内容类型(UTF-8) */
private static final MediaType JSON_UTF8 = MediaType.parseMediaType("application/json;charset=UTF-8");
/** 对外开放房间门禁服务的接口相对路径(见设计文档 §4、§5、§6、§7) */
private static final String PATH_CHECKIN = "/openapi/iot/stay/checkin";
private static final String PATH_CHECKOUT = "/openapi/iot/stay/checkout";
private static final String PATH_EXTEND = "/openapi/iot/stay/extend";
private static final String PATH_UNLOCK_RECORDS = "/openapi/iot/stay/unlock-records";
private static final String PATH_TEST = "/openapi/iot/ping";
/** 接口基础地址,例如 http://192.168.1.203:8080 */
@Value("${iot.face.lock_endpoint:}")
private String lockEndpoint;
/** 开放接口密钥,写入请求头 X-API-Key */
@Value("${iot.face.api_key:}")
private String apiKey;
@Value("${iot.face.avatar_profile:}")
private String avatarProfile;
/** 住宿安排服务:门锁回调结果回写 train_accommodation.lock_result */
@Autowired
private ITrainAccommodationService accommodationService;
/** 回调落库操作人(无登录上下文,固定标识为系统) */
private static final String CALLBACK_OPERATOR = "system";
/** 线程安全的 HTTP 客户端,连接超时 3s(请求级超时见 forward) */
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// ==================== 接口一:办理入住 ====================
/**
* 办理入住(POST /openapi/iot/stay/checkin)。
* 请求体字段(bizNo/externalPersonId/personName/roomNo/beginTime/endTime/photoUrl/photoBase64 等)
* 原样透传至门禁开放服务。
*/
@PostMapping("/stay/checkin")
public ResponseEntity<String> checkin(@RequestBody(required = false) Map<String, Object> body)
{
// photoUrl 为 /profile/ 开头的相对路径时,拼接配置的头像服务主机,补全为完整地址
if (body != null && body.get("photoUrl") instanceof String)
{
String photoUrl = (String) body.get("photoUrl");
if (photoUrl.startsWith("/profile/"))
{
try
{
String base = avatarProfile;
if (base != null && !base.trim().isEmpty())
{
base = base.trim();
if (!base.startsWith("http://") && !base.startsWith("https://"))
{
base = "http://" + base;
}
if (base.endsWith("/"))
{
base = base.substring(0, base.length() - 1);
}
body.put("photoUrl", base + photoUrl);
}
}
catch (Exception e)
{
log.warn("[办理入住] 读取 iot.face.avatar_profile 失败,使用原始 photoUrl:{}", e.getMessage());
}
}
}
log.info("[办理入住] 处理后请求体:{}", body);
return forward("POST", PATH_CHECKIN, body, "办理入住");
}
// ==================== 接口二:办理退房 ====================
/**
* 办理退房(POST /openapi/iot/stay/checkout)。
* 请求体支持 bizNo,或 personNo/externalPersonId + roomNo。
*/
@PostMapping("/stay/checkout")
public ResponseEntity<String> checkout(@RequestBody(required = false) Map<String, Object> body)
{
return forward("POST", PATH_CHECKOUT, body, "办理退房");
}
// ==================== 接口三:办理续住 ====================
/**
* 办理续住(POST /openapi/iot/stay/extend)。
* 请求体支持 bizNo 或人员标识 + roomNo,及新的 endTime。
*/
@PostMapping("/stay/extend")
public ResponseEntity<String> extend(@RequestBody(required = false) Map<String, Object> body)
{
return forward("POST", PATH_EXTEND, body, "办理续住");
}
// ==================== 接口四:查询开锁记录 ====================
/**
* 查询开锁记录(GET /openapi/iot/stay/unlock-records)。
* 按文档 §7.2 映射查询参数;空值参数不下发。
*/
@GetMapping("/stay/unlock-records")
public ResponseEntity<String> unlockRecords(
@RequestParam(value = "roomNo", required = false) String roomNo,
@RequestParam(value = "personNo", required = false) String personNo,
@RequestParam(value = "externalPersonId", required = false) String externalPersonId,
@RequestParam(value = "beginTime", required = false) String beginTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "openType", required = false) String openType,
@RequestParam(value = "pageNum", required = false) Integer pageNum,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "requestId", required = false) String requestId)
{
StringBuilder query = new StringBuilder();
appendParam(query, "roomNo", roomNo);
appendParam(query, "personNo", personNo);
appendParam(query, "externalPersonId", externalPersonId);
appendParam(query, "beginTime", beginTime);
appendParam(query, "endTime", endTime);
appendParam(query, "openType", openType);
appendParam(query, "pageNum", pageNum);
appendParam(query, "pageSize", pageSize);
appendParam(query, "requestId", requestId);
String path = query.length() == 0 ? PATH_UNLOCK_RECORDS : PATH_UNLOCK_RECORDS + "?" + query;
return forward("GET", path, null, "查询开锁记录");
}
@GetMapping("/stay/ping")
public ResponseEntity<String> pingRecords()
{
StringBuilder query = new StringBuilder();
String path = query.length() == 0 ? PATH_TEST : PATH_TEST + "?" + query;
return forward("GET", path, null, "测试接口");
}
// ==================== 接口五:结果回调接收 ====================
/**
* 接收门禁开放服务的结果回调(文档 §8.1)。
*
* <p>回调报文为 JSON(Content-Type: application/json;charset=UTF-8),字段与文档 §8.1 一致:
* eventId(String,事件唯一标识)、eventType(String,STAY_CHECKIN_RESULT / STAY_CHECKOUT_RESULT /
* STAY_EXTEND_RESULT)、revision(Integer,同一 bizNo 的结果版本,从 1 递增)、bizNo(String,业务序列号)、
* stayNo(String)、personNo(String)、externalPersonId(String)、roomNo(String)、roomCode(String)、
* deviceSn(String)、result(String,SUCCESS / FAILED)、message(String,失败原因,成功可为空)、
* occurredAt(String,yyyy-MM-dd HH:mm:ss)。
*
* <p>入参校验(不通过按文档 §3.2 返回 40001,HTTP 400):报文为空;eventId/eventType/bizNo/result 缺失;
* eventType 不在三类事件内;result 不是 SUCCESS/FAILED;revision 缺失或非正整数。
*
* <p>校验通过后按 bizNo + eventType + result 回写 train_accommodation.lock_result:
* STAY_CHECKIN_RESULT + SUCCESS → 2(已授权);STAY_CHECKOUT_RESULT + SUCCESS → 3(已过期);
* 其它组合不改变该字段。bizNo 无对应住宿记录时只记录日志并按接收成功应答;记录存在但更新未生效/异常时
* 返回 50002(HTTP 500),交由对方按回调重试策略重试,保证最终一致。
*
* <p>处理完成按文档 §8.1 的接收判定返回 HTTP 200 + {"code":0,...}(对方据此认为接收成功、不再重试);
* 去重与重试策略由回调发起方(本系统 P2 回调任务表 lock_callback_task)负责。
*
* <p>路由与鉴权沿用本控制器既有约定:同属 {@code /api/lock/v1} 前缀,未额外声明权限注解,
* 与 checkin/checkout/extend/unlock-records 保持一致的鉴权策略。
*/
@PostMapping("/stay/callback")
public ResponseEntity<String> stayCallback(@RequestBody(required = false) Map<String, Object> body)
{
if (body == null || body.isEmpty())
{
log.warn("[结果回调] 报文为空");
return error(40001, "回调报文不能为空");
}
Object eventIdObj = body.get("eventId");
Object eventTypeObj = body.get("eventType");
Object bizNoObj = body.get("bizNo");
Object resultObj = body.get("result");
String eventId = eventIdObj == null ? "" : eventIdObj.toString().trim();
String eventType = eventTypeObj == null ? "" : eventTypeObj.toString().trim();
String bizNo = bizNoObj == null ? "" : bizNoObj.toString().trim();
String result = resultObj == null ? "" : resultObj.toString().trim();
if (eventId.isEmpty() || eventType.isEmpty() || bizNo.isEmpty() || result.isEmpty())
{
log.warn("[结果回调] 必填参数缺失,报文:{}", body);
return error(40001, "参数缺失:eventId、eventType、bizNo、result 均为必填");
}
if (!"STAY_CHECKIN_RESULT".equals(eventType) && !"STAY_CHECKOUT_RESULT".equals(eventType)
&& !"STAY_EXTEND_RESULT".equals(eventType))
{
log.warn("[结果回调] eventType 非法:{}", eventType);
return error(40001, "参数 eventType 非法:" + eventType
+ ",可选值 STAY_CHECKIN_RESULT / STAY_CHECKOUT_RESULT / STAY_EXTEND_RESULT");
}
if (!"SUCCESS".equals(result) && !"FAILED".equals(result))
{
log.warn("[结果回调] result 非法:{}", result);
return error(40001, "参数 result 非法:" + result + ",可选值 SUCCESS / FAILED");
}
Object revisionObj = body.get("revision");
int revision;
try
{
revision = revisionObj == null ? -1 : Integer.parseInt(revisionObj.toString().trim());
}
catch (NumberFormatException formatError)
{
revision = -1;
}
if (revision <= 0)
{
log.warn("[结果回调] revision 非法:{}", revisionObj);
return error(40001, "参数 revision 非法:必须为正整数");
}
log.info("[结果回调] eventId={}, eventType={}, revision={}, bizNo={}, stayNo={}, personNo={}, "
+ "externalPersonId={}, roomNo={}, roomCode={}, deviceSn={}, result={}, message={}, occurredAt={}",
eventId, eventType, revision, bizNo, body.get("stayNo"), body.get("personNo"),
body.get("externalPersonId"), body.get("roomNo"), body.get("roomCode"), body.get("deviceSn"),
result, body.get("message"), body.get("occurredAt"));
// 回调结果回写住宿记录:入住成功 -> 2(已授权);退房成功 -> 3(已过期);
// 其它组合(result=FAILED、STAY_EXTEND_RESULT 等)不改变 lock_result。
Integer targetLockResult = null;
if ("SUCCESS".equals(result))
{
if ("STAY_CHECKIN_RESULT".equals(eventType))
{
targetLockResult = TrainAccommodation.LOCK_RESULT_AUTHORIZED;
}
else if ("STAY_CHECKOUT_RESULT".equals(eventType))
{
targetLockResult = TrainAccommodation.LOCK_RESULT_EXPIRED;
}
}
String lockUpdate = "SKIPPED";
if (targetLockResult == null)
{
log.info("[结果回调] eventType/result 不触发 lockResult 更新,bizNo={}, eventType={}, result={}",
bizNo, eventType, result);
}
else
{
// bizNo 为门锁开放接口业务流水号,仅当确实存在对应住宿记录时才更新,避免无记录时误报成功
TrainAccommodation accommodation = accommodationService.selectTrainAccommodationByLockApiBizNo(bizNo);
if (accommodation == null)
{
// 记录不存在(平台手工单、已归档或 bizNo 不匹配):只记录日志,仍按接收成功应答,避免对方无意义重试
lockUpdate = "NOT_FOUND";
log.warn("[结果回调] 未找到 bizNo 对应住宿记录,跳过 lockResult 更新,bizNo={}, eventType={}, result={}",
bizNo, eventType, result);
}
else
{
try
{
int rows = accommodationService.updateLockResult(accommodation.getAccommodationId(),
targetLockResult, CALLBACK_OPERATOR);
if (rows <= 0)
{
// 记录存在但未更新(并发删除等):返回 50002,交由对方按回调重试策略重试,保证最终一致
log.error("[结果回调] lockResult 更新未生效,accommodationId={}, bizNo={}, lockResult={}",
accommodation.getAccommodationId(), bizNo, targetLockResult);
return error(50002, "回调结果落库失败:住宿记录 lockResult 未更新,bizNo=" + bizNo);
}
lockUpdate = "UPDATED";
log.info("[结果回调] lockResult 已更新,accommodationId={}, bizNo={}, lockResult={}",
accommodation.getAccommodationId(), bizNo, targetLockResult);
}
catch (Exception updateError)
{
// 落库异常返回 50002,对方按 5 次退避重试;不吞异常,避免结果长期不一致
log.error("[结果回调] lockResult 更新异常,accommodationId={}, bizNo={}, lockResult={}",
accommodation.getAccommodationId(), bizNo, targetLockResult, updateError);
return error(50002, "回调结果落库失败:" + updateError.getMessage());
}
}
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("eventId", eventId);
data.put("eventType", eventType);
data.put("revision", revision);
data.put("bizNo", bizNo);
data.put("result", result);
data.put("received", true);
data.put("lockUpdate", lockUpdate);
if ("UPDATED".equals(lockUpdate))
{
data.put("lockResult", targetLockResult);
}
Map<String, Object> ack = new LinkedHashMap<>();
ack.put("code", 0);
ack.put("msg", "success");
ack.put("data", data);
String ackBody;
try
{
ackBody = MAPPER.writeValueAsString(ack);
}
catch (Exception serializeError)
{
log.error("[结果回调] 响应序列化失败", serializeError);
ackBody = "{\"code\":0,\"msg\":\"success\",\"data\":null}";
}
return ResponseEntity.status(HttpStatus.OK).contentType(JSON_UTF8).body(ackBody);
}
// ==================== 内部实现 ====================
/**
* 统一转发逻辑:校验配置、拼接地址、注入 X-API-Key、转发请求并透传响应。
*
* @param method GET/POST
* @param path 相对路径(含查询串)
* @param body POST 请求体(GET 为 null)
* @param action 操作名称(仅用于日志)
*/
private ResponseEntity<String> forward(String method, String path, Map<String, Object> body, String action)
{
String base = lockEndpoint == null ? "" : lockEndpoint.trim();
if (base.isEmpty())
{
log.warn("[{}] 未配置 iot.face.lock_endpoint,无法调用门禁开放服务", action);
return error(ApiErrorCode.CONFIG_MISSING, "未配置接口基础地址 iot.face.lock_endpoint");
}
String key = apiKey == null ? "" : apiKey.trim();
if (key.isEmpty())
{
log.warn("[{}] 未配置 iot.face.api_key,无法调用门禁开放服务", action);
return error(ApiErrorCode.CONFIG_MISSING, "未配置接口密钥 iot.face.api_key");
}
if (base.endsWith("/"))
{
base = base.substring(0, base.length() - 1);
}
String url = base + path;
long startAt = System.currentTimeMillis();
try
{
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.header("X-API-Key", key)
.header("Accept", "application/json");
if ("POST".equals(method))
{
String payload;
try
{
payload = body == null ? "{}" : MAPPER.writeValueAsString(body);
}
catch (Exception serializeError)
{
log.error("[{}] 请求体序列化失败", action, serializeError);
return error(ApiErrorCode.INTERNAL_ERROR, "请求体序列化失败:" + serializeError.getMessage());
}
builder.header("Content-Type", "application/json;charset=UTF-8");
builder.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8));
}
else
{
builder.GET();
}
HttpResponse<String> response = httpClient.send(
builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String responseBody = response.body();
long cost = System.currentTimeMillis() - startAt;
log.info("[{}] {} -> HTTP {}, cost={}ms, response={}",
action, url, response.statusCode(), cost, brief(responseBody));
if (responseBody == null || responseBody.trim().isEmpty())
{
return error(ApiErrorCode.INTERNAL_ERROR, "门禁服务返回空响应(HTTP " + response.statusCode() + ")");
}
// 透传对方 HTTP 状态与统一响应结构(含 4xx/5xx 的业务错误体),由调用方按 code 处理
return ResponseEntity.status(response.statusCode()).contentType(JSON_UTF8).body(responseBody);
}
catch (java.net.http.HttpTimeoutException timeout)
{
log.error("[{}] 调用门禁服务超时:{}", action, url, timeout);
return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务超时:" + timeout.getMessage());
}
catch (InterruptedException interrupted)
{
Thread.currentThread().interrupt();
log.error("[{}] 调用门禁服务被中断:{}", action, url, interrupted);
return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务被中断");
}
catch (Exception ex)
{
log.error("[{}] 调用门禁服务异常:{}", action, url, ex);
return error(ApiErrorCode.INTERNAL_ERROR, "调用门禁服务异常:" + ex.getMessage());
}
}
/** 追加非空查询参数(UTF-8 URL 编码)。 */
private static void appendParam(StringBuilder query, String name, Object value)
{
if (value == null)
{
return;
}
String text = String.valueOf(value);
if (text.isEmpty())
{
return;
}
if (query.length() > 0)
{
query.append('&');
}
query.append(name).append('=').append(URLEncoder.encode(text, StandardCharsets.UTF_8));
}
/** 构造与文档 §3.2 一致的错误响应体:{code,msg,data:null}。 */
private ResponseEntity<String> error(int code, String msg)
{
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", code);
result.put("msg", msg);
result.put("data", null);
String body;
try
{
body = MAPPER.writeValueAsString(result);
}
catch (Exception serializeError)
{
body = "{\"code\":" + code + ",\"msg\":\"" + (msg == null ? "" : msg.replace("\"", "'")) + "\",\"data\":null}";
}
return ResponseEntity.status(httpStatusOf(code)).contentType(JSON_UTF8).body(body);
}
/** 错误码 → HTTP 状态码映射(与文档 §3.2 对齐)。 */
private static HttpStatus httpStatusOf(int code)
{
if (code == ApiErrorCode.AUTH_INVALID)
{
return HttpStatus.UNAUTHORIZED;
}
if (code == ApiErrorCode.FORBIDDEN)
{
return HttpStatus.FORBIDDEN;
}
if (code == ApiErrorCode.RATE_LIMITED)
{
return HttpStatus.TOO_MANY_REQUESTS;
}
if (code >= 40000 && code < 50000)
{
return HttpStatus.BAD_REQUEST;
}
if (code >= 50000)
{
return HttpStatus.INTERNAL_SERVER_ERROR;
}
return HttpStatus.OK;
}
/** 日志截断,避免超长报文刷屏。 */
private static String brief(String text)
{
if (text == null)
{
return null;
}
return text.length() > 500 ? text.substring(0, 500) + "..." : text;
}
/** 代理侧使用的错误码常量(与文档 §3.2 保持一致)。 */
private static final class ApiErrorCode
{
private ApiErrorCode()
{
}
static final int AUTH_INVALID = 40100;
static final int FORBIDDEN = 40300;
static final int RATE_LIMITED = 42900;
static final int CONFIG_MISSING = 50002;
static final int INTERNAL_ERROR = 50002;
}
}
@@ -0,0 +1,302 @@
package com.ruoyi.web.controller.api;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.train.domain.TrainOpenApp;
import com.ruoyi.train.domain.dto.OpenExamReportDTO;
import com.ruoyi.train.domain.dto.OpenExamReportItemDTO;
import com.ruoyi.train.service.ITrainExamReportService;
import com.ruoyi.train.service.ITrainOpenAppService;
import com.ruoyi.train.service.ITrainOpenTokenService;
/**
* 外部实训系统数据接口(开放接口,面向 VR / 3D 巡检 / 实操 / 帽石考核等系统)
*
* <p>鉴权:除 3.1 登录外均需 {@code Authorization: Bearer {accessToken}},由本控制器自行校验,
* 并通过 {@code @Anonymous} 放行 Spring Security。</p>
*
* <p>响应:统一 {@link OpenApiResult},业务结果用 code 表达(HTTP 恒为 200)。</p>
*
* @author ruoyi
* @date 2026-11-05
*/
@Tag(name = "外部实训系统数据接口")
@Anonymous
@RestController
@RequestMapping("/api/open/v1")
public class TrainOpenApiController
{
private static final Logger log = LoggerFactory.getLogger(TrainOpenApiController.class);
private static final String BEARER_PREFIX = "Bearer ";
@Autowired
private ITrainOpenAppService trainOpenAppService;
@Autowired
private ITrainOpenTokenService trainOpenTokenService;
@Autowired
private ITrainExamReportService trainExamReportService;
/**
* 3.1 登录获取访问令牌
*/
@Operation(summary = "登录获取访问令牌")
@PostMapping("/auth/token")
public OpenApiResult token(@RequestBody Map<String, Object> body, HttpServletRequest request)
{
String reqId = nextReqId();
try
{
if (body == null)
{
throw new ServiceException("参数缺失:appId 与 appSecret 均为必填", 1001);
}
String appId = asText(body.get("appId"));
String appSecret = asText(body.get("appSecret"));
if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(appSecret))
{
throw new ServiceException("参数缺失:appId 与 appSecret 均为必填", 1001);
}
TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId);
if (app == null)
{
throw new ServiceException("应用未注册", 1003);
}
if (app.getStatus() == null || app.getStatus() != 1)
{
throw new ServiceException("应用已停用", 1004);
}
if (!trainOpenAppService.matchesSecret(app, appSecret))
{
throw new ServiceException("应用密钥错误", 1005);
}
Map<String, Object> data = trainOpenTokenService.issueToken(app, IpUtils.getIpAddr(),
request.getHeader("User-Agent"));
Map<String, Object> appInfo = new LinkedHashMap<>();
appInfo.put("appId", app.getAppId());
appInfo.put("appName", app.getAppName());
appInfo.put("appType", app.getAppType());
appInfo.put("orgName", app.getOrgName());
data.put("app", appInfo);
return OpenApiResult.success(data, reqId);
}
catch (ServiceException e)
{
return failure(e, reqId);
}
catch (Exception e)
{
log.error("开放接口登录异常 reqId={}", reqId, e);
return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId);
}
}
/**
* 3.2 在训学员查询
*/
@Operation(summary = "在训学员查询")
@GetMapping("/student/list")
public OpenApiResult studentList(@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestParam(value = "classId", required = false) String classId,
@RequestParam(value = "pageNo", required = false, defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", required = false, defaultValue = "50") Integer pageSize)
{
String reqId = nextReqId();
try
{
String appId = requireAppId(authorization);
if (StringUtils.isNotEmpty(classId) && !trainOpenAppService.hasScope(appId, "CLASS", classId))
{
throw new ServiceException("无权限访问该资源:班级未授权", 2001);
}
if (pageNo == null || pageNo < 1 || pageSize == null || pageSize < 1 || pageSize > 200)
{
throw new ServiceException("参数格式错误:pageNo ≥ 1 且 pageSize ∈ [1,200]", 1002);
}
Map<String, Object> data = trainExamReportService.selectInTrainingStudents(classId, pageNo, pageSize);
return OpenApiResult.success(data, reqId);
}
catch (ServiceException e)
{
return failure(e, reqId);
}
catch (Exception e)
{
log.error("开放接口学员查询异常 reqId={}", reqId, e);
return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId);
}
}
/**
* 3.3 考试成绩批量上报
*/
@Operation(summary = "考试成绩批量上报")
@PostMapping("/exam/report")
public OpenApiResult reportBatch(@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestBody(required = false) OpenExamReportDTO dto)
{
String reqId = nextReqId();
try
{
String appId = requireAppId(authorization);
checkSubjectScope(appId, dto == null ? null : dto.getReports());
Map<String, Object> data = trainExamReportService.reportBatch(appId, dto, reqId);
return OpenApiResult.success(data, reqId);
}
catch (ServiceException e)
{
return failure(e, reqId);
}
catch (Exception e)
{
log.error("开放接口批量上报异常 reqId={}", reqId, e);
return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId);
}
}
/**
* 3.4 实时成绩上报
*/
@Operation(summary = "实时成绩上报")
@PostMapping("/exam/report/realtime")
public OpenApiResult reportRealtime(@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestBody(required = false) OpenExamReportDTO dto)
{
String reqId = nextReqId();
try
{
String appId = requireAppId(authorization);
TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId);
if (app != null && app.getRealtimeEnabled() != null && app.getRealtimeEnabled() != 1)
{
throw new ServiceException("无权限访问该资源:未开启实时上报", 2001);
}
checkSubjectScope(appId, dto == null ? null : dto.getReports());
Map<String, Object> data = trainExamReportService.reportRealtime(appId, dto, reqId);
return OpenApiResult.success(data, reqId);
}
catch (ServiceException e)
{
return failure(e, reqId);
}
catch (Exception e)
{
log.error("开放接口实时上报异常 reqId={}", reqId, e);
return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId);
}
}
/**
* 3.5 上报结果查询
*/
@Operation(summary = "上报结果查询")
@GetMapping("/exam/result")
public OpenApiResult result(@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestParam(value = "batchId", required = false) String batchId,
@RequestParam(value = "reportId", required = false) String reportId,
@RequestParam(value = "periodStart", required = false) Long periodStart,
@RequestParam(value = "periodEnd", required = false) Long periodEnd,
@RequestParam(value = "pageNo", required = false, defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", required = false, defaultValue = "50") Integer pageSize)
{
String reqId = nextReqId();
try
{
String appId = requireAppId(authorization);
Map<String, Object> data = trainExamReportService.queryResult(appId, batchId, reportId, periodStart,
periodEnd, pageNo == null ? 1 : pageNo, pageSize == null ? 50 : pageSize);
return OpenApiResult.success(data, reqId);
}
catch (ServiceException e)
{
return failure(e, reqId);
}
catch (Exception e)
{
log.error("开放接口结果查询异常 reqId={}", reqId, e);
return OpenApiResult.error(OpenApiResult.INTERNAL_ERROR, "服务端内部错误", reqId);
}
}
/**
* 解析并校验令牌,返回 appId
*/
private String requireAppId(String authorization)
{
if (StringUtils.isEmpty(authorization) || !authorization.startsWith(BEARER_PREFIX))
{
throw new ServiceException("令牌无效或已过期", 1006);
}
String appId = trainOpenTokenService.validateToken(authorization.substring(BEARER_PREFIX.length()));
if (StringUtils.isEmpty(appId))
{
throw new ServiceException("令牌无效或已过期", 1006);
}
TrainOpenApp app = trainOpenAppService.selectTrainOpenAppByAppId(appId);
if (app == null || app.getStatus() == null || app.getStatus() != 1)
{
throw new ServiceException("令牌无效或已过期", 1006);
}
return appId;
}
/**
* 科目级授权校验(未配置任何 SUBJECT 授权时视为不限制,便于本地联调)
*/
private void checkSubjectScope(String appId, List<OpenExamReportItemDTO> items)
{
if (items == null || items.isEmpty())
{
return;
}
for (OpenExamReportItemDTO item : items)
{
if (item != null && StringUtils.isNotEmpty(item.getSubjectCode())
&& !trainOpenAppService.hasScope(appId, "SUBJECT", item.getSubjectCode()))
{
throw new ServiceException("无权限访问该资源:科目未授权 " + item.getSubjectCode(), 2001);
}
}
}
private OpenApiResult failure(ServiceException e, String reqId)
{
Integer code = e.getCode();
return OpenApiResult.error(code == null ? OpenApiResult.INTERNAL_ERROR : code, e.getMessage(), reqId);
}
private String asText(Object value)
{
return value == null ? null : value.toString().trim();
}
private String nextReqId()
{
String date = new SimpleDateFormat("yyyyMMdd").format(new Date());
int random = ThreadLocalRandom.current().nextInt(0x100000, 0xFFFFFF);
return "RQ-" + date + "-" + Integer.toHexString(random);
}
}
@@ -0,0 +1,949 @@
package com.ruoyi.web.controller.api;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.device.domain.*;
import com.ruoyi.device.service.*;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainElectronicBoard;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.service.ITrainElectronicBoardService;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.file.FileUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.List;
//import com.ruoyi.system.domain.SysUser;
/**
* 阅读机/听书机对外接口(设备端调用)
*
* BasePath: /api/iot/v1
* 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。
* 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@Tag(name = "阅读机/听书机接口", description = "设备端对外接口:登录/注销/心跳、阅读上报、进度查询、书籍列表与下载回执。登录免鉴权,其余接口走 RuoYi Bearer 鉴权。")
@RequestMapping("/api/iot/v1")
public class TrainReaderApiController extends BaseController
{
@Autowired
private ITrainIotDeviceService iotDeviceService;
@Autowired
private ITrainUserSessionService userSessionService;
@Autowired
private ITrainReadingDurationLogService readingLogService;
@Autowired
private ITrainReadingCurrentProgressService currentProgressService;
@Autowired
private ITrainReadingAlternateProgressService alternateProgressService;
@Autowired
private ITrainBookSnapshotService bookSnapshotService;
@Autowired
private ITrainBookDownloadRecordService bookDownloadRecordService;
@Autowired
private TokenService tokenService;
@Autowired
private ISysUserService sysUserService;
@Autowired
private TrainStudentMapper trainStudentMapper;
@Autowired
private ITrainElectronicBoardService electronicBoardService;
@Value("${ruoyi.profile:}")
private String profileRoot;
/** 图书下载地址前缀(取 iot.face.avatar_profile),用于把 /profile/... 补全为完整 http 地址 */
@Value("${iot.face.avatar_profile:}")
private String avatarProfile;
/**
* 1. 设备学员登录
*/
@Operation(summary = "设备学员登录", description = "校验设备存在/启用 + 学员账号正常,签发 RuoYi token 并创建业务会话,返回 accessToken / sessionId / heartbeatInterval。")
@PostMapping("/device/login")
public AjaxResult login(@RequestBody LoginRequest req)
{
TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId);
if (device == null)
{
return AjaxResult.error("设备不存在");
}
if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2)
{
return AjaxResult.error("设备已禁用");
}
// HMAC 验签应在网关/拦截器完成;此处仅做账号校验
// 设备传入的 userId 实为学员 sId:先查 train_student 取业务账户 userId,再查系统用户
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(req.userId == null ? null : req.userId.toString());
logger.info("student:{}", student);
Long sysUserId = student != null ? student.getUserId() : null;
SysUser sysUser = sysUserId != null ? sysUserService.selectUserById(sysUserId) : null;
logger.info("sysUser:{}", sysUser);
if (sysUser == null || !"0".equals(sysUser.getStatus()))
{
return AjaxResult.error("学员账号不存在或已停用");
}
LoginUser loginUser = new LoginUser();
loginUser.setUser(sysUser);
loginUser.setUserId(sysUser.getUserId());
//loginUser.setUsername(sysUser.getUserName());
String token = tokenService.createToken(loginUser);
String tokenKey = CacheConstants.LOGIN_TOKEN_KEY + token;
String sessionId = userSessionService.createSession(sysUserId, req.deviceId, tokenKey, new Date());
LoginData data = new LoginData();
data.accessToken = token;
data.sessionId = sessionId;
data.heartbeatInterval = device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60;
return AjaxResult.success(data);
}
/**
* 2. 设备注销
*/
@Operation(summary = "设备注销", description = "业务会话置 LOGOUT,并撤销 RuoYi Redis 中的 token。")
@PostMapping("/device/logout")
public AjaxResult logout(@RequestBody LogoutRequest req, HttpServletRequest request)
{
Long userId = getUserId();
userSessionService.logoutSession(userId, req.deviceId);
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer "))
{
String token = auth.substring(7).trim();
tokenService.delLoginUser(token);
}
LogoutData data = new LogoutData();
data.result = true;
data.tokenRevoked = true;
return AjaxResult.success(data);
}
/**
* 3. 设备心跳
*/
@Operation(summary = "设备心跳", description = "更新设备最后心跳时间,返回下次心跳间隔与平台下行指令数组。")
@PostMapping("/device/heartbeat")
public AjaxResult heartbeat(@RequestBody HeartbeatRequest req)
{
Integer status = "ONLINE".equals(req.status) ? 1 : null;
iotDeviceService.updateHeartbeatByDeviceId(req.deviceId, new Date(), status);
TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId);
HeartbeatData data = new HeartbeatData();
data.nextInterval = device != null && device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60;
data.commands = new ArrayList<>();
return AjaxResult.success(data);
}
/**
* 4. 阅读记录单条上报
*/
@Operation(summary = "阅读记录单条上报", description = "幂等上报(recordId 去重);ACTIVE 会话更新主进度,REPLACED 会话写入备选冲突进度。返回 ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED。")
@PostMapping("/reading/record")
public AjaxResult reportRecord(@RequestBody ReadingRecordRequest req)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId);
TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, req);
String resultCode = readingLogService.reportReadingRecord(log);
long studySeconds = log.getDurationSec() != null ? log.getDurationSec().longValue() : 0;
long totalSeconds = readingLogService.sumDurationSeconds(userId, req.mode);
RecordData data = new RecordData();
data.result = resultCode;
data.studyMinutes = (int) (studySeconds / 60);
data.totalMinutes = (int) (totalSeconds / 60);
return AjaxResult.success(data);
}
/**
* 5. 阅读记录批量上报(断网补传)
*/
@Operation(summary = "阅读记录批量上报", description = "单批最多 200 条,每条 recordId 独立幂等,逐条走单条上报逻辑。")
@PostMapping("/reading/record/batch")
public AjaxResult reportRecordBatch(@RequestBody ReadingRecordBatchRequest req)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId);
List<ReadingRecordItemResult> itemResults = new ArrayList<>();
if (req.records != null)
{
for (ReadingRecordRequest r : req.records)
{
TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, r);
String rc = readingLogService.reportReadingRecord(log);
itemResults.add(new ReadingRecordItemResult(r.recordId, rc));
}
}
BatchData data = new BatchData();
data.itemResults = itemResults;
return AjaxResult.success(data);
}
private TrainReadingDurationLog buildLog(Long userId, String deviceId, TrainUserSession session, ReadingRecordRequest req)
{
TrainReadingDurationLog log = new TrainReadingDurationLog();
log.setUserId(userId);
log.setDeviceId(deviceId);
log.setSessionId(session != null ? session.getSessionId() : null);
log.setRecordId(req.recordId);
log.setBookId(req.bookId);
log.setBookVersion(req.bookVersion);
log.setMode(req.mode);
log.setRecordType(req.recordType);
log.setDurationSec(req.durationSec);
log.setPageFrom(req.pageFrom);
log.setPageTo(req.pageTo);
log.setProgress(req.progress);
log.setStartTime(req.startTime != null ? new Date(req.startTime) : null);
log.setEndTime(req.endTime != null ? new Date(req.endTime) : null);
return log;
}
/**
* 6. 获取学员阅读汇总与续读进度
*/
@Operation(summary = "获取学员阅读汇总与续读进度", description = "返回累计阅读/听书时长,以及每本书的主进度 + 备选冲突进度。REPLACED 会话禁止调用。")
@GetMapping("/reading/progress")
public AjaxResult progress(@Parameter(description = "设备ID") @RequestParam String deviceId)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, deviceId);
if (session == null || session.getSessionStatus() != 1)
{
return AjaxResult.error("当前会话状态不允许查询进度(被替换设备仅允许补交上报)");
}
long readSec = readingLogService.sumDurationSeconds(userId, "read");
long audioSec = readingLogService.sumDurationSeconds(userId, "audio");
List<TrainReadingCurrentProgress> mains = currentProgressService.selectListByUserId(userId);
List<BookRecord> bookRecords = new ArrayList<>();
if (mains != null)
{
for (TrainReadingCurrentProgress cp : mains)
{
BookRecord br = new BookRecord();
br.bookId = cp.getBookId();
br.bookVersion = cp.getBookVersion();
MainProgress mp = new MainProgress();
mp.mode = cp.getMode();
mp.pageNo = cp.getPageNo();
mp.sentenceIndex = cp.getSentenceIndex();
mp.progress = cp.getProgress();
mp.updateTime = cp.getUpdateTime() != null ? cp.getUpdateTime().getTime() : null;
br.mainProgress = mp;
List<TrainReadingAlternateProgress> alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode());
List<AlternateProgress> altList = new ArrayList<>();
if (alts != null)
{
for (TrainReadingAlternateProgress a : alts)
{
AlternateProgress ap = new AlternateProgress();
ap.mode = a.getMode();
ap.bookVersion = a.getBookVersion();
ap.pageNo = a.getPageNo();
ap.sentenceIndex = a.getSentenceIndex();
ap.progress = a.getProgress();
ap.createTime = a.getCreateTime() != null ? a.getCreateTime().getTime() : null;
altList.add(ap);
}
}
br.alternateProgressList = altList;
bookRecords.add(br);
}
}
ProgressData data = new ProgressData();
// 由当前登录的 sys userId 反查 train_student,将学员 sId 写入返回结果
TrainStudent student = trainStudentMapper.selectTrainStudentByUserId(userId);
data.userId = student != null ? student.getsId() : null;
data.totalReadSeconds = readSec;
data.totalAudioSeconds = audioSec;
data.bookRecords = bookRecords;
return AjaxResult.success(data);
}
/**
* 设备列表:汇总电子班牌(train_electronic_board)与读书机(train_iot_device)。
* 以 board_id / device_id 作为统一的 device_id;device_type 分别为 board / reader;班牌附带 board_sn。
*/
@GetMapping("/devicelist")
@Operation(summary = "设备列表", description = "汇总电子班牌(train_electronic_board)与读书机(train_iot_device):以 board_id / device_id 作为 device_id,device_type 分别为 board / reader,班牌附带 board_sn。")
public AjaxResult deviceList()
{
List<DeviceListItem> result = new ArrayList<>();
// 电子班牌
List<TrainElectronicBoard> boards = electronicBoardService.selectTrainElectronicBoardList(new TrainElectronicBoard());
if (boards != null)
{
for (TrainElectronicBoard b : boards)
{
DeviceListItem item = new DeviceListItem();
item.deviceId = b.getBoardId();
item.deviceType = "board";
item.boardSn = b.getBoardSn();
result.add(item);
}
}
// 读书机
List<TrainIotDevice> readers = iotDeviceService.selectTrainIotDeviceList(new TrainIotDevice());
if (readers != null)
{
for (TrainIotDevice d : readers)
{
DeviceListItem item = new DeviceListItem();
item.deviceId = d.getDeviceId();
item.deviceType = "reader";
result.add(item);
}
}
return AjaxResult.success(result);
}
/**
* 7. 获取可阅读书籍列表(仅上架)
*/
@Operation(summary = "获取可阅读书籍列表", description = "只返回 is_on_shelf=1 的上架图书,支持按 bookCategory 过滤。")
@GetMapping("/book/list")
public AjaxResult bookList(@Parameter(description = "设备ID") @RequestParam String deviceId,
@Parameter(description = "分类过滤:SAFETY/REGULATION/LANGUAGE,可空") @RequestParam(required = false) String bookCategory)
{
List<TrainBookSnapshot> list = bookSnapshotService.selectOnShelfList(bookCategory);
List<BookItem> items = new ArrayList<>();
if (list != null)
{
for (TrainBookSnapshot b : list)
{
BookItem bi = new BookItem();
bi.bookId = b.getBookId();
bi.bookTitle = b.getBookTitle();
bi.bookCategory = b.getBookCategory();
bi.bookSummary = b.getBookSummary();
bi.bookVersion = b.getBookVersion();
String coverPath = b.getBookCover();
if (coverPath != null && !coverPath.isEmpty() && coverPath.startsWith(Constants.RESOURCE_PREFIX))
{
try
{
String realPath = RuoYiConfig.getProfile() + FileUtils.stripPrefix(coverPath);
byte[] coverData = Files.readAllBytes(Paths.get(realPath));
String lower = coverPath.toLowerCase();
String mime = "image/jpeg";
if (lower.endsWith(".png")) mime = "image/png";
else if (lower.endsWith(".gif")) mime = "image/gif";
else if (lower.endsWith(".webp")) mime = "image/webp";
else if (lower.endsWith(".bmp")) mime = "image/bmp";
bi.bookCover = "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(coverData);
}
catch (Exception e)
{
// 本地读取失败(文件缺失/中文路径无法定位):回退为补全前缀并编码的可访问绝对地址
bi.bookCover = toAccessibleCoverUrl(coverPath);
logger.warn("图书封面本地读取失败,回退为绝对地址, bookId={}, coverPath={}, msg={}",
b.getBookId(), coverPath, e.getMessage());
}
}
else
{
// 非 /profile 地址:绝对 URL 仅做中文/空格编码,其余原样
bi.bookCover = toAccessibleCoverUrl(coverPath);
}
bi.fileSize = b.getFileSize();
bi.fileMd5 = b.getFileMd5();
bi.isOnShelf = b.getIsOnShelf() != null && b.getIsOnShelf() == 1;
bi.publishTime = b.getPublishTime() != null ? b.getPublishTime().getTime() : null;
items.add(bi);
}
}
BookListData data = new BookListData();
data.total = items.size();
data.list = items;
return AjaxResult.success(data);
}
/**
* 将图书封面地址转换为设备可访问的绝对地址。
* <p>
* /profile/... 相对地址补全 iot.face.avatar_profile 前缀后做 UTF-8 百分号编码;
* 已是 http(s) 的地址只做编码;其余地址原样返回。
*
* @param coverPath 封面原始路径
* @return 可访问的封面地址
*/
private String toAccessibleCoverUrl(String coverPath)
{
if (coverPath == null || coverPath.isEmpty())
{
return coverPath;
}
return encodeHttpUrl(withAvatarProfilePrefix(coverPath));
}
/**
* 8. PDF 书籍下载(支持 Range 断点续传;下架图书禁止下载)
*/
@Operation(summary = "PDF 书籍下载", description = "支持 HTTP Range 断点续传;下架图书返回业务错误禁止下载。")
@GetMapping("/book/download")
public void bookDownload(@Parameter(description = "设备ID") @RequestParam String deviceId,
@Parameter(description = "图书ID") @RequestParam String bookId,
@Parameter(description = "图书版本 BV-xx") @RequestParam String bookVersion,
HttpServletRequest request, HttpServletResponse response) throws IOException
{
TrainBookSnapshot book = bookSnapshotService.selectByBookIdAndVersion(bookId, bookVersion);
if (book == null)
{
writeJsonError(response, "图书或版本不存在");
return;
}
if (book.getIsOnShelf() == null || book.getIsOnShelf() != 1)
{
writeJsonError(response, "图书已下架,禁止下载");
return;
}
String url = book.getDownloadUrl();
if (url == null || url.isEmpty())
{
writeJsonError(response, "文件地址不存在");
return;
}
// /profile/upload/...pdf 相对地址补全 iot.face.avatar_profile 的 http 前缀
url = withAvatarProfilePrefix(url);
// 中文/空格等非 ASCII 字符需做 UTF-8 百分号编码,否则 HttpURLConnection 会把原始字节写入请求行,对端返回 400
url = encodeHttpUrl(url);
streamFile(url, book.getFileMd5(), book.getFileSize(), book.getBookTitle(), request, response);
}
/**
* 对 http(s) 地址中的中文、空格等非 ASCII 字符做 UTF-8 百分号编码。
* <p>
* 仅处理 scheme://host 之后的部分;已编码的 %XX 序列、以及 URL 合法字符原样保留,
* 非 http(s) 地址(本地 /profile/... 或绝对路径)原样返回,避免影响本地文件读取。
*
* @param url 原始地址
* @return 编码后的地址
*/
private String encodeHttpUrl(String url)
{
if (url == null)
{
return null;
}
String lower = url.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://"))
{
return url;
}
int pathStart = url.indexOf('/', url.indexOf("://") + 3);
if (pathStart < 0)
{
return url;
}
String tail = url.substring(pathStart);
StringBuilder encoded = new StringBuilder(url.length() + 32);
encoded.append(url, 0, pathStart);
for (int i = 0; i < tail.length(); i++)
{
char c = tail.charAt(i);
if (c == '%' && i + 2 < tail.length()
&& isHexDigit(tail.charAt(i + 1)) && isHexDigit(tail.charAt(i + 2)))
{
// 已是合法百分号编码,原样保留,避免二次编码
encoded.append(tail, i, i + 3);
i += 2;
}
else if (isUrlSafeChar(c))
{
encoded.append(c);
}
else
{
int codePoint = tail.codePointAt(i);
if (codePoint > Character.MAX_VALUE)
{
i++;
}
byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
for (byte b : bytes)
{
encoded.append('%').append(String.format("%02X", b & 0xff));
}
}
}
return encoded.toString();
}
private static boolean isHexDigit(char c)
{
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
private static boolean isUrlSafeChar(char c)
{
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))
{
return true;
}
return c == '-' || c == '_' || c == '.' || c == '~'
|| c == '/' || c == '?' || c == '&' || c == '=' || c == ':'
|| c == '@' || c == '+' || c == '!' || c == '$' || c == '\''
|| c == '(' || c == ')' || c == '*' || c == ',' || c == ';';
}
/**
* 为 /profile/ 开头的相对下载地址拼接 iot.face.avatar_profile 配置的 http 前缀。
* <p>
* 已带 http(s) 协议头的地址、非 /profile/ 的相对地址、以及未配置前缀时,均原样返回。
*
* @param url 原始下载地址
* @return 补全后的下载地址
*/
private String withAvatarProfilePrefix(String url)
{
if (url == null || !url.startsWith("/profile/"))
{
return url;
}
String prefix = avatarProfile == null ? "" : avatarProfile.trim();
if (prefix.isEmpty())
{
return url;
}
if (!prefix.toLowerCase().startsWith("http://") && !prefix.toLowerCase().startsWith("https://"))
{
prefix = "http://" + prefix;
}
while (prefix.endsWith("/"))
{
prefix = prefix.substring(0, prefix.length() - 1);
}
return prefix + url;
}
/**
* 9. 下载完成回执上报
*/
@Operation(summary = "下载完成回执上报", description = "设备上报下载校验结果,落库 train_book_download_record,返回 deviceReady 与 recordId。")
@PostMapping("/book/download/ack")
public AjaxResult downloadAck(@RequestBody DownloadAckRequest req)
{
Long userId = getUserId();
TrainBookDownloadRecord record = new TrainBookDownloadRecord();
record.setUserId(userId);
record.setDeviceId(req.deviceId);
record.setBookId(req.bookId);
record.setBookVersion(req.bookVersion);
record.setFileMd5(req.fileMd5);
record.setResult(req.result != null ? req.result : "SUCCESS");
record.setErrMsg(req.errMsg);
record.setDownloadTime(req.timestamp != null ? new Date(req.timestamp) : new Date());
bookDownloadRecordService.insertTrainBookDownloadRecord(record);
DownloadAckData data = new DownloadAckData();
data.deviceReady = true;
data.recordId = record.getId();
return AjaxResult.success(data);
}
private void writeJsonError(HttpServletResponse response, String msg) throws IOException
{
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}");
}
private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.setHeader("Accept-Ranges", "bytes");
if (md5 != null && !md5.isEmpty())
{
response.setHeader("Content-MD5", md5);
}
// 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问)
response.setContentType(guessContentType(url));
String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf";
try
{
String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc);
}
catch (Exception ignored) {}
boolean isHttp = url.startsWith("http://") || url.startsWith("https://");
InputStream in = null;
long total = -1;
if (isHttp)
{
// 完整 http(s) 远程地址(如 OSS):直接拉取
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestProperty("User-Agent", "ruoyi-device");
in = conn.getInputStream();
String cl = conn.getHeaderField("Content-Length");
if (cl != null && !cl.isEmpty())
{
try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {}
}
}
else if (url.startsWith("/"))
{
// 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401)
if (url.startsWith("/profile/"))
{
// /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致
File f = new File(profileRoot, url.substring("/profile".length()));
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
else
{
// 其他相对路径(测试用 /books/...)从 classpath 静态资源读取
ClassPathResource res = new ClassPathResource("static" + url);
if (!res.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = res.contentLength();
in = res.getInputStream();
}
}
else
{
// 绝对本地路径(遗留用法)
File f = new File(url);
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
try
{
String range = request.getHeader("Range");
long start = 0;
long end = total - 1;
boolean hasRange = false;
if (range != null && range.startsWith("bytes="))
{
String[] parts = range.substring(6).split("-");
try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {}
if (parts.length > 1 && !parts[1].isEmpty())
{
try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {}
}
hasRange = true;
}
if (hasRange)
{
if (end < 0 || end >= total)
{
end = total - 1;
}
if (start > end)
{
start = end;
}
long skipped = in.skip(start);
while (skipped < start)
{
long s = in.skip(start - skipped);
if (s <= 0)
{
break;
}
skipped += s;
}
long len = end - start + 1;
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total);
response.setContentLengthLong(len);
copy(in, response.getOutputStream(), len);
}
else
{
response.setStatus(HttpServletResponse.SC_OK);
if (total > 0)
{
response.setContentLengthLong(total);
}
copy(in, response.getOutputStream(), -1);
}
}
finally
{
if (in != null)
{
in.close();
}
}
}
private String guessContentType(String url)
{
if (url != null && url.toLowerCase().endsWith(".pdf"))
{
return "application/pdf";
}
return "application/octet-stream";
}
private void copy(InputStream in, OutputStream out, long limit) throws IOException
{
byte[] buf = new byte[8192];
long remaining = limit;
int n;
while ((n = in.read(buf)) != -1)
{
if (limit > 0)
{
if (remaining <= 0)
{
break;
}
int toWrite = (int) Math.min(n, remaining);
out.write(buf, 0, toWrite);
remaining -= toWrite;
}
else
{
out.write(buf, 0, n);
}
}
out.flush();
}
// ================= 请求/响应 DTO(内嵌,避免新增文件) =================
public static class LoginRequest
{
public String deviceId;
public String userId;
public String firmware;
public String appVersion;
public NetInfo netInfo;
public Long timestamp;
public String nonce;
}
public static class NetInfo
{
public String ip;
public String mac;
public Integer rssi;
}
public static class LogoutRequest
{
public String deviceId;
public String reason;
public Long timestamp;
public String nonce;
}
public static class HeartbeatRequest
{
public String deviceId;
public String status;
public Metrics metrics;
public String configVersion;
public String personVersion;
public Long timestamp;
public String nonce;
}
public static class Metrics
{
public Integer cpu;
public Integer mem;
public Long diskFree;
}
public static class ReadingRecordRequest
{
public String deviceId;
public String recordId;
public String bookId;
public String bookVersion;
public String mode;
public String recordType;
public Long startTime;
public Long endTime;
public Integer durationSec;
public Integer pageFrom;
public Integer pageTo;
public BigDecimal progress;
}
public static class ReadingRecordBatchRequest
{
public String deviceId;
public String batchId;
public Long timestamp;
public String nonce;
public List<ReadingRecordRequest> records;
}
public static class DownloadAckRequest
{
public String deviceId;
public String bookId;
public String bookVersion;
public String fileMd5;
public String result;
public String errMsg;
public Long timestamp;
public String nonce;
}
public static class LoginData
{
public String accessToken;
public String sessionId;
public Integer heartbeatInterval;
}
public static class LogoutData
{
public Boolean result;
public Boolean tokenRevoked;
}
public static class HeartbeatData
{
public Integer nextInterval;
public List<Object> commands;
}
public static class RecordData
{
public String result;
public Integer studyMinutes;
public Integer totalMinutes;
}
public static class ReadingRecordItemResult
{
public String recordId;
public String result;
public ReadingRecordItemResult(String recordId, String result)
{
this.recordId = recordId;
this.result = result;
}
}
public static class BatchData
{
public List<ReadingRecordItemResult> itemResults;
}
public static class MainProgress
{
public String mode;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long updateTime;
}
public static class AlternateProgress
{
public String mode;
public String bookVersion;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long createTime;
}
public static class BookRecord
{
public String bookId;
public String bookVersion;
public MainProgress mainProgress;
public List<AlternateProgress> alternateProgressList;
}
public static class ProgressData
{
public String userId;
public long totalReadSeconds;
public long totalAudioSeconds;
public List<BookRecord> bookRecords;
}
public static class BookItem
{
public String bookId;
public String bookTitle;
public String bookCategory;
public String bookSummary;
public String bookVersion;
public String bookCover;
public Long fileSize;
public String fileMd5;
public Boolean isOnShelf;
public Long publishTime;
}
public static class BookListData
{
public int total;
public List<BookItem> list;
}
public static class DownloadAckData
{
public Boolean deviceReady;
public Long recordId;
}
public static class DeviceListItem
{
public String deviceId;
public String deviceType;
public String boardSn;
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainBookSnapshot;
import com.ruoyi.device.service.ITrainBookSnapshotService;
/**
* 图书快照Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/booksnapshot")
public class TrainBookSnapshotController extends BaseController
{
@Autowired
private ITrainBookSnapshotService trainBookSnapshotService;
@PreAuthorize("@ss.hasPermi('device:booksnapshot:list')")
@GetMapping("/list")
public TableDataInfo list(TrainBookSnapshot trainBookSnapshot)
{
startPage();
List<TrainBookSnapshot> list = trainBookSnapshotService.selectTrainBookSnapshotList(trainBookSnapshot);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:booksnapshot:export')")
@Log(title = "图书快照", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainBookSnapshot trainBookSnapshot)
{
List<TrainBookSnapshot> list = trainBookSnapshotService.selectTrainBookSnapshotList(trainBookSnapshot);
ExcelUtil<TrainBookSnapshot> util = new ExcelUtil<TrainBookSnapshot>(TrainBookSnapshot.class);
util.exportExcel(response, list, "图书快照数据");
}
@PreAuthorize("@ss.hasPermi('device:booksnapshot:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainBookSnapshotService.selectTrainBookSnapshotById(id));
}
@PreAuthorize("@ss.hasPermi('device:booksnapshot:add')")
@Log(title = "图书快照", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainBookSnapshot trainBookSnapshot)
{
return toAjax(trainBookSnapshotService.insertTrainBookSnapshot(trainBookSnapshot));
}
@PreAuthorize("@ss.hasPermi('device:booksnapshot:edit')")
@Log(title = "图书快照", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainBookSnapshot trainBookSnapshot)
{
return toAjax(trainBookSnapshotService.updateTrainBookSnapshot(trainBookSnapshot));
}
@PreAuthorize("@ss.hasPermi('device:booksnapshot:remove')")
@Log(title = "图书快照", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainBookSnapshotService.deleteTrainBookSnapshotByIds(ids));
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainCheckinTerminal;
import com.ruoyi.device.service.ITrainCheckinTerminalService;
/**
* 报到一体机(自助报到终端)Controller
*
* @author ruoyi
* @date 2026-10-24
*/
@RestController
@RequestMapping("/device/checkinterminal")
public class TrainCheckinTerminalController extends BaseController
{
@Autowired
private ITrainCheckinTerminalService trainCheckinTerminalService;
@PreAuthorize("@ss.hasPermi('device:checkinterminal:list')")
@GetMapping("/list")
public TableDataInfo list(TrainCheckinTerminal trainCheckinTerminal)
{
startPage();
List<TrainCheckinTerminal> list = trainCheckinTerminalService.selectTrainCheckinTerminalList(trainCheckinTerminal);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:checkinterminal:export')")
@Log(title = "报到一体机", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainCheckinTerminal trainCheckinTerminal)
{
List<TrainCheckinTerminal> list = trainCheckinTerminalService.selectTrainCheckinTerminalList(trainCheckinTerminal);
ExcelUtil<TrainCheckinTerminal> util = new ExcelUtil<TrainCheckinTerminal>(TrainCheckinTerminal.class);
util.exportExcel(response, list, "报到一体机数据");
}
@PreAuthorize("@ss.hasPermi('device:checkinterminal:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainCheckinTerminalService.selectTrainCheckinTerminalById(id));
}
@PreAuthorize("@ss.hasPermi('device:checkinterminal:add')")
@Log(title = "报到一体机", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainCheckinTerminal trainCheckinTerminal)
{
return toAjax(trainCheckinTerminalService.insertTrainCheckinTerminal(trainCheckinTerminal));
}
@PreAuthorize("@ss.hasPermi('device:checkinterminal:edit')")
@Log(title = "报到一体机", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainCheckinTerminal trainCheckinTerminal)
{
return toAjax(trainCheckinTerminalService.updateTrainCheckinTerminal(trainCheckinTerminal));
}
@PreAuthorize("@ss.hasPermi('device:checkinterminal:remove')")
@Log(title = "报到一体机", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainCheckinTerminalService.deleteTrainCheckinTerminalByIds(ids));
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainIotDevice;
import com.ruoyi.device.service.ITrainIotDeviceService;
/**
* 读书机设备档案Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/iotdevice")
public class TrainIotDeviceController extends BaseController
{
@Autowired
private ITrainIotDeviceService trainIotDeviceService;
@PreAuthorize("@ss.hasPermi('device:iotdevice:list')")
@GetMapping("/list")
public TableDataInfo list(TrainIotDevice trainIotDevice)
{
startPage();
List<TrainIotDevice> list = trainIotDeviceService.selectTrainIotDeviceList(trainIotDevice);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:iotdevice:export')")
@Log(title = "读书机设备档案", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainIotDevice trainIotDevice)
{
List<TrainIotDevice> list = trainIotDeviceService.selectTrainIotDeviceList(trainIotDevice);
ExcelUtil<TrainIotDevice> util = new ExcelUtil<TrainIotDevice>(TrainIotDevice.class);
util.exportExcel(response, list, "读书机设备档案数据");
}
@PreAuthorize("@ss.hasPermi('device:iotdevice:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainIotDeviceService.selectTrainIotDeviceById(id));
}
@PreAuthorize("@ss.hasPermi('device:iotdevice:add')")
@Log(title = "读书机设备档案", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainIotDevice trainIotDevice)
{
return toAjax(trainIotDeviceService.insertTrainIotDevice(trainIotDevice));
}
@PreAuthorize("@ss.hasPermi('device:iotdevice:edit')")
@Log(title = "读书机设备档案", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainIotDevice trainIotDevice)
{
return toAjax(trainIotDeviceService.updateTrainIotDevice(trainIotDevice));
}
@PreAuthorize("@ss.hasPermi('device:iotdevice:remove')")
@Log(title = "读书机设备档案", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainIotDeviceService.deleteTrainIotDeviceByIds(ids));
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainReadingAlternateProgress;
import com.ruoyi.device.service.ITrainReadingAlternateProgressService;
/**
* 备选冲突续读进度Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/readingalternateprogress")
public class TrainReadingAlternateProgressController extends BaseController
{
@Autowired
private ITrainReadingAlternateProgressService trainReadingAlternateProgressService;
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:list')")
@GetMapping("/list")
public TableDataInfo list(TrainReadingAlternateProgress trainReadingAlternateProgress)
{
startPage();
List<TrainReadingAlternateProgress> list = trainReadingAlternateProgressService.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:export')")
@Log(title = "备选冲突续读进度", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainReadingAlternateProgress trainReadingAlternateProgress)
{
List<TrainReadingAlternateProgress> list = trainReadingAlternateProgressService.selectTrainReadingAlternateProgressList(trainReadingAlternateProgress);
ExcelUtil<TrainReadingAlternateProgress> util = new ExcelUtil<TrainReadingAlternateProgress>(TrainReadingAlternateProgress.class);
util.exportExcel(response, list, "备选冲突续读进度数据");
}
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainReadingAlternateProgressService.selectTrainReadingAlternateProgressById(id));
}
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:add')")
@Log(title = "备选冲突续读进度", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainReadingAlternateProgress trainReadingAlternateProgress)
{
return toAjax(trainReadingAlternateProgressService.insertTrainReadingAlternateProgress(trainReadingAlternateProgress));
}
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:edit')")
@Log(title = "备选冲突续读进度", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainReadingAlternateProgress trainReadingAlternateProgress)
{
return toAjax(trainReadingAlternateProgressService.updateTrainReadingAlternateProgress(trainReadingAlternateProgress));
}
@PreAuthorize("@ss.hasPermi('device:readingalternateprogress:remove')")
@Log(title = "备选冲突续读进度", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainReadingAlternateProgressService.deleteTrainReadingAlternateProgressByIds(ids));
}
}
@@ -0,0 +1,735 @@
package com.ruoyi.web.controller.device;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.device.domain.TrainBookDownloadRecord;
import com.ruoyi.device.domain.TrainBookSnapshot;
import com.ruoyi.device.domain.TrainIotDevice;
import com.ruoyi.device.domain.TrainReadingAlternateProgress;
import com.ruoyi.device.domain.TrainReadingCurrentProgress;
import com.ruoyi.device.domain.TrainReadingDurationLog;
import com.ruoyi.device.domain.TrainUserSession;
import com.ruoyi.device.service.ITrainBookDownloadRecordService;
import com.ruoyi.device.service.ITrainBookSnapshotService;
import com.ruoyi.device.service.ITrainIotDeviceService;
import com.ruoyi.device.service.ITrainReadingAlternateProgressService;
import com.ruoyi.device.service.ITrainReadingCurrentProgressService;
import com.ruoyi.device.service.ITrainReadingDurationLogService;
import com.ruoyi.device.service.ITrainUserSessionService;
import com.ruoyi.framework.web.service.TokenService;
import java.net.URLEncoder;
import org.springframework.core.io.ClassPathResource;
//import com.ruoyi.system.domain.SysUser;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.service.ISysUserService;
/**
* 阅读机/听书机对外接口(设备端调用)
*
* BasePath: /api/iot/v1
* 安全约束:登录接口需 HMAC 签名(X-Signature/X-Nonce);其余接口复用 RuoYi Bearer 鉴权。
* 说明:HMAC 验签依赖设备原始密钥,本实现仅做设备存在/启用校验,生产环境建议在网关/拦截器中补充 X-Signature 校验。
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@Tag(name = "阅读机/听书机接口", description = "设备端对外接口:登录/注销/心跳、阅读上报、进度查询、书籍列表与下载回执。登录免鉴权,其余接口走 RuoYi Bearer 鉴权。")
@RequestMapping("/api/iot/v0")
public class TrainReadingApiController extends BaseController
{
@Autowired
private ITrainIotDeviceService iotDeviceService;
@Autowired
private ITrainUserSessionService userSessionService;
@Autowired
private ITrainReadingDurationLogService readingLogService;
@Autowired
private ITrainReadingCurrentProgressService currentProgressService;
@Autowired
private ITrainReadingAlternateProgressService alternateProgressService;
@Autowired
private ITrainBookSnapshotService bookSnapshotService;
@Autowired
private ITrainBookDownloadRecordService bookDownloadRecordService;
@Autowired
private TokenService tokenService;
@Autowired
private ISysUserService sysUserService;
@Value("${ruoyi.profile:}")
private String profileRoot;
/**
* 1. 设备学员登录
*/
@Operation(summary = "设备学员登录", description = "校验设备存在/启用 + 学员账号正常,签发 RuoYi token 并创建业务会话,返回 accessToken / sessionId / heartbeatInterval。")
@PostMapping("/device/login")
public AjaxResult login(@RequestBody LoginRequest req)
{
TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId);
if (device == null)
{
return AjaxResult.error("设备不存在");
}
if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2)
{
return AjaxResult.error("设备已禁用");
}
// HMAC 验签应在网关/拦截器完成;此处仅做账号校验
SysUser sysUser = sysUserService.selectUserById(req.userId);
if (sysUser == null || !"0".equals(sysUser.getStatus()))
{
return AjaxResult.error("学员账号不存在或已停用");
}
LoginUser loginUser = new LoginUser();
loginUser.setUser(sysUser);
loginUser.setUserId(sysUser.getUserId());
//loginUser.setUsername(sysUser.getUserName());
String token = tokenService.createToken(loginUser);
String tokenKey = CacheConstants.LOGIN_TOKEN_KEY + token;
String sessionId = userSessionService.createSession(req.userId, req.deviceId, tokenKey, new Date());
LoginData data = new LoginData();
data.accessToken = token;
data.sessionId = sessionId;
data.heartbeatInterval = device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60;
return AjaxResult.success(data);
}
/**
* 2. 设备注销
*/
@Operation(summary = "设备注销", description = "业务会话置 LOGOUT,并撤销 RuoYi Redis 中的 token。")
@PostMapping("/device/logout")
public AjaxResult logout(@RequestBody LogoutRequest req, HttpServletRequest request)
{
Long userId = getUserId();
userSessionService.logoutSession(userId, req.deviceId);
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer "))
{
String token = auth.substring(7).trim();
tokenService.delLoginUser(token);
}
LogoutData data = new LogoutData();
data.result = true;
data.tokenRevoked = true;
return AjaxResult.success(data);
}
/**
* 3. 设备心跳
*/
@Operation(summary = "设备心跳", description = "更新设备最后心跳时间,返回下次心跳间隔与平台下行指令数组。")
@PostMapping("/device/heartbeat")
public AjaxResult heartbeat(@RequestBody HeartbeatRequest req)
{
Integer status = "ONLINE".equals(req.status) ? 1 : null;
iotDeviceService.updateHeartbeatByDeviceId(req.deviceId, new Date(), status);
TrainIotDevice device = iotDeviceService.selectTrainIotDeviceByDeviceId(req.deviceId);
HeartbeatData data = new HeartbeatData();
data.nextInterval = device != null && device.getHeartbeatInterval() != null ? device.getHeartbeatInterval() : 60;
data.commands = new ArrayList<>();
return AjaxResult.success(data);
}
/**
* 4. 阅读记录单条上报
*/
@Operation(summary = "阅读记录单条上报", description = "幂等上报(recordId 去重);ACTIVE 会话更新主进度,REPLACED 会话写入备选冲突进度。返回 ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED。")
@PostMapping("/reading/record")
public AjaxResult reportRecord(@RequestBody ReadingRecordRequest req)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId);
TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, req);
String resultCode = readingLogService.reportReadingRecord(log);
long studySeconds = log.getDurationSec() != null ? log.getDurationSec().longValue() : 0;
long totalSeconds = readingLogService.sumDurationSeconds(userId, req.mode);
RecordData data = new RecordData();
data.result = resultCode;
data.studyMinutes = (int) (studySeconds / 60);
data.totalMinutes = (int) (totalSeconds / 60);
return AjaxResult.success(data);
}
/**
* 5. 阅读记录批量上报(断网补传)
*/
@Operation(summary = "阅读记录批量上报", description = "单批最多 200 条,每条 recordId 独立幂等,逐条走单条上报逻辑。")
@PostMapping("/reading/record/batch")
public AjaxResult reportRecordBatch(@RequestBody ReadingRecordBatchRequest req)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, req.deviceId);
List<ReadingRecordItemResult> itemResults = new ArrayList<>();
if (req.records != null)
{
for (ReadingRecordRequest r : req.records)
{
TrainReadingDurationLog log = buildLog(userId, req.deviceId, session, r);
String rc = readingLogService.reportReadingRecord(log);
itemResults.add(new ReadingRecordItemResult(r.recordId, rc));
}
}
BatchData data = new BatchData();
data.itemResults = itemResults;
return AjaxResult.success(data);
}
private TrainReadingDurationLog buildLog(Long userId, String deviceId, TrainUserSession session, ReadingRecordRequest req)
{
TrainReadingDurationLog log = new TrainReadingDurationLog();
log.setUserId(userId);
log.setDeviceId(deviceId);
log.setSessionId(session != null ? session.getSessionId() : null);
log.setRecordId(req.recordId);
log.setBookId(req.bookId);
log.setBookVersion(req.bookVersion);
log.setMode(req.mode);
log.setRecordType(req.recordType);
log.setDurationSec(req.durationSec);
log.setPageFrom(req.pageFrom);
log.setPageTo(req.pageTo);
log.setProgress(req.progress);
log.setStartTime(req.startTime != null ? new Date(req.startTime) : null);
log.setEndTime(req.endTime != null ? new Date(req.endTime) : null);
return log;
}
/**
* 6. 获取学员阅读汇总与续读进度
*/
@Operation(summary = "获取学员阅读汇总与续读进度", description = "返回累计阅读/听书时长,以及每本书的主进度 + 备选冲突进度。REPLACED 会话禁止调用。")
@GetMapping("/reading/progress")
public AjaxResult progress(@Parameter(description = "设备ID") @RequestParam String deviceId)
{
Long userId = getUserId();
TrainUserSession session = userSessionService.selectLatestSession(userId, deviceId);
if (session == null || session.getSessionStatus() != 1)
{
return AjaxResult.error("当前会话状态不允许查询进度(被替换设备仅允许补交上报)");
}
long readSec = readingLogService.sumDurationSeconds(userId, "read");
long audioSec = readingLogService.sumDurationSeconds(userId, "audio");
List<TrainReadingCurrentProgress> mains = currentProgressService.selectListByUserId(userId);
List<BookRecord> bookRecords = new ArrayList<>();
if (mains != null)
{
for (TrainReadingCurrentProgress cp : mains)
{
BookRecord br = new BookRecord();
br.bookId = cp.getBookId();
br.bookVersion = cp.getBookVersion();
MainProgress mp = new MainProgress();
mp.mode = cp.getMode();
mp.pageNo = cp.getPageNo();
mp.sentenceIndex = cp.getSentenceIndex();
mp.progress = cp.getProgress();
mp.updateTime = cp.getUpdateTime() != null ? cp.getUpdateTime().getTime() : null;
br.mainProgress = mp;
List<TrainReadingAlternateProgress> alts = alternateProgressService.selectListByUserBookMode(userId, cp.getBookId(), cp.getMode());
List<AlternateProgress> altList = new ArrayList<>();
if (alts != null)
{
for (TrainReadingAlternateProgress a : alts)
{
AlternateProgress ap = new AlternateProgress();
ap.mode = a.getMode();
ap.bookVersion = a.getBookVersion();
ap.pageNo = a.getPageNo();
ap.sentenceIndex = a.getSentenceIndex();
ap.progress = a.getProgress();
ap.createTime = a.getCreateTime() != null ? a.getCreateTime().getTime() : null;
altList.add(ap);
}
}
br.alternateProgressList = altList;
bookRecords.add(br);
}
}
ProgressData data = new ProgressData();
data.userId = userId;
data.totalReadSeconds = readSec;
data.totalAudioSeconds = audioSec;
data.bookRecords = bookRecords;
return AjaxResult.success(data);
}
/**
* 7. 获取可阅读书籍列表(仅上架)
*/
@Operation(summary = "获取可阅读书籍列表", description = "只返回 is_on_shelf=1 的上架图书,支持按 bookCategory 过滤。")
@GetMapping("/book/list")
public AjaxResult bookList(@Parameter(description = "设备ID") @RequestParam String deviceId,
@Parameter(description = "分类过滤:SAFETY/REGULATION/LANGUAGE,可空") @RequestParam(required = false) String bookCategory)
{
List<TrainBookSnapshot> list = bookSnapshotService.selectOnShelfList(bookCategory);
List<BookItem> items = new ArrayList<>();
if (list != null)
{
for (TrainBookSnapshot b : list)
{
BookItem bi = new BookItem();
bi.bookId = b.getBookId();
bi.bookTitle = b.getBookTitle();
bi.bookCategory = b.getBookCategory();
bi.bookSummary = b.getBookSummary();
bi.bookVersion = b.getBookVersion();
bi.bookCover = b.getBookCover();
bi.fileSize = b.getFileSize();
bi.fileMd5 = b.getFileMd5();
bi.isOnShelf = b.getIsOnShelf() != null && b.getIsOnShelf() == 1;
bi.publishTime = b.getPublishTime() != null ? b.getPublishTime().getTime() : null;
items.add(bi);
}
}
BookListData data = new BookListData();
data.total = items.size();
data.list = items;
return AjaxResult.success(data);
}
/**
* 8. PDF 书籍下载(支持 Range 断点续传;下架图书禁止下载)
*/
@Operation(summary = "PDF 书籍下载", description = "支持 HTTP Range 断点续传;下架图书返回业务错误禁止下载。")
@GetMapping("/book/download")
public void bookDownload(@Parameter(description = "设备ID") @RequestParam String deviceId,
@Parameter(description = "图书ID") @RequestParam String bookId,
@Parameter(description = "图书版本 BV-xx") @RequestParam String bookVersion,
HttpServletRequest request, HttpServletResponse response) throws IOException
{
TrainBookSnapshot book = bookSnapshotService.selectByBookIdAndVersion(bookId, bookVersion);
if (book == null)
{
writeJsonError(response, "图书或版本不存在");
return;
}
if (book.getIsOnShelf() == null || book.getIsOnShelf() != 1)
{
writeJsonError(response, "图书已下架,禁止下载");
return;
}
String url = book.getDownloadUrl();
if (url == null || url.isEmpty())
{
writeJsonError(response, "文件地址不存在");
return;
}
streamFile(url, book.getFileMd5(), book.getFileSize(), book.getBookTitle(), request, response);
}
/**
* 9. 下载完成回执上报
*/
@Operation(summary = "下载完成回执上报", description = "设备上报下载校验结果,落库 train_book_download_record,返回 deviceReady 与 recordId。")
@PostMapping("/book/download/ack")
public AjaxResult downloadAck(@RequestBody DownloadAckRequest req)
{
Long userId = getUserId();
TrainBookDownloadRecord record = new TrainBookDownloadRecord();
record.setUserId(userId);
record.setDeviceId(req.deviceId);
record.setBookId(req.bookId);
record.setBookVersion(req.bookVersion);
record.setFileMd5(req.fileMd5);
record.setResult(req.result != null ? req.result : "SUCCESS");
record.setErrMsg(req.errMsg);
record.setDownloadTime(req.timestamp != null ? new Date(req.timestamp) : new Date());
bookDownloadRecordService.insertTrainBookDownloadRecord(record);
DownloadAckData data = new DownloadAckData();
data.deviceReady = true;
data.recordId = record.getId();
return AjaxResult.success(data);
}
private void writeJsonError(HttpServletResponse response, String msg) throws IOException
{
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":500,\"msg\":\"" + msg + "\",\"data\":null}");
}
private void streamFile(String url, String md5, Long fileSize, String title, HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.setHeader("Accept-Ranges", "bytes");
if (md5 != null && !md5.isEmpty())
{
response.setHeader("Content-MD5", md5);
}
// 加上 HTTP 头,保证文件以正确类型、可下载方式返回(浏览器可访问)
response.setContentType(guessContentType(url));
String fileName = (title != null && !title.isEmpty() ? title : "book") + ".pdf";
try
{
String enc = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"download.pdf\"; filename*=UTF-8''" + enc);
}
catch (Exception ignored) {}
boolean isHttp = url.startsWith("http://") || url.startsWith("https://");
InputStream in = null;
long total = -1;
if (isHttp)
{
// 完整 http(s) 远程地址(如 OSS):直接拉取
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestProperty("User-Agent", "ruoyi-device");
in = conn.getInputStream();
String cl = conn.getHeaderField("Content-Length");
if (cl != null && !cl.isEmpty())
{
try { total = Long.parseLong(cl); } catch (NumberFormatException ignored) {}
}
}
else if (url.startsWith("/"))
{
// 相对路径:直接按本地资源读取,避免后端自请求(否则会触发 /error 鉴权 401)
if (url.startsWith("/profile/"))
{
// /profile/** 指向 RuoYi 上传根目录(ruoyi.profile),与静态资源映射一致
File f = new File(profileRoot, url.substring("/profile".length()));
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
else
{
// 其他相对路径(测试用 /books/...)从 classpath 静态资源读取
ClassPathResource res = new ClassPathResource("static" + url);
if (!res.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = res.contentLength();
in = res.getInputStream();
}
}
else
{
// 绝对本地路径(遗留用法)
File f = new File(url);
if (!f.exists())
{
writeJsonError(response, "文件不存在");
return;
}
total = f.length();
in = new FileInputStream(f);
}
try
{
String range = request.getHeader("Range");
long start = 0;
long end = total - 1;
boolean hasRange = false;
if (range != null && range.startsWith("bytes="))
{
String[] parts = range.substring(6).split("-");
try { start = Long.parseLong(parts[0]); } catch (NumberFormatException ignored) {}
if (parts.length > 1 && !parts[1].isEmpty())
{
try { end = Long.parseLong(parts[1]); } catch (NumberFormatException ignored) {}
}
hasRange = true;
}
if (hasRange)
{
if (end < 0 || end >= total)
{
end = total - 1;
}
if (start > end)
{
start = end;
}
long skipped = in.skip(start);
while (skipped < start)
{
long s = in.skip(start - skipped);
if (s <= 0)
{
break;
}
skipped += s;
}
long len = end - start + 1;
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total);
response.setContentLengthLong(len);
copy(in, response.getOutputStream(), len);
}
else
{
response.setStatus(HttpServletResponse.SC_OK);
if (total > 0)
{
response.setContentLengthLong(total);
}
copy(in, response.getOutputStream(), -1);
}
}
finally
{
if (in != null)
{
in.close();
}
}
}
private String guessContentType(String url)
{
if (url != null && url.toLowerCase().endsWith(".pdf"))
{
return "application/pdf";
}
return "application/octet-stream";
}
private void copy(InputStream in, OutputStream out, long limit) throws IOException
{
byte[] buf = new byte[8192];
long remaining = limit;
int n;
while ((n = in.read(buf)) != -1)
{
if (limit > 0)
{
if (remaining <= 0)
{
break;
}
int toWrite = (int) Math.min(n, remaining);
out.write(buf, 0, toWrite);
remaining -= toWrite;
}
else
{
out.write(buf, 0, n);
}
}
out.flush();
}
// ================= 请求/响应 DTO(内嵌,避免新增文件) =================
public static class LoginRequest
{
public String deviceId;
public Long userId;
public String firmware;
public String appVersion;
public NetInfo netInfo;
public Long timestamp;
public String nonce;
}
public static class NetInfo
{
public String ip;
public String mac;
public Integer rssi;
}
public static class LogoutRequest
{
public String deviceId;
public String reason;
public Long timestamp;
public String nonce;
}
public static class HeartbeatRequest
{
public String deviceId;
public String status;
public Metrics metrics;
public String configVersion;
public String personVersion;
public Long timestamp;
public String nonce;
}
public static class Metrics
{
public Integer cpu;
public Integer mem;
public Long diskFree;
}
public static class ReadingRecordRequest
{
public String deviceId;
public String recordId;
public String bookId;
public String bookVersion;
public String mode;
public String recordType;
public Long startTime;
public Long endTime;
public Integer durationSec;
public Integer pageFrom;
public Integer pageTo;
public BigDecimal progress;
}
public static class ReadingRecordBatchRequest
{
public String deviceId;
public String batchId;
public Long timestamp;
public String nonce;
public List<ReadingRecordRequest> records;
}
public static class DownloadAckRequest
{
public String deviceId;
public String bookId;
public String bookVersion;
public String fileMd5;
public String result;
public String errMsg;
public Long timestamp;
public String nonce;
}
public static class LoginData
{
public String accessToken;
public String sessionId;
public Integer heartbeatInterval;
}
public static class LogoutData
{
public Boolean result;
public Boolean tokenRevoked;
}
public static class HeartbeatData
{
public Integer nextInterval;
public List<Object> commands;
}
public static class RecordData
{
public String result;
public Integer studyMinutes;
public Integer totalMinutes;
}
public static class ReadingRecordItemResult
{
public String recordId;
public String result;
public ReadingRecordItemResult(String recordId, String result)
{
this.recordId = recordId;
this.result = result;
}
}
public static class BatchData
{
public List<ReadingRecordItemResult> itemResults;
}
public static class MainProgress
{
public String mode;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long updateTime;
}
public static class AlternateProgress
{
public String mode;
public String bookVersion;
public Integer pageNo;
public Integer sentenceIndex;
public BigDecimal progress;
public Long createTime;
}
public static class BookRecord
{
public String bookId;
public String bookVersion;
public MainProgress mainProgress;
public List<AlternateProgress> alternateProgressList;
}
public static class ProgressData
{
public Long userId;
public long totalReadSeconds;
public long totalAudioSeconds;
public List<BookRecord> bookRecords;
}
public static class BookItem
{
public String bookId;
public String bookTitle;
public String bookCategory;
public String bookSummary;
public String bookVersion;
public String bookCover;
public Long fileSize;
public String fileMd5;
public Boolean isOnShelf;
public Long publishTime;
}
public static class BookListData
{
public int total;
public List<BookItem> list;
}
public static class DownloadAckData
{
public Boolean deviceReady;
public Long recordId;
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainReadingCurrentProgress;
import com.ruoyi.device.service.ITrainReadingCurrentProgressService;
/**
* 学员主续读进度Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/readingcurrentprogress")
public class TrainReadingCurrentProgressController extends BaseController
{
@Autowired
private ITrainReadingCurrentProgressService trainReadingCurrentProgressService;
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:list')")
@GetMapping("/list")
public TableDataInfo list(TrainReadingCurrentProgress trainReadingCurrentProgress)
{
startPage();
List<TrainReadingCurrentProgress> list = trainReadingCurrentProgressService.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:export')")
@Log(title = "学员主续读进度", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainReadingCurrentProgress trainReadingCurrentProgress)
{
List<TrainReadingCurrentProgress> list = trainReadingCurrentProgressService.selectTrainReadingCurrentProgressList(trainReadingCurrentProgress);
ExcelUtil<TrainReadingCurrentProgress> util = new ExcelUtil<TrainReadingCurrentProgress>(TrainReadingCurrentProgress.class);
util.exportExcel(response, list, "学员主续读进度数据");
}
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainReadingCurrentProgressService.selectTrainReadingCurrentProgressById(id));
}
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:add')")
@Log(title = "学员主续读进度", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainReadingCurrentProgress trainReadingCurrentProgress)
{
return toAjax(trainReadingCurrentProgressService.insertTrainReadingCurrentProgress(trainReadingCurrentProgress));
}
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:edit')")
@Log(title = "学员主续读进度", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainReadingCurrentProgress trainReadingCurrentProgress)
{
return toAjax(trainReadingCurrentProgressService.updateTrainReadingCurrentProgress(trainReadingCurrentProgress));
}
@PreAuthorize("@ss.hasPermi('device:readingcurrentprogress:remove')")
@Log(title = "学员主续读进度", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainReadingCurrentProgressService.deleteTrainReadingCurrentProgressByIds(ids));
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainReadingDurationLog;
import com.ruoyi.device.service.ITrainReadingDurationLogService;
/**
* 阅读上报幂等日志Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/readingdurationlog")
public class TrainReadingDurationLogController extends BaseController
{
@Autowired
private ITrainReadingDurationLogService trainReadingDurationLogService;
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:list')")
@GetMapping("/list")
public TableDataInfo list(TrainReadingDurationLog trainReadingDurationLog)
{
startPage();
List<TrainReadingDurationLog> list = trainReadingDurationLogService.selectTrainReadingDurationLogList(trainReadingDurationLog);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:export')")
@Log(title = "阅读上报幂等日志", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainReadingDurationLog trainReadingDurationLog)
{
List<TrainReadingDurationLog> list = trainReadingDurationLogService.selectTrainReadingDurationLogList(trainReadingDurationLog);
ExcelUtil<TrainReadingDurationLog> util = new ExcelUtil<TrainReadingDurationLog>(TrainReadingDurationLog.class);
util.exportExcel(response, list, "阅读上报幂等日志数据");
}
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainReadingDurationLogService.selectTrainReadingDurationLogById(id));
}
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:add')")
@Log(title = "阅读上报幂等日志", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainReadingDurationLog trainReadingDurationLog)
{
return toAjax(trainReadingDurationLogService.insertTrainReadingDurationLog(trainReadingDurationLog));
}
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:edit')")
@Log(title = "阅读上报幂等日志", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainReadingDurationLog trainReadingDurationLog)
{
return toAjax(trainReadingDurationLogService.updateTrainReadingDurationLog(trainReadingDurationLog));
}
@PreAuthorize("@ss.hasPermi('device:readingdurationlog:remove')")
@Log(title = "阅读上报幂等日志", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainReadingDurationLogService.deleteTrainReadingDurationLogByIds(ids));
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.web.controller.device;
import java.util.List;
import jakarta.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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.device.domain.TrainUserSession;
import com.ruoyi.device.service.ITrainUserSessionService;
/**
* 学员业务会话Controller
*
* @author ruoyi
* @date 2026-10-20
*/
@RestController
@RequestMapping("/device/usersession")
public class TrainUserSessionController extends BaseController
{
@Autowired
private ITrainUserSessionService trainUserSessionService;
@PreAuthorize("@ss.hasPermi('device:usersession:list')")
@GetMapping("/list")
public TableDataInfo list(TrainUserSession trainUserSession)
{
startPage();
List<TrainUserSession> list = trainUserSessionService.selectTrainUserSessionList(trainUserSession);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('device:usersession:export')")
@Log(title = "学员业务会话", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainUserSession trainUserSession)
{
List<TrainUserSession> list = trainUserSessionService.selectTrainUserSessionList(trainUserSession);
ExcelUtil<TrainUserSession> util = new ExcelUtil<TrainUserSession>(TrainUserSession.class);
util.exportExcel(response, list, "学员业务会话数据");
}
@PreAuthorize("@ss.hasPermi('device:usersession:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(trainUserSessionService.selectTrainUserSessionById(id));
}
@PreAuthorize("@ss.hasPermi('device:usersession:add')")
@Log(title = "学员业务会话", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainUserSession trainUserSession)
{
return toAjax(trainUserSessionService.insertTrainUserSession(trainUserSession));
}
@PreAuthorize("@ss.hasPermi('device:usersession:edit')")
@Log(title = "学员业务会话", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainUserSession trainUserSession)
{
return toAjax(trainUserSessionService.updateTrainUserSession(trainUserSession));
}
@PreAuthorize("@ss.hasPermi('device:usersession:remove')")
@Log(title = "学员业务会话", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(trainUserSessionService.deleteTrainUserSessionByIds(ids));
}
}
@@ -1,6 +1,8 @@
package com.ruoyi.web.controller.train;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -19,6 +21,7 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.dto.AccommodationSaveResult;
import com.ruoyi.train.service.ITrainAccommodationService;
/**
@@ -85,7 +88,17 @@ public class TrainAccommodationController extends BaseController
public AjaxResult add(@RequestBody TrainAccommodation accommodation)
{
accommodation.setCreateBy(getUsernameSafe());
return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation));
AccommodationSaveResult result = trainAccommodationService.insertTrainAccommodationWithLockResult(accommodation);
if (result == null || result.getRows() <= 0)
{
return AjaxResult.error("新增住宿安排失败");
}
// 住宿主流程成功即返回成功,门禁调用结果在 data.lockCheckin 中透出,不改变原有 code 语义
Map<String, Object> data = new HashMap<>(4);
data.put("accommodationId", result.getAccommodationId());
data.put("lockApiBizNo", result.getLockApiBizNo());
data.put("lockCheckin", result.getLockCheckin());
return AjaxResult.success(data);
}
/**
@@ -1,14 +1,14 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.dto.ReportStartDTO;
import jakarta.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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -66,4 +66,13 @@ public class TrainClassReportController extends BaseController
return success(trainClassReportService.selectTrainClassReportByReportId(reportId));
}
/** 开班报到启动。 */
@PreAuthorize("@ss.hasPermi('train:report:list')")
@Log(title = "开班报到启动", businessType = BusinessType.INSERT)
@PostMapping("/start")
public AjaxResult reportStart(@RequestBody ReportStartDTO dto)
{
return success(trainClassReportService.reportStart(dto, SecurityUtils.getUsername()));
}
}
@@ -0,0 +1,106 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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.TrainClassSeat;
import com.ruoyi.train.domain.dto.ClassSeatSaveDTO;
import com.ruoyi.train.service.ITrainClassSeatService;
/**
* 班级座位Controller
*
* @author ruoyi
* @date 2026-10-26
*/
@RestController
@RequestMapping("/train/classseat")
public class TrainClassSeatController extends BaseController
{
@Autowired
private ITrainClassSeatService trainClassSeatService;
/**
* 查询班级座位列表 (关联学员姓名;支持 planId/classroomId/siteId/canceled/studentId/zoneId 过滤)
*/
@PreAuthorize("@ss.hasPermi('train:classseat:list')")
@GetMapping("/list")
public TableDataInfo list(TrainClassSeat trainClassSeat)
{
startPage();
List<?> list = trainClassSeatService.selectTrainClassSeatList(trainClassSeat);
return getDataTable(list);
}
/**
* 获取班级座位详细信息
*/
@PreAuthorize("@ss.hasPermi('train:classseat:list')")
@GetMapping(value = "/{seatId}")
public AjaxResult getInfo(@PathVariable("seatId") Long seatId)
{
return success(trainClassSeatService.selectTrainClassSeatBySeatId(seatId));
}
/**
* 新增班级座位 (同一教室同一 (行,列) 唯一)
*/
@PreAuthorize("@ss.hasPermi('train:classseat:edit')")
@Log(title = "班级座位", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody TrainClassSeat trainClassSeat)
{
trainClassSeat.setCreateBy(SecurityUtils.getUsername());
return toAjax(trainClassSeatService.insertTrainClassSeat(trainClassSeat));
}
/**
* 修改班级座位 (RESTful PUT /train/classseat/{seatId})
*/
@PreAuthorize("@ss.hasPermi('train:classseat:edit')")
@Log(title = "班级座位", businessType = BusinessType.UPDATE)
@PutMapping("/{seatId}")
public AjaxResult edit(@PathVariable("seatId") Long seatId, @RequestBody TrainClassSeat trainClassSeat)
{
trainClassSeat.setSeatId(seatId);
trainClassSeat.setUpdateBy(SecurityUtils.getUsername());
return toAjax(trainClassSeatService.updateTrainClassSeat(trainClassSeat));
}
/**
* 整体保存座位布局:删除 (planId, classroomId) 下原有座位后批量写入本次座位
*/
@PreAuthorize("@ss.hasPermi('train:classseat:edit')")
@Log(title = "班级座位布局保存", businessType = BusinessType.UPDATE)
@PostMapping("/layout")
public AjaxResult saveLayout(@RequestBody ClassSeatSaveDTO dto)
{
return success(trainClassSeatService.saveClassSeatLayout(dto, SecurityUtils.getUsername()));
}
/**
* 删除班级座位
*/
@PreAuthorize("@ss.hasPermi('train:classseat:remove')")
@Log(title = "班级座位", businessType = BusinessType.DELETE)
@DeleteMapping("/{seatIds}")
public AjaxResult remove(@PathVariable Long[] seatIds)
{
return toAjax(trainClassSeatService.deleteTrainClassSeatBySeatIds(seatIds));
}
}
@@ -56,6 +56,16 @@ public class TrainClassroomInfoController extends BaseController
return success(trainClassroomInfoService.selectClassroomDetailByClassRoomId(classRoomId));
}
/**
* 获取某开班计划(planId)课表中用到的教室信息(按教室去重)
*/
@PreAuthorize("@ss.hasPermi('train:classroom:list')")
@GetMapping("/plan/{planId}")
public AjaxResult listByPlan(@PathVariable("planId") String planId)
{
return success(trainClassroomInfoService.selectClassroomByPlanId(planId));
}
/**
* 新增教室信息
*/
@@ -5,9 +5,11 @@ import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -60,6 +62,7 @@ public class TrainDormitoryInfoController extends BaseController
{
DormitoryItemVO vo = new DormitoryItemVO();
vo.setDormId(entity.getDormId());
vo.setId(entity.getId());
vo.setDormBuilding(entity.getDormBuilding());
vo.setDormFloor(entity.getDormFloor());
vo.setDormRoom(entity.getDormRoom());
@@ -102,6 +105,22 @@ public class TrainDormitoryInfoController extends BaseController
return success();
}
/**
* 修改宿舍房间信息 (楼栋/楼层/房号/容量/性别/门锁SN/状态/备注)
* PUT /train/dormitory/{dormId}
*/
@PreAuthorize("@ss.hasPermi('train:dormitory:edit')")
@Log(title = "宿舍房间管理", businessType = BusinessType.UPDATE)
@PutMapping("/{dormId}")
public AjaxResult edit(@PathVariable("dormId") String dormId,
@RequestBody TrainDormitoryInfo trainDormitoryInfo)
{
trainDormitoryInfo.setDormId(dormId);
trainDormitoryInfo.setUpdateBy(SecurityUtils.getUsername());
trainDormitoryInfoService.updateTrainDormitoryInfo(trainDormitoryInfo);
return success();
}
/**
* 获取楼栋、楼层级联下拉数据 (分房交互必需)
* GET /train/dormitory/options
@@ -113,4 +132,16 @@ public class TrainDormitoryInfoController extends BaseController
List<DormOptionsVO> tree = trainDormitoryInfoService.selectDormitoryTreeOptions();
return success(tree);
}
/**
* 删除宿舍房间 (支持批量,dormIds 以逗号分隔)
* DELETE /train/dormitory/{dormIds}
*/
@PreAuthorize("@ss.hasPermi('train:dormitory:edit')")
@Log(title = "宿舍房间管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{dormIds}")
public AjaxResult remove(@PathVariable String[] dormIds)
{
return toAjax(trainDormitoryInfoService.deleteTrainDormitoryInfoByDormIds(dormIds));
}
}
@@ -1,6 +1,7 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
@@ -85,6 +86,20 @@ public class TrainEnrollmentController extends BaseController
return success(result);
}
/**
* 下载参训人员批量导入模板 (UC-05 / P0)。
*
* <p>与 {@link #batchImportEnrollments} 使用同一套解析规则:xlsx 格式,第一个工作表按
* A 学员ID / B 参训来源 / C 住宿需求 / D 备注 的列顺序解析;模板内含逐列填写规则说明与一行示例数据。</p>
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
@Log(title = "参训人员导入模板", businessType = BusinessType.EXPORT)
@PostMapping("/enrollments/importTemplate")
public void importTemplate(HttpServletResponse response)
{
enrollmentAppService.exportImportTemplate(response);
}
/**
* 获取参训档案详情 (P0)。
*/
@@ -0,0 +1,87 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainExamReport;
import com.ruoyi.train.service.ITrainExamReportService;
/**
* 外部实训上报成绩Controller
*
* @author ruoyi
* @date 2026-11-05
*/
@RestController
@RequestMapping("/train/examReport")
public class TrainExamReportController extends BaseController
{
@Autowired
private ITrainExamReportService trainExamReportService;
/**
* 查询上报成绩列表
*/
@PreAuthorize("@ss.hasPermi('train:examReport:list')")
@GetMapping("/list")
public TableDataInfo list(TrainExamReport trainExamReport)
{
startPage();
List<TrainExamReport> list = trainExamReportService.selectTrainExamReportList(trainExamReport);
return getDataTable(list);
}
/**
* 获取上报成绩详情
*/
@PreAuthorize("@ss.hasPermi('train:examReport:list')")
@GetMapping(value = "/{reportPk}")
public AjaxResult getInfo(@PathVariable("reportPk") Long reportPk)
{
return success(trainExamReportService.selectTrainExamReportByReportPk(reportPk));
}
/**
* 认领待匹配成绩(指定平台学员)
*/
@PreAuthorize("@ss.hasPermi('train:examReport:claim')")
@Log(title = "上报成绩认领", businessType = BusinessType.UPDATE)
@PostMapping("/claim")
public AjaxResult claim(@RequestBody Map<String, Object> body)
{
Object reportPkObj = body.get("reportPk");
Object studentIdObj = body.get("studentId");
if (reportPkObj == null || studentIdObj == null)
{
return error("参数缺失:reportPk 与 studentId 均为必填");
}
Long reportPk = Long.valueOf(reportPkObj.toString());
return toAjax(trainExamReportService.claimReport(reportPk, studentIdObj.toString().trim(),
SecurityUtils.getUsername()));
}
/**
* 删除上报成绩
*/
@PreAuthorize("@ss.hasPermi('train:examReport:remove')")
@Log(title = "上报成绩", businessType = BusinessType.DELETE)
@DeleteMapping("/{reportPks}")
public AjaxResult remove(@PathVariable Long[] reportPks)
{
return toAjax(trainExamReportService.deleteTrainExamReportByReportPks(reportPks));
}
}
@@ -0,0 +1,92 @@
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.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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.TrainExamSubject;
import com.ruoyi.train.service.ITrainExamSubjectService;
/**
* 考试科目字典Controller
*
* @author ruoyi
* @date 2026-11-05
*/
@RestController
@RequestMapping("/train/examSubject")
public class TrainExamSubjectController extends BaseController
{
@Autowired
private ITrainExamSubjectService trainExamSubjectService;
/**
* 查询考试科目列表
*/
@PreAuthorize("@ss.hasPermi('train:examSubject:list')")
@GetMapping("/list")
public TableDataInfo list(TrainExamSubject trainExamSubject)
{
startPage();
List<TrainExamSubject> list = trainExamSubjectService.selectTrainExamSubjectList(trainExamSubject);
return getDataTable(list);
}
/**
* 获取考试科目详细信息
*/
@PreAuthorize("@ss.hasPermi('train:examSubject:list')")
@GetMapping(value = "/{subjectId}")
public AjaxResult getInfo(@PathVariable("subjectId") Long subjectId)
{
return success(trainExamSubjectService.selectTrainExamSubjectBySubjectId(subjectId));
}
/**
* 新增考试科目
*/
@PreAuthorize("@ss.hasPermi('train:examSubject:add')")
@Log(title = "考试科目", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainExamSubject trainExamSubject)
{
trainExamSubject.setCreateBy(SecurityUtils.getUsername());
return toAjax(trainExamSubjectService.insertTrainExamSubject(trainExamSubject));
}
/**
* 修改考试科目
*/
@PreAuthorize("@ss.hasPermi('train:examSubject:edit')")
@Log(title = "考试科目", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainExamSubject trainExamSubject)
{
trainExamSubject.setUpdateBy(SecurityUtils.getUsername());
return toAjax(trainExamSubjectService.updateTrainExamSubject(trainExamSubject));
}
/**
* 删除考试科目
*/
@PreAuthorize("@ss.hasPermi('train:examSubject:remove')")
@Log(title = "考试科目", businessType = BusinessType.DELETE)
@DeleteMapping("/{subjectIds}")
public AjaxResult remove(@PathVariable Long[] subjectIds)
{
return toAjax(trainExamSubjectService.deleteTrainExamSubjectBySubjectIds(subjectIds));
}
}
@@ -0,0 +1,130 @@
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.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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.TrainOpenApp;
import com.ruoyi.train.domain.TrainOpenAppScope;
import com.ruoyi.train.service.ITrainOpenAppService;
/**
* 外部实训系统应用Controller
*
* @author ruoyi
* @date 2026-11-05
*/
@RestController
@RequestMapping("/train/openApp")
public class TrainOpenAppController extends BaseController
{
@Autowired
private ITrainOpenAppService trainOpenAppService;
/**
* 查询外部应用列表
*/
@PreAuthorize("@ss.hasPermi('train:openApp:list')")
@GetMapping("/list")
public TableDataInfo list(TrainOpenApp trainOpenApp)
{
startPage();
List<TrainOpenApp> list = trainOpenAppService.selectTrainOpenAppList(trainOpenApp);
return getDataTable(list);
}
/**
* 获取外部应用详细信息
*/
@PreAuthorize("@ss.hasPermi('train:openApp:query')")
@GetMapping(value = "/{appId}")
public AjaxResult getInfo(@PathVariable("appId") String appId)
{
return success(trainOpenAppService.selectTrainOpenAppByAppId(appId));
}
/**
* 新增外部应用
*/
@PreAuthorize("@ss.hasPermi('train:openApp:add')")
@Log(title = "外部应用", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainOpenApp trainOpenApp)
{
trainOpenApp.setCreateBy(SecurityUtils.getUsername());
int rows = trainOpenAppService.insertTrainOpenApp(trainOpenApp);
AjaxResult result = toAjax(rows);
// 明文密钥仅在新增时返回一次,用于线下交付接入方
result.put("appSecret", trainOpenApp.getAppSecret());
return result;
}
/**
* 修改外部应用
*/
@PreAuthorize("@ss.hasPermi('train:openApp:edit')")
@Log(title = "外部应用", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainOpenApp trainOpenApp)
{
trainOpenApp.setUpdateBy(SecurityUtils.getUsername());
return toAjax(trainOpenAppService.updateTrainOpenApp(trainOpenApp));
}
/**
* 删除外部应用
*/
@PreAuthorize("@ss.hasPermi('train:openApp:remove')")
@Log(title = "外部应用", businessType = BusinessType.DELETE)
@DeleteMapping("/{appIds}")
public AjaxResult remove(@PathVariable String[] appIds)
{
return toAjax(trainOpenAppService.deleteTrainOpenAppByAppIds(appIds));
}
/**
* 重置应用密钥(返回一次明文)
*/
@PreAuthorize("@ss.hasPermi('train:openApp:edit')")
@Log(title = "外部应用密钥", businessType = BusinessType.UPDATE)
@PostMapping("/resetSecret/{appId}")
public AjaxResult resetSecret(@PathVariable("appId") String appId)
{
return success(trainOpenAppService.resetAppSecret(appId));
}
/**
* 查询应用授权范围
*/
@PreAuthorize("@ss.hasPermi('train:openApp:query')")
@GetMapping("/scope/{appId}")
public AjaxResult scopeList(@PathVariable("appId") String appId)
{
return success(trainOpenAppService.selectScopeList(appId));
}
/**
* 保存应用授权范围(全量覆盖)
*/
@PreAuthorize("@ss.hasPermi('train:openApp:edit')")
@Log(title = "外部应用授权范围", businessType = BusinessType.UPDATE)
@PutMapping("/scope/{appId}")
public AjaxResult saveScopes(@PathVariable("appId") String appId,
@RequestBody List<TrainOpenAppScope> scopes)
{
return toAjax(trainOpenAppService.saveScopes(appId, scopes, SecurityUtils.getUsername()));
}
}
@@ -8,6 +8,7 @@ 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -82,4 +83,14 @@ public class TrainPlanController extends BaseController
planAppService.completePlan(planId, SecurityUtils.getUsername());
return success();
}
/** 删除培训计划(仅未开始允许)。 */
@PreAuthorize("@ss.hasPermi('train:plan:remove')")
@Log(title = "培训计划", businessType = BusinessType.DELETE)
@DeleteMapping("/{planId}")
public AjaxResult remove(@PathVariable String planId)
{
planAppService.deletePlan(planId, SecurityUtils.getUsername());
return success();
}
}
@@ -0,0 +1,252 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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;
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.poi.ExcelUtil;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.dto.RegReportSaveDTO;
import com.ruoyi.train.domain.dto.RegUrgeDTO;
import com.ruoyi.train.domain.vo.RegBatchTrendVO;
import com.ruoyi.train.domain.vo.RegClassVO;
import com.ruoyi.train.domain.vo.RegHistorySummaryVO;
import com.ruoyi.train.domain.vo.RegRosterVO;
import com.ruoyi.train.domain.vo.RegSummaryVO;
import com.ruoyi.train.domain.vo.RegUnitVO;
import com.ruoyi.train.service.ITrainRegDataService;
/**
* 班级报到数据 Controller
*
* <p>路由前缀 /train/regdata,与前端 src/api/train/regData.js、
* src/api/train/historyRegData.js 一一对应。</p>
*
* <p>错误语义遵循框架统一返回体:HTTP 状态固定 200,业务码在 body.code 中,
* 400=参数缺失/非法,403=无权限,404=开班计划/报到记录不存在,409=重复报到。</p>
*
* @author ruoyi
*/
@RestController
@RequestMapping("/train/regdata")
public class TrainRegDataController extends BaseController
{
@Autowired
private ITrainRegDataService trainRegDataService;
/**
* 查询有报到任务的班级标签。
*
* <p>GET /train/regdata/classes?pageNum=1&pageSize=100</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/classes")
public TableDataInfo classes()
{
startPage();
List<RegClassVO> list = trainRegDataService.selectReportClasses();
return getDataTable(list);
}
/**
* 查询班级报到统计汇总。
*
* <p>GET /train/regdata/summary?planId=xxx</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/summary")
public AjaxResult summary(@RequestParam(value = "planId", required = false) String planId)
{
RegSummaryVO summary = trainRegDataService.selectReportSummary(planId);
return success(summary);
}
/**
* 分页查询未报到学员。
*
* <p>GET /train/regdata/notReported?planId=xxx&pageNum=1&pageSize=6</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/notReported")
public TableDataInfo notReported(@RequestParam(value = "planId", required = false) String planId)
{
// 先校验班级存在性,避免分页线程变量残留
trainRegDataService.selectReportPlan(planId);
startPage();
List<RegRosterVO> list = trainRegDataService.selectNotReported(planId);
return getDataTable(list);
}
/**
* 分页查询报到名册(支持 studentName / reportTime 排序)。
*
* <p>GET /train/regdata/roster?planId=xxx&pageNum=1&pageSize=10&orderByColumn=reportTime&isAsc=desc</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/roster")
public TableDataInfo roster(@RequestParam(value = "planId", required = false) String planId)
{
trainRegDataService.selectReportPlan(planId);
startPage();
List<RegRosterVO> list = trainRegDataService.selectRoster(planId);
return getDataTable(list);
}
/**
* 导出报到名册。
*
* <p>POST /train/regdata/roster/export (form: planId)</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:export')")
@Log(title = "报到名册", businessType = BusinessType.EXPORT)
@PostMapping("/roster/export")
public void exportRoster(HttpServletResponse response,
@RequestParam(value = "planId", required = false) String planId)
{
List<RegRosterVO> list = trainRegDataService.selectRoster(planId);
ExcelUtil<RegRosterVO> util = new ExcelUtil<RegRosterVO>(RegRosterVO.class);
util.exportExcel(response, list, "报到名册");
}
/**
* 催促未报到学员。
*
* <p>POST /train/regdata/urge,body {"planId":"xxx","studentIds":["s1"]},
* studentIds 为空表示催促全班未报到学员。</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:confirm')")
@Log(title = "报到催促", businessType = BusinessType.UPDATE)
@PostMapping("/urge")
public AjaxResult urge(@Validated @RequestBody RegUrgeDTO dto)
{
int urgedCount = trainRegDataService.urgeReport(dto.getPlanId(), dto.getStudentIds(), getUsername());
AjaxResult ajax = AjaxResult.success("催促通知已发送");
ajax.put("urgedCount", urgedCount);
return ajax;
}
/**
* 查询单条报到记录。
*
* <p>GET /train/regdata/report/{reportId}</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/report/{reportId}")
public AjaxResult getReport(@PathVariable("reportId") Long reportId)
{
TrainClassReport report = trainRegDataService.selectReport(reportId);
return success(report);
}
/**
* 新增报到记录。
*
* <p>POST /train/regdata/report,body 见 {@link RegReportSaveDTO}。</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:confirm')")
@Log(title = "报到记录", businessType = BusinessType.INSERT)
@PostMapping("/report")
public AjaxResult addReport(@RequestBody RegReportSaveDTO dto)
{
Long reportId = trainRegDataService.createReport(dto, getUsername());
AjaxResult ajax = AjaxResult.success("报到记录创建成功");
ajax.put("reportId", reportId);
return ajax;
}
/**
* 更新报到记录。
*
* <p>PUT /train/regdata/report/{reportId},body 仅需传入待修改字段。</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:confirm')")
@Log(title = "报到记录", businessType = BusinessType.UPDATE)
@PutMapping("/report/{reportId}")
public AjaxResult editReport(@PathVariable("reportId") Long reportId, @RequestBody RegReportSaveDTO dto)
{
return toAjax(trainRegDataService.updateReport(reportId, dto, getUsername()));
}
/**
* 删除报到记录。
*
* <p>DELETE /train/regdata/report/{reportId}</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:confirm')")
@Log(title = "报到记录", businessType = BusinessType.DELETE)
@DeleteMapping("/report/{reportId}")
public AjaxResult removeReport(@PathVariable("reportId") Long reportId)
{
return toAjax(trainRegDataService.deleteReport(reportId));
}
/**
* 历史报到统计汇总。
*
* <p>GET /train/regdata/history/summary</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/history/summary")
public AjaxResult historySummary()
{
RegHistorySummaryVO summary = trainRegDataService.selectHistorySummary();
return success(summary);
}
/**
* 各批次报到率趋势。
*
* <p>GET /train/regdata/history/batchTrend</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/history/batchTrend")
public TableDataInfo batchTrend()
{
List<RegBatchTrendVO> list = trainRegDataService.selectBatchTrend();
return getDataTable(list);
}
/**
* 分页查询各送培单位报到情况(按平均报到率降序)。
*
* <p>GET /train/regdata/history/units?pageNum=1&pageSize=10</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/history/units")
public TableDataInfo historyUnits()
{
startPage();
List<RegUnitVO> list = trainRegDataService.selectUnitReport();
return getDataTable(list);
}
/**
* 导出历史报到统计报表。
*
* <p>POST /train/regdata/history/export</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:export')")
@Log(title = "历史报到统计", businessType = BusinessType.EXPORT)
@PostMapping("/history/export")
public void exportHistory(HttpServletResponse response)
{
List<RegUnitVO> list = trainRegDataService.selectUnitReport();
ExcelUtil<RegUnitVO> util = new ExcelUtil<RegUnitVO>(RegUnitVO.class);
util.exportExcel(response, list, "历史报到统计");
}
}
@@ -0,0 +1,70 @@
package com.ruoyi.web.controller.train;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.train.domain.analysis.ScoreAnalysisFilterDto;
import com.ruoyi.train.service.ITrainScoreAnalysisService;
/**
* 成绩统计分析 Controller
*
* 复用既有分析模块的返回与权限约定(AjaxResult + @PreAuthorize),
* 与考勤分析 /train/analysis/attendance/* 保持同一风格。
*/
@RestController
@RequestMapping("/train/analysis/score")
public class TrainScoreAnalysisController extends BaseController
{
@Autowired
private ITrainScoreAnalysisService scoreAnalysisService;
/**
* 成绩统计总览 KPI
*/
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
@GetMapping("/summary")
public AjaxResult getScoreSummary(ScoreAnalysisFilterDto filter)
{
Map<String, Object> summary = scoreAnalysisService.getScoreSummary(filter);
return success(summary);
}
/**
* 成绩统计图表集合
*/
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
@GetMapping("/charts")
public AjaxResult getScoreCharts(ScoreAnalysisFilterDto filter)
{
Map<String, Object> charts = scoreAnalysisService.getScoreCharts(filter);
return success(charts);
}
/**
* 指定维度的分组统计明细(支持视图切换:图表 / 表格)
*/
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
@GetMapping("/dimension")
public AjaxResult getDimensionAnalysis(ScoreAnalysisFilterDto filter)
{
Map<String, Object> result = scoreAnalysisService.getDimensionAnalysis(filter);
return success(result);
}
/**
* 筛选下拉选项(培训班 / 课程 / 实训上报平台)
*/
@PreAuthorize("@ss.hasPermi('train:analysis:score')")
@GetMapping("/filters")
public AjaxResult getFilterOptions()
{
Map<String, Object> options = scoreAnalysisService.getFilterOptions();
return success(options);
}
}
@@ -2,9 +2,18 @@ package com.ruoyi.web.controller.train;
import java.util.List;
import java.util.regex.Pattern;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.iot.lock.service.ILockFaceService;
import com.ruoyi.train.domain.TrainStudent;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.github.pagehelper.PageInfo;
@@ -17,10 +26,14 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.train.domain.dto.StudentCreateDTO;
import com.ruoyi.train.domain.dto.StudentUpdateDTO;
import com.ruoyi.train.domain.query.StudentQuery;
import com.ruoyi.train.domain.vo.BatchImportResultVO;
import com.ruoyi.train.domain.vo.StudentAdminVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.StudentExportVO;
import com.ruoyi.train.domain.vo.StudentImportVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.StudentImportService;
import com.ruoyi.web.service.train.TrainAccountBindingAdminService;
/** ISSUE-01-02 学员主档 P0 API。 */
@@ -28,16 +41,20 @@ import com.ruoyi.web.service.train.TrainAccountBindingAdminService;
@RequestMapping({"/train/student", "/train/students"})
public class TrainStudentController extends BaseController
{
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
private final ITrainStudentService trainStudentService;
private final TrainAccountBindingAdminService bindingAdminService;
private final StudentImportService studentImportService;
public TrainStudentController(ITrainStudentService trainStudentService,
TrainAccountBindingAdminService bindingAdminService)
TrainAccountBindingAdminService bindingAdminService,
StudentImportService studentImportService)
{
this.trainStudentService = trainStudentService;
this.bindingAdminService = bindingAdminService;
this.studentImportService = studentImportService;
}
@PreAuthorize("@ss.hasPermi('train:student:list')")
@@ -67,13 +84,23 @@ public class TrainStudentController extends BaseController
return success(bindingAdminService.toStudentAdminVO(detail));
}
@PreAuthorize("@ss.hasAnyPermi('train:student:add,train:student:edit')")
@GetMapping("/gen-person-no")
public AjaxResult genPersonNo()
{
// 必须强制转为 Object,走 success(Object data) 重载。
// 否则 String 参数会命中 success(String message),编号被放进 msg、data 为空,
// 前端解析 res.data 永远拿不到值(历史上“未返回可用编号”报错的根因)。
return success((Object) trainStudentService.genPersonNo());
}
@PreAuthorize("@ss.hasPermi('train:student:add')")
@Log(title = "学员主档", businessType = BusinessType.INSERT,
isSaveRequestData = false, isSaveResponseData = true)
@PostMapping
public AjaxResult add(@Valid @RequestBody StudentCreateDTO dto)
{
return success(trainStudentService.createStudent(dto));
return success(bindingAdminService.createStudentWithAccount(dto));
}
/**
@@ -94,7 +121,45 @@ public class TrainStudentController extends BaseController
{
return AjaxResult.error("学员手机号码格式不正确");
}
return success(trainStudentService.updateTrainStudent(dto));
return success(bindingAdminService.updateStudentWithAccount(dto));
}
/**
* 下载学员批量导入模板 (POST /train/student/importTemplate)。
*/
@PreAuthorize("@ss.hasPermi('train:student:add')")
@Log(title = "学员导入模板", businessType = BusinessType.EXPORT)
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response)
{
List<StudentImportVO> rows = studentImportService.templateRows();
ExcelUtil<StudentImportVO> util = new ExcelUtil<StudentImportVO>(StudentImportVO.class);
util.exportExcel(response, rows, "学员导入模板");
}
/**
* 学员批量导入 (POST /train/student/importData)。
*
* <p>必须以 multipart/form-data 提交,文件字段名为 {@code file};
* 若用 application/json 提交(例如 axios 把 FormData 序列化成 JSON),
* 会因 consumes 不匹配直接返回 415,而不是抛出难以定位的
* "Current request is not a multipart request"。</p>
*
* <p>返回体 data 为 { totalCount, successCount, failureCount, errorList }。</p>
*/
@PreAuthorize("@ss.hasPermi('train:student:add')")
@Log(title = "学员主档", businessType = BusinessType.IMPORT)
@PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult importData(@RequestParam(value = "file", required = false) MultipartFile file)
{
if (file == null)
{
throw new com.ruoyi.common.exception.ServiceException(
"未接收到上传文件,请使用 multipart/form-data 上传且字段名为 file",
HttpStatus.BAD_REQUEST);
}
BatchImportResultVO result = studentImportService.importStudents(file, getUsername());
return AjaxResult.success("导入完成:成功 " + result.getSuccessCount() + " 条,失败 " + result.getFailureCount() + " 条", result);
}
@PreAuthorize("@ss.hasPermi('train:student:remove')")
@@ -109,4 +174,64 @@ public class TrainStudentController extends BaseController
{
return PHONE_PATTERN.matcher(phone).matches();
}
/**
* 触发特征提取
* deviceId 指定目标门锁,用于取该锁维护的 algorithm_version;为空时按该人员授权自动推断
* <p>
* 返回结果的 data 携带特征值,供门禁授权页面回填「授权内容」;
* msg 保持「特征提取成功」文案以兼容既有调用。
*/
@Log(title = "人脸特征提取", businessType = BusinessType.UPDATE)
@PostMapping("/{faceId}/extract")
public AjaxResult extract(@PathVariable Long faceId, @RequestParam(required = false) Long deviceId)
{
String feature = trainStudentService.extract(faceId, deviceId);
if (StringUtils.isEmpty(feature))
{
return error("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本");
}
// 注意:此处必须用 AjaxResult.success 全限定调用。
// 继承而来的 BaseController.success 只有单参重载,写 success(msg, data) 会编译失败。
return AjaxResult.success("特征提取成功", feature);
}
@Log(title = "人脸批量特征提取", businessType = BusinessType.UPDATE)
@PostMapping("/batch/extract")
public AjaxResult batchExtract(@RequestBody Long[] faceIds, @RequestParam(required = false) Long deviceId)
{
int count = trainStudentService.batchExtract(faceIds, deviceId);
return success("成功提取 " + count + " 条");
}
/**
* 导出学员台账 (POST /train/student/export)。
*
* <p>必须使用带 {@code @Excel} 的 {@link StudentExportVO}:ExcelUtil 只收集带该注解的字段,
* 若直接导出列表 VO(无注解)会生成只有空行、没有表头与单元格的"空表"。</p>
*
* <p>筛选条件与列表页一致;传入 planId 时按培训班维度过滤(排除已退训学员)。</p>
*/
@PreAuthorize("@ss.hasPermi('train:student:list')")
@Log(title = "学员台账", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, StudentQuery query)
{
List<StudentExportVO> list = trainStudentService.selectStudentExportList(query);
ExcelUtil<StudentExportVO> util = new ExcelUtil<StudentExportVO>(StudentExportVO.class);
util.exportExcel(response, list, "学员台账");
}
@Log(title = "人脸特征提取", businessType = BusinessType.UPDATE)
@PostMapping("/{sId}/extractStudent")
public AjaxResult extractStudent(@PathVariable String sId, @RequestParam(required = false) Long deviceId)
{
String feature = trainStudentService.extractStudent(sId, deviceId);
if (StringUtils.isEmpty(feature))
{
return error("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本");
}
// 注意:此处必须用 AjaxResult.success 全限定调用。
// 继承而来的 BaseController.success 只有单参重载,写 success(msg, data) 会编译失败。
return AjaxResult.success("特征提取成功", feature);
}
}
@@ -0,0 +1,131 @@
package com.ruoyi.web.controller.train;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.dto.TeacherAccommodationDTO;
import com.ruoyi.train.domain.dto.TeacherAccommodationSaveResult;
import com.ruoyi.train.domain.vo.TeacherAccommodationVO;
import com.ruoyi.train.service.ITrainTeacherAccommodationService;
/**
* 教师住宿安排 Controller
* <p>
* 与学生住宿(/train/accommodation)完全隔离:教师住宿不关联班级,
* enrollment_id 字段存放教师主键,occupant_type 固定为 teacher。
*
* @author ruoyi
* @date 2026-11-04
*/
@RestController
@RequestMapping("/train/teacher/accommodation")
public class TrainTeacherAccommodationController extends BaseController
{
@Autowired
private ITrainTeacherAccommodationService trainTeacherAccommodationService;
/**
* 批量查询教师当前有效住宿安排 (教师列表页住宿列回显)
*/
@PreAuthorize("@ss.hasPermi('train:teacher:list')")
@GetMapping("/list")
public AjaxResult list(@RequestParam(value = "teacherIds", required = false) List<Long> teacherIds)
{
if (teacherIds == null || teacherIds.isEmpty())
{
return AjaxResult.success(Collections.emptyList());
}
return AjaxResult.success(trainTeacherAccommodationService.listActiveByTeacherIds(teacherIds));
}
/**
* 查询教师当前有效住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:teacher:query')")
@GetMapping("/{teacherId}")
public AjaxResult getByTeacherId(@PathVariable("teacherId") Long teacherId)
{
return AjaxResult.success(trainTeacherAccommodationService.getActiveByTeacherId(teacherId));
}
/**
* 查询教师住宿历史记录 (含已退宿)
*/
@PreAuthorize("@ss.hasPermi('train:teacher:query')")
@GetMapping("/{teacherId}/history")
public AjaxResult getHistoryByTeacherId(@PathVariable("teacherId") Long teacherId)
{
return AjaxResult.success(trainTeacherAccommodationService.listHistoryByTeacherId(teacherId));
}
/**
* 教师住宿分配 / 调整宿舍 (教师住宿与班级无关)
*/
@PreAuthorize("@ss.hasPermi('train:teacher:edit')")
@Log(title = "教师住宿安排", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult assign(@RequestBody TeacherAccommodationDTO dto)
{
if (dto == null || dto.getTeacherId() == null)
{
return AjaxResult.error("教师ID (teacherId) 不能为空");
}
TeacherAccommodationSaveResult saveResult = trainTeacherAccommodationService.assign(dto.getTeacherId(),
dto.getDormId(), dto.getBedNo(), getUsernameSafe());
if (saveResult == null || saveResult.getAccommodation() == null)
{
return AjaxResult.error("教师住宿安排失败");
}
// 住宿主流程成功即返回成功;人脸校验未通过时拒绝开锁授权,由 lockAuthorized/lockMessage 透出原因,不改变 code 语义
TeacherAccommodationVO accommodation = saveResult.getAccommodation();
Map<String, Object> data = new HashMap<>(8);
data.put("accommodationId", accommodation.getAccommodationId());
data.put("lockApiBizNo", accommodation.getLockApiBizNo());
data.put("lockAuthorized", saveResult.isLockAuthorized());
data.put("lockStatus", saveResult.getLockStatus());
data.put("lockMessage", saveResult.getLockMessage());
data.put("accommodation", accommodation);
return AjaxResult.success(data);
}
/**
* 教师退宿:门锁退宿撤权 + 人脸库删除 (不清空教师头像)
*/
@PreAuthorize("@ss.hasPermi('train:teacher:edit')")
@Log(title = "教师住宿安排", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/checkout")
public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId)
{
return toAjax(trainTeacherAccommodationService.checkOut(accommodationId, getUsernameSafe()));
}
/**
* 取当前登录用户名,取不到时回落 admin (与住宿安排 Controller 保持一致)
*/
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception ex)
{
return "admin";
}
}
}
@@ -2,6 +2,11 @@ package com.ruoyi.web.controller.train;
import java.util.List;
import java.util.regex.Pattern;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.train.domain.query.StudentQuery;
import com.ruoyi.train.domain.vo.StudentItemVO;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -105,7 +110,7 @@ public class TrainTeacherController extends BaseController
{
return AjaxResult.error("手机号码格式不正确");
}
return toAjax(trainTeacherService.insertTrainTeacher(trainTeacher));
return success(bindingAdminService.createTeacherWithAccount(trainTeacher));
}
/**
@@ -125,7 +130,7 @@ public class TrainTeacherController extends BaseController
{
return AjaxResult.error("手机号码格式不正确");
}
return toAjax(trainTeacherService.updateTrainTeacher(trainTeacher));
return success(bindingAdminService.updateTeacherWithAccount(trainTeacher));
}
/**
@@ -139,6 +144,15 @@ public class TrainTeacherController extends BaseController
return toAjax(trainTeacherService.deleteTrainTeacherByTId(tId));
}
@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, "教师数据");
}
private boolean isValidPhone(String phone)
{
return PHONE_PATTERN.matcher(phone).matches();
@@ -0,0 +1,107 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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.poi.ExcelUtil;
import com.ruoyi.train.domain.dto.TrainTheoryExamScoreDTO;
import com.ruoyi.train.domain.vo.TheoryExamScoreExportVO;
import com.ruoyi.train.domain.vo.TrainTheoryExamScoreVO;
import com.ruoyi.train.service.ITrainTheoryExamScoreService;
/**
* 班级学员理论考试成绩Controller
*
* @author ruoyi
* @date 2026-11-06
*/
@RestController
@RequestMapping("/train/theoryExamScore")
public class TrainTheoryExamScoreController extends BaseController
{
@Autowired
private ITrainTheoryExamScoreService trainTheoryExamScoreService;
/**
* 查询理论考试成绩列表(分页 + 条件查询)
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:list')")
@GetMapping("/list")
public TableDataInfo list(TrainTheoryExamScoreVO query)
{
startPage();
List<TrainTheoryExamScoreVO> list = trainTheoryExamScoreService.selectTrainTheoryExamScoreList(query);
return getDataTable(list);
}
/**
* 导出理论成绩:按当前查询条件导出全部数据(不调用 startPage,故不受分页限制),
* 数据按班级 → 课程 → 学号(person_no)排序
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:export') or @ss.hasPermi('train:theoryExamScore:list')")
@Log(title = "理论成绩导出", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainTheoryExamScoreVO query)
{
List<TheoryExamScoreExportVO> list = trainTheoryExamScoreService.selectTheoryExamScoreExportList(query);
ExcelUtil<TheoryExamScoreExportVO> util = new ExcelUtil<TheoryExamScoreExportVO>(TheoryExamScoreExportVO.class);
util.exportExcel(response, list, "理论成绩");
}
/**
* 获取理论考试成绩详情(含学员/班级/课程名称,避免页面展示原始ID)
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:query')")
@GetMapping(value = "/{theoryScoreId}")
public AjaxResult getInfo(@PathVariable("theoryScoreId") Long theoryScoreId)
{
return success(trainTheoryExamScoreService.selectTrainTheoryExamScoreDetail(theoryScoreId));
}
/**
* 新增理论考试成绩
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:add')")
@Log(title = "理论考试成绩", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainTheoryExamScoreDTO dto)
{
return toAjax(trainTheoryExamScoreService.insertTrainTheoryExamScore(dto));
}
/**
* 修改理论考试成绩
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:edit')")
@Log(title = "理论考试成绩", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainTheoryExamScoreDTO dto)
{
return toAjax(trainTheoryExamScoreService.updateTrainTheoryExamScore(dto));
}
/**
* 删除理论考试成绩(逻辑删除,支持批量)
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:remove')")
@Log(title = "理论考试成绩", businessType = BusinessType.DELETE)
@DeleteMapping("/{theoryScoreIds}")
public AjaxResult remove(@PathVariable Long[] theoryScoreIds)
{
return toAjax(trainTheoryExamScoreService.deleteTrainTheoryExamScoreByTheoryScoreIds(theoryScoreIds));
}
}
@@ -0,0 +1,74 @@
package com.ruoyi.web.controller.train;
import java.io.IOException;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
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.vo.TheoryScoreImportResultVO;
import com.ruoyi.train.service.TheoryScoreImportService;
/**
* 理论考试成绩批量导入 Controller(新增)
*
* <p>与既有 TrainTheoryExamScoreController 共用 /train/theoryExamScore 前缀,
* 仅新增 importTemplate / importData 两个端点,不改动任何既有端点。</p>
*/
@RestController
@RequestMapping("/train/theoryExamScore")
public class TrainTheoryExamScoreImportController extends BaseController
{
@Autowired
private TheoryScoreImportService theoryScoreImportService;
/**
* 下载按“班级 + 课程”预填在册学员的导入模板
*
* @param response 响应
* @param planId 培训班ID
* @param planCourseId 培训课程实例ID
* @param strictStatus 是否仅导出已报到/已完成学员
* @param format 已废弃:模板固定输出 xlsx,无需用户选择
* @throws IOException IO异常
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:import') or @ss.hasPermi('train:theoryExamScore:add')")
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response,
@RequestParam(value = "planId", required = false) String planId,
@RequestParam(value = "planCourseId", required = false) Long planCourseId,
@RequestParam(value = "strictStatus", required = false, defaultValue = "false") boolean strictStatus,
@RequestParam(value = "format", required = false, defaultValue = "xlsx") String format)
throws IOException
{
theoryScoreImportService.exportTemplate(response, planId, planCourseId, strictStatus, format);
}
/**
* 上传并导入理论成绩
*
* @param file Excel 文件
* @param planId 培训班ID
* @param planCourseId 培训课程实例ID
* @param importMode 冲突策略:SKIP 跳过 / OVERWRITE 覆盖 / STRICT 严格
* @return 导入结果(成功、覆盖、跳过、失败数量及逐行错误明细)
*/
@PreAuthorize("@ss.hasPermi('train:theoryExamScore:import') or @ss.hasPermi('train:theoryExamScore:add')")
@Log(title = "理论考试成绩导入", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(@RequestParam("file") MultipartFile file,
@RequestParam(value = "planId", required = false) String planId,
@RequestParam(value = "planCourseId", required = false) Long planCourseId,
@RequestParam(value = "importMode", required = false, defaultValue = "SKIP") String importMode)
{
TheoryScoreImportResultVO result = theoryScoreImportService.importData(file, planId, planCourseId, importMode);
return success(result);
}
}
@@ -56,6 +56,16 @@ public class TrainTrainingSiteController extends BaseController
return success(trainTrainingSiteService.selectTrainTrainingSiteBySiteId(siteId));
}
/**
* 获取某开班计划(planId)课表中用到的实训基地信息(按实训基地去重)
*/
@PreAuthorize("@ss.hasPermi('train:site:list')")
@GetMapping("/plan/{planId}")
public AjaxResult listByPlan(@PathVariable("planId") String planId)
{
return success(trainTrainingSiteService.selectTrainTrainingSiteByPlanId(planId));
}
/**
* 新增实训场地
*/
@@ -0,0 +1,193 @@
package com.ruoyi.web.filter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.device.domain.TrainIotDevice;
import com.ruoyi.device.service.ITrainIotDeviceService;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
/**
* 设备端 HMAC 签名校验过滤器(仅作用于设备登录接口)。
*
* 安全约定(客户端需遵守):
* 1. 请求头携带 {@code X-Signature} 与 {@code X-Nonce};
* 2. 签名串 StringToSign =
* HTTP_METHOD + "\n" + ServletPath + "\n" + body.timestamp + "\n" + nonce + "\n" + 原始请求体(Raw Body);
* 3. X-Signature = HMAC-SHA256(设备 deviceSecretDigest, StringToSign) 的十六进制小写;
* 设备档案仅存储 deviceSecretDigest(raw secret 的 SHA256 摘要),服务端以此摘要作为 HMAC 密钥,
* 全程不接触明文密钥;
* 4. body.timestamp 与服务器时间偏差不得超过 5 分钟,用于防重放。
*
* 该接口已在 SecurityConfig 中 permitAll,由本过滤器在到达控制器前完成设备身份验签。
*
* @author ruoyi
* @date 2026-10-20
*/
public class DeviceHmacFilter extends OncePerRequestFilter
{
private static final String LOGIN_PATH = "/api/iot/v1/device/login";
private static final String SIGNATURE_HEADER = "X-Signature";
private static final String NONCE_HEADER = "X-Nonce";
private static final long MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000L;
private boolean enabled = true;
private ITrainIotDeviceService iotDeviceService;
public void setIotDeviceService(ITrainIotDeviceService iotDeviceService)
{
this.iotDeviceService = iotDeviceService;
}
public void setEnabled(boolean enabled)
{
this.enabled = enabled;
}
// 不依赖容器 ObjectMapper bean(本项目未将其注册为 bean),本地实例化即可满足取 deviceId/timestamp 的解析需求
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected boolean shouldNotFilter(HttpServletRequest request)
{
// 本地联调可关闭验签(iot.hmac.enabled=false);否则仅对设备登录接口做 HMAC 校验
if (!enabled)
{
return true;
}
return !LOGIN_PATH.equals(request.getServletPath());
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException
{
// 包装请求并缓存 body,供下游控制器 @RequestBody 复用
ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request, 10 * 1024 * 1024);
byte[] body = readAndCacheBody(wrapped);
String signature = wrapped.getHeader(SIGNATURE_HEADER);
String nonce = wrapped.getHeader(NONCE_HEADER);
try
{
if (signature == null || signature.isEmpty() || nonce == null || nonce.isEmpty())
{
writeUnauthorized(response, "缺少设备签名头");
return;
}
JsonNode root = objectMapper.readTree(body);
String deviceId = root.path("deviceId").asText(null);
long timestamp = root.path("timestamp").asLong(0L);
TrainIotDevice device = (deviceId == null) ? null : iotDeviceService.selectTrainIotDeviceByDeviceId(deviceId);
if (device == null)
{
writeUnauthorized(response, "设备不存在");
return;
}
if (device.getDeviceStatus() != null && device.getDeviceStatus() == 2)
{
writeUnauthorized(response, "设备已禁用");
return;
}
String secret = device.getDeviceSecretDigest();
if (secret == null || secret.isEmpty())
{
writeUnauthorized(response, "设备未配置密钥");
return;
}
long now = Instant.now().toEpochMilli();
if (timestamp <= 0 || Math.abs(now - timestamp) > MAX_TIMESTAMP_SKEW_MS)
{
writeUnauthorized(response, "时间戳超时");
return;
}
String stringToSign = buildStringToSign(wrapped.getMethod(), wrapped.getServletPath(), timestamp, nonce, body);
String expected = hmacSha256Hex(secret, stringToSign);
if (!constantTimeEquals(expected, signature))
{
writeUnauthorized(response, "签名校验失败");
return;
}
}
catch (Exception e)
{
writeUnauthorized(response, "签名校验异常");
return;
}
filterChain.doFilter(wrapped, response);
}
private byte[] readAndCacheBody(ContentCachingRequestWrapper wrapped) throws IOException
{
InputStream in = wrapped.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1)
{
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
private String buildStringToSign(String method, String path, long timestamp, String nonce, byte[] body)
{
return method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + new String(body, StandardCharsets.UTF_8);
}
private String hmacSha256Hex(String key, String data) throws Exception
{
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw)
{
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private boolean constantTimeEquals(String a, String b)
{
if (a == null || b == null)
{
return false;
}
byte[] ab = a.getBytes(StandardCharsets.UTF_8);
byte[] bb = b.getBytes(StandardCharsets.UTF_8);
if (ab.length != bb.length)
{
return false;
}
int result = 0;
for (int i = 0; i < ab.length; i++)
{
result |= ab[i] ^ bb[i];
}
return result == 0;
}
private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException
{
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(AjaxResult.error(msg)));
}
}
@@ -0,0 +1,34 @@
package com.ruoyi.web.filter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import com.ruoyi.device.service.ITrainIotDeviceService;
/**
* 设备 HMAC 验签过滤器注册:仅对设备登录接口生效,避免影响其它接口。
*
* @author ruoyi
* @date 2026-10-20
*/
@Configuration
public class DeviceHmacFilterConfig
{
@Bean
public FilterRegistrationBean<DeviceHmacFilter> deviceHmacFilterRegistration(
ITrainIotDeviceService iotDeviceService,
@Value("${iot.hmac.enabled:true}") boolean hmacEnabled)
{
DeviceHmacFilter filter = new DeviceHmacFilter();
filter.setIotDeviceService(iotDeviceService);
filter.setEnabled(hmacEnabled);
FilterRegistrationBean<DeviceHmacFilter> registration = new FilterRegistrationBean<>(filter);
// 仅对设备登录接口做 HMAC 验签
registration.addUrlPatterns("/api/iot/v1/device/login");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setName("deviceHmacFilter");
return registration;
}
}
@@ -1,23 +1,39 @@
package com.ruoyi.web.service.train;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysRoleService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.vo.*;
import com.ruoyi.train.domain.dto.StudentCreateDTO;
import com.ruoyi.train.domain.dto.StudentUpdateDTO;
import com.ruoyi.train.domain.vo.BindableUserVO;
import com.ruoyi.train.domain.vo.StudentAdminVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.domain.vo.TeacherAdminVO;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainTeacherService;
import com.ruoyi.train.service.ITrainingIdentityService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/**
* 管理员端业务身份账号绑定编排服务。
@@ -36,6 +52,26 @@ public class TrainAccountBindingAdminService
private final TrainTeacherMapper teacherMapper;
private final TrainStudentMapper studentMapper;
/**
* 学员主档服务(跨模块依赖,通过字段注入以避免改动既有构造函数签名影响单元测试)。
* 用于“新增/修改学员时自动创建登录账号”这一编排场景。
*/
@Autowired(required = false)
private ITrainStudentService trainStudentService;
/**
* 教师主档服务(跨模块依赖,字段注入,与学员服务同理)。
* 用于“新增/修改教师时自动创建登录账号”这一编排场景。
*/
@Autowired(required = false)
private ITrainTeacherService trainTeacherService;
/**
* 系统用户 Mapper(用于按需同步部门/邮箱/性别等基础字段,避免走 updateUser 重置角色/岗位)。
*/
@Autowired(required = false)
private SysUserMapper userMapper;
public TrainAccountBindingAdminService(ITrainingIdentityService identityService,
ISysUserService userService,
ISysRoleService roleService,
@@ -345,15 +381,19 @@ public class TrainAccountBindingAdminService
// 2. 单次批量查询学员的关联 userId 映射
Map<String, Long> studentUserMap;
Map<String, TrainStudent> studentMap;
if (!sIds.isEmpty())
{
List<TrainStudent> students = studentMapper.selectTrainStudentsBySIds(sIds);
studentMap = students.stream()
.collect(Collectors.toMap(TrainStudent::getsId, s -> s, (k1, k2) -> k1));
studentUserMap = students.stream()
.filter(s -> s.getUserId() != null)
.collect(Collectors.toMap(TrainStudent::getsId, TrainStudent::getUserId, (k1, k2) -> k1));
}
else
{
studentMap = Collections.emptyMap();
studentUserMap = Collections.emptyMap();
}
@@ -376,6 +416,12 @@ public class TrainAccountBindingAdminService
{
StudentAdminVO vo = new StudentAdminVO();
BeanUtils.copyBeanProp(vo, item);
// 列表视图本身不含建档时间,这里从批量查询到的学员档案补齐(批量添加参训人员需展示创建时间)
TrainStudent student = studentMap.get(item.getsId());
if (student != null && student.getCreateTime() != null)
{
vo.setCreateTime(student.getCreateTime());
}
Long uid = studentUserMap.get(item.getsId());
if (uid != null)
{
@@ -430,6 +476,327 @@ public class TrainAccountBindingAdminService
return vo;
}
/**
* 新增学员主档并自动创建登录账号。
*
* <p>以学员联系电话作为登录用户名、123456 作为初始密码创建系统用户,
* 分配“培训学员”角色,并把生成的系统用户ID回写到 train_student.user_id。
* 学员建档与账号创建在同一事务内完成,保证数据一致。</p>
*
* @param dto 学员新增请求
* @return 学员主档ID
*/
@Transactional(rollbackFor = Exception.class)
public String createStudentWithAccount(StudentCreateDTO dto)
{
if (trainStudentService == null)
{
throw new ServiceException("学员主档服务不可用");
}
String sId = trainStudentService.createStudent(dto);
ensureStudentAccount(sId, dto.getsPhone(), dto.getsName());
return sId;
}
/**
* 修改学员主档并自动创建登录账号(若尚未绑定)。
*
* @param dto 学员修改请求(携带 sId)
* @return 学员主档ID
*/
@Transactional(rollbackFor = Exception.class)
public String updateStudentWithAccount(StudentUpdateDTO dto)
{
if (trainStudentService == null)
{
throw new ServiceException("学员主档服务不可用");
}
String sId = trainStudentService.updateTrainStudent(dto);
ensureStudentAccount(sId, dto.getsPhone(), dto.getsName());
return sId;
}
/**
* 新增教师主档并自动创建登录账号。
*
* <p>以教师联系电话作为登录用户名、123456 作为初始密码创建系统用户,
* 分配“培训教师”角色,并把生成的系统用户ID回写到 train_teacher.user_id。
* 教师建档与账号创建在同一事务内完成,保证数据一致。</p>
*
* @param teacher 教师新增请求(实体)
* @return 教师主档ID(工号)
*/
@Transactional(rollbackFor = Exception.class)
public String createTeacherWithAccount(TrainTeacher teacher)
{
if (trainTeacherService == null)
{
throw new ServiceException("教师主档服务不可用");
}
trainTeacherService.insertTrainTeacher(teacher);
String tId = teacher.gettId();
ensureTeacherAccount(tId, teacher.gettPhone(), teacher.gettName());
return tId;
}
/**
* 修改教师主档并自动创建登录账号(若尚未绑定)。
*
* @param teacher 教师修改请求(携带 tId)
* @return 教师主档ID(工号)
*/
@Transactional(rollbackFor = Exception.class)
public String updateTeacherWithAccount(TrainTeacher teacher)
{
if (trainTeacherService == null)
{
throw new ServiceException("教师主档服务不可用");
}
trainTeacherService.updateTrainTeacher(teacher);
ensureTeacherAccount(teacher.gettId(), teacher.gettPhone(), teacher.gettName());
return teacher.gettId();
}
/**
* 确保教师主档已绑定登录账号:若尚未绑定,则以联系电话为用户名、123456 为初始密码
* 创建系统用户,分配“培训教师”角色,并把 user_id 回写到 train_teacher;已绑定则同步基础信息。
*
* <p>无论新建还是已绑定,都会将教师的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。</p>
*
* @param tId 教师主档ID(工号)
* @param phone 联系电话(同时作为登录用户名)
* @param name 教师姓名(作为用户昵称)
* @return 绑定的系统用户ID
*/
@Transactional(rollbackFor = Exception.class)
public Long ensureTeacherAccount(String tId, String phone, String name)
{
TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId);
if (teacher == null)
{
throw new ServiceException("教师业务档案不存在");
}
Long userId = teacher.getUserId();
if (userId == null)
{
if (StringUtils.isEmpty(phone))
{
throw new ServiceException("教师联系电话不能为空,无法创建登录账号");
}
// 登录账号(用户名 = 联系电话)唯一性校验
SysUser probe = new SysUser();
probe.setUserName(phone);
if (!userService.checkUserNameUnique(probe))
{
throw new ServiceException("登录账号(" + phone + ")已存在,无法重复创建");
}
// 解析“培训教师”角色 (roleKey = train_teacher)
Long roleId = resolveRoleIdByKey("train_teacher");
SysUser user = new SysUser();
user.setUserName(phone);
user.setNickName(StringUtils.isNotEmpty(name) ? name : phone);
user.setPhonenumber(phone);
user.setPassword(SecurityUtils.encryptPassword("123456"));
user.setStatus("0");
user.setDelFlag("0");
// 同步部门、邮箱、性别到登录用户
user.setDeptId(parseDeptId(teacher.getDeptName()));
user.setEmail(teacher.gettEmail());
user.setSex(toSysUserSex(teacher.gettGender()));
user.setRoleIds(new Long[] { roleId });
// insertUser 内部会写入角色关联并回填自增 user_id
userService.insertUser(user);
userId = user.getUserId();
System.out.println("userId = " + userId);
if (userId == null)
{
throw new ServiceException("创建登录账号失败");
}
// 复用底层原子互斥绑定,将 user_id 回写到 train_teacher
if (userId != 0)
{
identityService.bindTeacherUser(tId, userId);
}
}
// 已绑定或刚创建:将部门、邮箱、性别同步到关联登录用户(仅更新非空字段,不影响角色/密码)
syncUserBasicInfo(userId, parseDeptId(teacher.getDeptName()),
teacher.gettEmail(), toSysUserSex(teacher.gettGender()));
return userId;
}
/**
* 确保学员主档已绑定登录账号:若尚未绑定,则以联系电话为用户名、123456 为初始密码
* 创建系统用户,分配“培训学员”角色,并把 user_id 回写到 train_student;已绑定则同步基础信息。
*
* <p>无论新建还是已绑定,都会将学员的部门、邮箱、性别同步到关联登录用户 sys_user 的对应字段。</p>
*
* @param sId 学员主档ID
* @param phone 联系电话(同时作为登录用户名)
* @param name 学员姓名(作为用户昵称)
* @return 绑定的系统用户ID
*/
@Transactional(rollbackFor = Exception.class)
public Long ensureStudentAccount(String sId, String phone, String name)
{
TrainStudent student = studentMapper.selectTrainStudentBySId(sId);
if (student == null)
{
throw new ServiceException("学员业务档案不存在");
}
Long userId = student.getUserId();
if (userId == null)
{
if (StringUtils.isEmpty(phone))
{
throw new ServiceException("学员联系电话不能为空,无法创建登录账号");
}
// 登录账号(用户名 = 联系电话)唯一性校验
SysUser probe = new SysUser();
probe.setUserName(phone);
if (!userService.checkUserNameUnique(probe))
{
throw new ServiceException("登录账号(" + phone + ")已存在,无法重复创建");
}
// 解析“培训学员”角色 (roleKey = train_student)
Long roleId = resolveRoleIdByKey("train_student");
SysUser user = new SysUser();
user.setUserName(phone);
user.setNickName(StringUtils.isNotEmpty(name) ? name : phone);
user.setPhonenumber(phone);
user.setPassword(SecurityUtils.encryptPassword("123456"));
user.setStatus("0");
user.setDelFlag("0");
// 同步部门、邮箱、性别到登录用户
user.setDeptId(parseDeptId(student.getDeptName()));
user.setEmail(student.getsEmail());
user.setSex(toSysUserSex(student.getsGender()));
user.setRoleIds(new Long[] { roleId });
// insertUser 内部会写入角色关联并回填自增 user_id
userService.insertUser(user);
userId = user.getUserId();
System.out.println("userId = " + userId);
if (userId == null)
{
throw new ServiceException("创建登录账号失败");
}
// 复用底层原子互斥绑定,将 user_id 回写到 train_student
identityService.bindStudentUser(sId, userId);
}
// 已绑定或刚创建:将部门、邮箱、性别同步到关联登录用户(仅更新非空字段,不影响角色/密码)
syncUserBasicInfo(userId, parseDeptId(student.getDeptName()),
student.getsEmail(), toSysUserSex(student.getsGender()));
return userId;
}
/**
* 按角色权限字符串查找角色ID。
*
* @param roleKey 角色权限字符串
* @return 角色ID
*/
private Long resolveRoleIdByKey(String roleKey)
{
List<SysRole> roles = roleService.selectRoleAll();
if (roles != null)
{
for (SysRole role : roles)
{
if (roleKey.equals(role.getRoleKey()))
{
return role.getRoleId();
}
}
}
throw new ServiceException("未找到角色(" + roleKey + "),无法分配");
}
/**
* 将部门、邮箱、性别同步到关联登录用户 sys_user 的基础字段。
*
* <p>仅更新非空字段(dept_id 为空/0 不更新、email/sex 为 null 不更新),
* 不会触碰密码、角色、岗位、状态等,避免覆盖系统用户管理的其他配置。</p>
*
* @param userId 系统用户ID
* @param deptId 部门ID(学员/教师的 deptName,即部门树节点ID)
* @param email 邮箱
* @param sex 性别(sys_user 标准值:0 男 / 1 女 / 2 未知)
*/
private void syncUserBasicInfo(Long userId, Long deptId, String email, String sex)
{
if (userMapper == null)
{
return;
}
SysUser user = new SysUser();
user.setUserId(userId);
user.setDeptId(deptId);
user.setEmail(email);
user.setSex(sex);
userMapper.updateUser(user);
}
/**
* 将学员/教师的 deptName(归属部门树节点ID,字符串形式)解析为系统用户部门ID。
*
* @param deptName 归属部门(部门树节点ID)
* @return 部门ID;非数字或为空时返回 null(不更新部门)
*/
private Long parseDeptId(String deptName)
{
if (StringUtils.isEmpty(deptName))
{
return null;
}
try
{
return Long.valueOf(deptName.trim());
}
catch (NumberFormatException e)
{
return null;
}
}
/**
* 将业务性别转换为 sys_user.sex 标准值。
*
* @param gender 业务性别(学员为 0/1/2;教师为 男/女)
* @return sys_user.sex 标准值(0 男 / 1 女 / 2 未知);无法识别时返回 null(不更新性别)
*/
private String toSysUserSex(String gender)
{
if (StringUtils.isEmpty(gender))
{
return null;
}
if ("男".equals(gender) || "0".equals(gender))
{
return "0";
}
if ("女".equals(gender) || "1".equals(gender))
{
return "1";
}
if ("2".equals(gender) || "未知".equals(gender))
{
return "2";
}
return null;
}
private void validateUserRole(Long userId, String requiredRoleKey, String roleLabel)
{
SysUser user = userService.selectUserById(userId);
@@ -3,16 +3,33 @@ spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
# KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver
driverClassName: com.kingbase8.Driver
driverClassName: com.mysql.cj.jdbc.Driver
#driverClassName: com.kingbase8.Driver
druid:
# 主库数据源
master:
url: ${SPRING_DATASOURCE_DRUID_MASTER_URL:jdbc:kingbase8://${DB_HOST:127.0.0.1}:${DB_PORT:54321}/${DB_NAME:railwaysystem_fusion}?useUnicode=true&characterEncoding=utf8}
username: ${SPRING_DATASOURCE_DRUID_MASTER_USERNAME:${QA_DB_USER:fusion_app}}
password: ${SPRING_DATASOURCE_DRUID_MASTER_PASSWORD:${FUSION_QA_APP_PASSWORD:}}
# 多模式透明路由与系统视图隔离
connectionInitSqls:
- set search_path to iotlocks, public, pg_catalog, sys_catalog
# KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public
# MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public
##username: kingbase
##password: sict123456
##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks
##username: system
##password: kingbase
url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
#url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: root
password: psw.db7898
# KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。
# 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面,
# 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。
# 因此这里必须显式写出 sys_catalog 并排在业务模式之后。
# 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。
#connectionInitSqls:
#- set search_path to public, pg_catalog, sys_catalog
# 从库数据源
slave:
# 从数据源开关/默认关闭
@@ -48,13 +65,14 @@ spring:
webStatFilter:
enabled: true
statViewServlet:
enabled: ${DRUID_CONSOLE_ENABLED:false}
# 设置白名单,不填则允许所有访问
allow:
enabled: false
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ${DRUID_CONSOLE_USERNAME:}
login-password: ${DRUID_CONSOLE_PASSWORD:}
login-username: admin
login-password: admin123
# IP白名单,本地开发填写127.0.0.1
allow: 127.0.0.1
reset-enable: false
filter:
stat:
enabled: true
@@ -0,0 +1,87 @@
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
# KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver
driverClassName: com.mysql.cj.jdbc.Driver
#driverClassName: com.kingbase8.Driver
druid:
# 主库数据源
master:
# KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public
# MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public
##username: kingbase
##password: sict123456
##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks
##username: system
##password: kingbase
#url: jdbc:mysql://localhost:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
url: jdbc:mysql://192.168.1.230:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: root
password: psw.db7898
# KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。
# 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面,
# 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。
# 因此这里必须显式写出 sys_catalog 并排在业务模式之后。
# 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。
#connectionInitSqls:
#- set search_path to public, pg_catalog, sys_catalog
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
connectionInitSqls:
- set search_path to public, pg_catalog, sys_catalog
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法)
validationQuery: SELECT 1
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: false
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123
# IP白名单,本地开发填写127.0.0.1
allow: 127.0.0.1
reset-enable: false
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
# 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型
# db-type: kingbase
config:
multi-statement-allow: true
@@ -0,0 +1,87 @@
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
# KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver
driverClassName: com.mysql.cj.jdbc.Driver
#driverClassName: com.kingbase8.Driver
druid:
# 主库数据源
master:
# KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public
# MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public
##username: kingbase
##password: sict123456
##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks
##username: system
##password: kingbase
#url: jdbc:mysql://localhost:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
url: jdbc:mysql://192.168.106.24:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: root
password: psw.db7898
# KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。
# 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面,
# 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。
# 因此这里必须显式写出 sys_catalog 并排在业务模式之后。
# 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。
#connectionInitSqls:
#- set search_path to public, pg_catalog, sys_catalog
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
connectionInitSqls:
- set search_path to public, pg_catalog, sys_catalog
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法)
validationQuery: SELECT 1
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: false
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123
# IP白名单,本地开发填写127.0.0.1
allow: 127.0.0.1
reset-enable: false
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
# 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型
# db-type: kingbase
config:
multi-statement-allow: true
@@ -0,0 +1,87 @@
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
# KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver
driverClassName: com.mysql.cj.jdbc.Driver
#driverClassName: com.kingbase8.Driver
druid:
# 主库数据源
master:
# KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public
# MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public
##username: kingbase
##password: sict123456
##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks
##username: system
##password: kingbase
url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
#url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: root
password: psw.db7898
# KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。
# 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面,
# 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。
# 因此这里必须显式写出 sys_catalog 并排在业务模式之后。
# 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。
#connectionInitSqls:
#- set search_path to public, pg_catalog, sys_catalog
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
connectionInitSqls:
- set search_path to public, pg_catalog, sys_catalog
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法)
validationQuery: SELECT 1
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: false
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123
# IP白名单,本地开发填写127.0.0.1
allow: 127.0.0.1
reset-enable: false
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
# 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型
# db-type: kingbase
config:
multi-statement-allow: true
@@ -0,0 +1,88 @@
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
# KingbaseES 使用 com.kingbase8.Driver;切回 MySQL 使用 com.mysql.cj.jdbc.Driver
driverClassName: com.mysql.cj.jdbc.Driver
#driverClassName: com.kingbase8.Driver
druid:
# 主库数据源
master:
# KingbaseES: jdbc:kingbase8://localhost:54321/ry-vue?currentSchema=public
# MySQL: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
##url: jdbc:kingbase8://localhost:54321/training_railway?currentSchema=public
##username: kingbase
##password: sict123456
##url: jdbc:kingbase8://127.0.0.1:54321/training?currentSchema=iotlocks
##username: system
##password: kingbase
url: jdbc:mysql://127.0.0.1:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
#url: jdbc:mysql://localhost:3306/training_server?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
#url: jdbc:mysql://192.168.1.202:3306/training_railway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
username: blp
password: psw.db1234
# KingbaseES 的 SYS_CATALOG 内置了 sys_user、sys_config 等与业务表同名的系统对象。
# 注意:search_path 中若【不显式包含】sys_catalog,Kingbase 会自动把它加到最前面,
# 导致优先命中系统视图(报“字段 config_id 不存在”之类错误)。
# 因此这里必须显式写出 sys_catalog 并排在业务模式之后。
# 另需在 kingbase.conf 配置 search_path = '"$user", iotlocks, public, sys_catalog' 并重启数据库。
#connectionInitSqls:
#- set search_path to public, pg_catalog, sys_catalog
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
connectionInitSqls:
- set search_path to public, pg_catalog, sys_catalog
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效(SELECT 1 为 MySQL 与 KingbaseES 通用写法)
validationQuery: SELECT 1
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: false
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123
# IP白名单,本地开发填写127.0.0.1
allow: 127.0.0.1
reset-enable: false
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
# 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型
# db-type: kingbase
config:
multi-statement-allow: true
@@ -57,9 +57,9 @@ spring:
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
max-file-size: 50MB
# 设置总上传的文件大小
max-request-size: 20MB
max-request-size: 100MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
@@ -92,6 +92,14 @@ spring:
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 人脸识别设备 API 配置
face:
# 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用)
api-base-url: http://192.168.1.202:9025
#api-base-url: http://192.168.1.230:9000
# 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用
enabled: true
# 培训与门禁业务配置
train:
# QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接
@@ -101,15 +109,19 @@ train:
identity:
encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}
hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}
# 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true
mock:
device:
enabled: false
# token配置
token:
# 令牌自定义标识
header: Authorization
# 令牌密钥
secret: ${TOKEN_SECRET:}
secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA==
# 令牌有效期(默认30分钟)
expireTime: 30
expireTime: 600
# MyBatis配置
mybatis:
@@ -139,8 +151,7 @@ springdoc:
- group: 'default'
display-name: '测试模块'
paths-to-match: '/**'
packages-to-scan: com.ruoyi.web.controller.tool
packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device
# 防盗链配置
referer:
# 防盗链开关
@@ -157,48 +168,69 @@ xss:
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# IOT设备接入(Mock 模式,拦截外部真实硬件)
# IOT设备接入(MQTT)
iot:
# 人脸特征提取服务 (Mock 拦截,绝不向外部真实接口发请求)
qa-api-enabled: ${IOT_QA_API_ENABLED:false}
face:
endpoint: ${IOT_FACE_ENDPOINT:}
mock: ${IOT_FACE_MOCK:true}
enabled: ${IOT_FACE_ENABLED:false}
real-http-enabled: ${IOT_FACE_REAL_HTTP_ENABLED:false}
environment-id: ${IOT_ENVIRONMENT_ID:local-mock}
allowed-endpoints: []
timeout: 3000
# 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key
openapi:
enabled: ${IOT_OPENAPI_ENABLED:false}
api-key: ${IOT_OPENAPI_KEY:}
path-prefix: /openapi/iot
mqtt:
# 彻底禁用真实 MQTT 连接,启用 Mock 替身模式
# 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段
hmac:
enabled: false
mock: true
# 真实硬件下行控制写入总开关(硬性安全门禁,默认为 false,严禁向真实硬件发控制指令)
real-device-write-enabled: false
# 真实设备 SN 白名单(归档配置默认值为空列表,联调时由环境配置或控制台注入)
device-whitelist: []
# 历史指令防重放保护(禁止向真实硬件重放系统启动前遗留的历史待发送指令)
replay-protection-enabled: true
# 定时补偿任务开关
retry-task-enabled: ${IOT_RETRY_TASK_ENABLED:false}
environment-id: ${IOT_ENVIRONMENT_ID:local-mock}
protocol-verified: false
broker-url: ${IOT_MQTT_BROKER_URL:tcp://127.0.0.1:1883}
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒)
allowed-endpoints:
- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
# lock_endpoint: http://192.168.1.203:8080
lock_endpoint: http://192.168.1.202:8085
api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy
avatar_profile: http://192.168.1.106:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
'80745D75','80745D9A','80745D99','80745D73',
'80745D9F','80745D92','80745D7A','80745D8E',
'80745DA7','80745D86','80745D72','80745DA8',
'80745D90','80745D77','80745D76','80745D96',
'80745D7B','80745DA4','80745D7E','80745D9C',
'80745D71','80745DA2','80745D82','80745DAA',
'80745D8D','80745D7F','80745D97','80745D8A',
'80745D6E','80745D9B','80745D94','80745D84',
'80745D9D','80745D6A','80745D7D','80745D70',
'80745DA5','80745D8C','80745D74','80745D6D',
'80745D8B','80745D6C','80745D98','80745D88',
'80745DAB','80745D87','80745D8F','80745DA1',
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.1.202:1883
#broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: ${IOT_MQTT_USERNAME:admin}
password: ${IOT_MQTT_PASSWORD:}
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: false
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
@@ -0,0 +1,259 @@
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
profile: ./uploadPath
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8096
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
threads:
# tomcat最大线程数,默认为200
max: 800
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: warn
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
# Spring配置
spring:
# 资源信息
messages:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: druid
# 文件上传
servlet:
multipart:
# 单个文件大小
max-file-size: 50MB
# 设置总上传的文件大小
max-request-size: 100MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
# 服务模块
devtools:
restart:
# 热部署开关
enabled: true
data:
# redis 配置
redis:
# 地址
host: localhost
# 端口,默认为6379
port: 6379
# 数据库索引 (独立 QA 测试隔离使用 14 库)
database: 14
# 密码
password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 人脸识别设备 API 配置
face:
# 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用)
api-base-url: http://192.168.1.202:9025
#api-base-url: http://192.168.1.230:9000
# 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用
enabled: true
# 培训与门禁业务配置
train:
# QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接
lock:
auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot}
student:
identity:
encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}
hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}
# 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true
mock:
device:
enabled: false
# token配置
token:
# 令牌自定义标识
header: Authorization
# 令牌密钥
secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA==
# 令牌有效期(默认30分钟)
expireTime: 600
# MyBatis配置
mybatis:
# 搜索指定包别名
typeAliasesPackage: com.ruoyi.**.domain
# 配置mapper的扫描,找到所有的mapper.xml映射文件
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 加载全局的配置文件
configLocation: classpath:mybatis/mybatis-config.xml
# PageHelper分页插件
pagehelper:
# 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES)
# 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase
supportMethodsArguments: true
params: count=countSql
# Springdoc配置
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
tags-sorter: alpha
group-configs:
- group: 'default'
display-name: '测试模块'
paths-to-match: '/**'
packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device
# 防盗链配置
referer:
# 防盗链开关
enabled: false
# 允许的域名列表
allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip
# 防止XSS攻击
xss:
# 过滤开关
enabled: true
# 排除链接(多个用逗号分隔)
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# IOT设备接入(MQTT)
iot:
# 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段
hmac:
enabled: false
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒)
allowed-endpoints:
#- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
# 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key
openapi:
enabled: true
api-key: please-change-me
path-prefix: /openapi/iot
mqtt:
# 开关,false 时不创建任何连接
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
'80745D75','80745D9A','80745D99','80745D73',
'80745D9F','80745D92','80745D7A','80745D8E',
'80745DA7','80745D86','80745D72','80745DA8',
'80745D90','80745D77','80745D76','80745D96',
'80745D7B','80745DA4','80745D7E','80745D9C',
'80745D71','80745DA2','80745D82','80745DAA',
'80745D8D','80745D7F','80745D97','80745D8A',
'80745D6E','80745D9B','80745D94','80745D84',
'80745D9D','80745D6A','80745D7D','80745D70',
'80745DA5','80745D8C','80745D74','80745D6D',
'80745D8B','80745D6C','80745D98','80745D88',
'80745DAB','80745D87','80745D8F','80745DA1',
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
#broker-url: tcp://192.168.1.202:1883
broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -0,0 +1,259 @@
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
profile: ./uploadPath
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8096
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
threads:
# tomcat最大线程数,默认为200
max: 800
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: warn
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
# Spring配置
spring:
# 资源信息
messages:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: druid
# 文件上传
servlet:
multipart:
# 单个文件大小
max-file-size: 50MB
# 设置总上传的文件大小
max-request-size: 100MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
# 服务模块
devtools:
restart:
# 热部署开关
enabled: true
data:
# redis 配置
redis:
# 地址
host: localhost
# 端口,默认为6379
port: 6379
# 数据库索引 (独立 QA 测试隔离使用 14 库)
database: 14
# 密码
password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 人脸识别设备 API 配置
face:
# 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用)
api-base-url: http://192.168.106.24:9025
#api-base-url: http://192.168.1.230:9000
# 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用
enabled: true
# 培训与门禁业务配置
train:
# QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接
lock:
auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot}
student:
identity:
encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}
hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}
# 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true
mock:
device:
enabled: false
# token配置
token:
# 令牌自定义标识
header: Authorization
# 令牌密钥
secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA==
# 令牌有效期(默认30分钟)
expireTime: 600
# MyBatis配置
mybatis:
# 搜索指定包别名
typeAliasesPackage: com.ruoyi.**.domain
# 配置mapper的扫描,找到所有的mapper.xml映射文件
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 加载全局的配置文件
configLocation: classpath:mybatis/mybatis-config.xml
# PageHelper分页插件
pagehelper:
# 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES)
# 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase
supportMethodsArguments: true
params: count=countSql
# Springdoc配置
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
tags-sorter: alpha
group-configs:
- group: 'default'
display-name: '测试模块'
paths-to-match: '/**'
packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device
# 防盗链配置
referer:
# 防盗链开关
enabled: false
# 允许的域名列表
allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip
# 防止XSS攻击
xss:
# 过滤开关
enabled: true
# 排除链接(多个用逗号分隔)
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# IOT设备接入(MQTT)
iot:
# 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段
hmac:
enabled: false
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
endpoint: http://192.168.106.24:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒)
allowed-endpoints:
#- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
lock_endpoint: http://192.168.106.24:8098
api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy
avatar_profile: http://192.168.106.24:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
'80745D75','80745D9A','80745D99','80745D73',
'80745D9F','80745D92','80745D7A','80745D8E',
'80745DA7','80745D86','80745D72','80745DA8',
'80745D90','80745D77','80745D76','80745D96',
'80745D7B','80745DA4','80745D7E','80745D9C',
'80745D71','80745DA2','80745D82','80745DAA',
'80745D8D','80745D7F','80745D97','80745D8A',
'80745D6E','80745D9B','80745D94','80745D84',
'80745D9D','80745D6A','80745D7D','80745D70',
'80745DA5','80745D8C','80745D74','80745D6D',
'80745D8B','80745D6C','80745D98','80745D88',
'80745DAB','80745D87','80745D8F','80745DA1',
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
#broker-url: tcp://192.168.1.202:1883
broker-url: tcp://192.168.106.24:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -0,0 +1,260 @@
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
profile: ./uploadPath
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8096
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
threads:
# tomcat最大线程数,默认为200
max: 800
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: warn
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
# Spring配置
spring:
# 资源信息
messages:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: druid
# 文件上传
servlet:
multipart:
# 单个文件大小
max-file-size: 50MB
# 设置总上传的文件大小
max-request-size: 100MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
# 服务模块
devtools:
restart:
# 热部署开关
enabled: true
data:
# redis 配置
redis:
# 地址
host: localhost
# 端口,默认为6379
port: 6379
# 数据库索引 (独立 QA 测试隔离使用 14 库)
database: 14
# 密码
password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 人脸识别设备 API 配置
face:
# 人脸识别服务端地址(设备未启用时仅用于模拟响应,不实际调用)
api-base-url: http://192.168.1.202:9025
#api-base-url: http://192.168.1.230:9000
# 是否实际调用人脸识别设备:false=返回模拟响应,true=真实调用
enabled: true
# 培训与门禁业务配置
train:
# QA 环境业务桥接引擎配置:显式使用自主 iot 门锁授权桥接
lock:
auth-engine: ${TRAIN_LOCK_AUTH_ENGINE:iot}
student:
identity:
encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}
hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}
# 模拟设备打卡开关:默认 false(禁用),仅允许在独立测试/QA 环境显式开启 true
mock:
device:
enabled: false
# token配置
token:
# 令牌自定义标识
header: Authorization
# 令牌密钥
secret: tBTCXCgTTrO32NvcpWm2+OQjk3w938P0FkOw0GuRFIee9oRHo3QaTCIeha1N1erESpCeNj7KjCovk4Gns+aiFA==
# 令牌有效期(默认30分钟)
expireTime: 600
# MyBatis配置
mybatis:
# 搜索指定包别名
typeAliasesPackage: com.ruoyi.**.domain
# 配置mapper的扫描,找到所有的mapper.xml映射文件
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 加载全局的配置文件
configLocation: classpath:mybatis/mybatis-config.xml
# PageHelper分页插件
pagehelper:
# 方言由 PageHelper 根据数据源自动识别(已内置 MySQL 与 KingbaseES)
# 如需强制指定,MySQL 填写 mysql,KingbaseES 填写 kingbase
supportMethodsArguments: true
params: count=countSql
# Springdoc配置
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
tags-sorter: alpha
group-configs:
- group: 'default'
display-name: '测试模块'
paths-to-match: '/**'
packages-to-scan: com.ruoyi.web.controller.tool,com.ruoyi.web.controller.device
# 防盗链配置
referer:
# 防盗链开关
enabled: false
# 允许的域名列表
allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip
# 防止XSS攻击
xss:
# 过滤开关
enabled: true
# 排除链接(多个用逗号分隔)
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# IOT设备接入(MQTT)
iot:
# 设备 HMAC 验签开关:本地联调可临时关闭(无需 X-Signature 头);生产环境务必改回 true 或删除该段
hmac:
enabled: false
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 特征提取服务地址白名单(fail-closed 护栏,endpoint 必须显式列入,否则提取被拒)
allowed-endpoints:
- http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
# lock_endpoint: http://192.168.1.203:8080
lock_endpoint: http://192.168.1.202:8085
api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy
avatar_profile: http://192.168.1.106:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
'80745D75','80745D9A','80745D99','80745D73',
'80745D9F','80745D92','80745D7A','80745D8E',
'80745DA7','80745D86','80745D72','80745DA8',
'80745D90','80745D77','80745D76','80745D96',
'80745D7B','80745DA4','80745D7E','80745D9C',
'80745D71','80745DA2','80745D82','80745DAA',
'80745D8D','80745D7F','80745D97','80745D8A',
'80745D6E','80745D9B','80745D94','80745D84',
'80745D9D','80745D6A','80745D7D','80745D70',
'80745DA5','80745D8C','80745D74','80745D6D',
'80745D8B','80745D6C','80745D98','80745D88',
'80745DAB','80745D87','80745D8F','80745DA1',
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.1.202:1883
#broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 日志存放路径 -->
<property name="log.path" value="C:/Users/zfjia/work/projects/ruoyi/logs" />
<property name="log.path" value="./logs" />
<!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
4 0 obj<</Length 52>>stream
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000053 00000 n
0000000105 00000 n
0000000220 00000 n
0000000317 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
384
%%EOF
@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
4 0 obj<</Length 52>>stream
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000053 00000 n
0000000105 00000 n
0000000220 00000 n
0000000317 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
384
%%EOF
@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
4 0 obj<</Length 52>>stream
BT /F1 18 Tf 50 780 Td (Railway Building Construction Safety Manual) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000053 00000 n
0000000105 00000 n
0000000220 00000 n
0000000317 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
384
%%EOF
@@ -0,0 +1,151 @@
package com.ruoyi.common.utils.json;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
/**
* JsonParamUtils 解析与校验契约测试。
*/
public class JsonParamUtilsTest
{
@Test
public void parse_ValidJson_ExtractsAllFieldTypes()
{
String json = "{\"studentId\":\"STU_1\",\"lessonId\":1001,\"score\":1.5,\"active\":true,"
+ "\"ext\":{\"room\":\"A-01\"},\"tags\":[1,2],\"remark\":null}";
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse(json);
assertTrue(result.getMessage(), result.isSuccess());
assertEquals("解析成功", result.getMessage());
assertEquals("STU_1", result.get("studentId"));
assertEquals(Integer.valueOf(1001), result.get("lessonId"));
assertEquals(new BigDecimal("1.5"), result.get("score"));
assertEquals(Boolean.TRUE, result.get("active"));
assertTrue(result.get("ext") instanceof Map);
assertEquals("A-01", ((Map<?, ?>) result.get("ext")).get("room"));
assertTrue(result.get("tags") instanceof List);
assertNull(result.get("remark"));
assertEquals(7, result.getData().size());
}
@Test
public void parse_NullOrBlank_ReturnsClearMessage()
{
assertEquals("JSON 参数不能为空", JsonParamUtils.parse(null).getMessage());
assertEquals("JSON 参数不能为空", JsonParamUtils.parse(" ").getMessage());
assertFalse(JsonParamUtils.parse("").isSuccess());
}
@Test
public void parse_IllegalSyntax_ReturnsFormatError()
{
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"studentId\":\"STU_1\"");
assertFalse(result.isSuccess());
assertTrue(result.getMessage(), result.getMessage().startsWith("JSON 格式不合法"));
assertTrue(result.getData().isEmpty());
}
@Test
public void parse_TopLevelArrayOrScalar_Rejected()
{
JsonParamUtils.JsonParamResult arrayResult = JsonParamUtils.parse("[1,2,3]");
assertFalse(arrayResult.isSuccess());
assertTrue(arrayResult.getMessage().contains("顶层必须是对象"));
assertTrue(arrayResult.getMessage().contains("数组"));
JsonParamUtils.JsonParamResult numberResult = JsonParamUtils.parse("123");
assertFalse(numberResult.isSuccess());
assertTrue(numberResult.getMessage().contains("数值"));
JsonParamUtils.JsonParamResult stringResult = JsonParamUtils.parse("\"abc\"");
assertFalse(stringResult.isSuccess());
assertTrue(stringResult.getMessage().contains("字符串"));
JsonParamUtils.JsonParamResult nullResult = JsonParamUtils.parse("null");
assertFalse(nullResult.isSuccess());
}
@Test
public void parse_WithSchema_ConvertsTypes()
{
Map<String, Class<?>> schema = new LinkedHashMap<>();
schema.put("lessonId", Long.class);
schema.put("studentId", String.class);
schema.put("manualFlag", Boolean.class);
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse(
"{\"lessonId\":\"1001\",\"studentId\":123,\"manualFlag\":1}", schema);
assertTrue(result.getMessage(), result.isSuccess());
assertEquals(Long.valueOf(1001L), result.get("lessonId"));
assertEquals("123", result.get("studentId"));
assertEquals(Boolean.TRUE, result.get("manualFlag"));
}
@Test
public void parse_WithSchema_MissingFieldRejected()
{
Map<String, Class<?>> schema = new LinkedHashMap<>();
schema.put("lessonId", Long.class);
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"studentId\":\"STU_1\"}", schema);
assertFalse(result.isSuccess());
assertEquals("缺少必填字段:lessonId", result.getMessage());
}
@Test
public void parse_WithSchema_NullFieldRejected()
{
Map<String, Class<?>> schema = new LinkedHashMap<>();
schema.put("lessonId", Long.class);
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":null}", schema);
assertFalse(result.isSuccess());
assertEquals("缺少必填字段:lessonId", result.getMessage());
}
@Test
public void parse_WithSchema_TypeMismatchRejected()
{
Map<String, Class<?>> schema = new LinkedHashMap<>();
schema.put("lessonId", Long.class);
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":\"abc\"}", schema);
assertFalse(result.isSuccess());
assertEquals("字段[lessonId]类型不匹配,期望Long,实际为字符串", result.getMessage());
}
@Test
public void parse_WithSchema_NestedValueRejectedForLong()
{
Map<String, Class<?>> schema = new LinkedHashMap<>();
schema.put("lessonId", Long.class);
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"lessonId\":{\"x\":1}}", schema);
assertFalse(result.isSuccess());
assertEquals("字段[lessonId]类型不匹配,期望Long,实际为对象", result.getMessage());
}
@Test
public void getString_ReturnsDefaultWhenAbsent()
{
JsonParamUtils.JsonParamResult result = JsonParamUtils.parse("{\"a\":\"x\"}");
assertEquals("x", result.getString("a", "def"));
assertEquals("def", result.getString("b", "def"));
}
}
@@ -0,0 +1,204 @@
package com.ruoyi.web.controller.api;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.service.AttendanceAppService;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.ITrainPlanCourseService;
import com.ruoyi.train.service.ITrainStudentCheckinService;
/**
* 电子班牌签到上报/查询接口契约测试(纯内存,不依赖 Spring 容器与数据库)。
* 验证参数解析、enrollmentId 推导、studentId/boardId/signInTime 透传与学员姓名返回。
*/
public class TrainBoardApiControllerTest
{
private TrainBoardApiController controller;
private AttendanceSignInDTO capturedSignIn;
@Before
public void setUp() throws Exception
{
controller = new TrainBoardApiController();
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(1001L);
lesson.setPlanCourseId(10L);
TrainPlanCourse planCourse = new TrainPlanCourse();
planCourse.setPlanCourseId(10L);
planCourse.setPlanId("PLAN_A");
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(201L);
enrollment.setPlanId("PLAN_A");
enrollment.setStudentId("STU_1");
setField(controller, "trainLessonMapper", proxy(TrainLessonMapper.class, (p, m, a) ->
"selectTrainLessonById".equals(m.getName()) && Long.valueOf(1001L).equals(a[0]) ? lesson : null));
setField(controller, "trainPlanCourseService", proxy(ITrainPlanCourseService.class, (p, m, a) ->
"selectTrainPlanCourseById".equals(m.getName()) ? planCourse : null));
setField(controller, "trainEnrollmentService", proxy(ITrainEnrollmentService.class, (p, m, a) ->
"selectTrainEnrollmentByPlanIdAndStudentId".equals(m.getName())
&& "PLAN_A".equals(a[0]) && "STU_1".equals(a[1]) ? enrollment : null));
setField(controller, "attendanceAppService", proxy(AttendanceAppService.class, (p, m, a) -> {
if ("signIn".equals(m.getName()))
{
capturedSignIn = (AttendanceSignInDTO) a[0];
TrainStudentCheckin stored = new TrainStudentCheckin();
stored.setCheckinId(9001L);
stored.setEnrollmentId(capturedSignIn.getEnrollmentId());
stored.setLessonId(capturedSignIn.getLessonId());
stored.setStudentId(capturedSignIn.getStudentId());
stored.setBoardId(capturedSignIn.getBoardId());
stored.setSignInTime(capturedSignIn.getSignInTime());
return stored;
}
return null;
}));
TrainStudentCheckin row = new TrainStudentCheckin();
row.setCheckinId(9001L);
row.setLessonId(1001L);
row.setStudentId("STU_1");
row.setStudentName("张三");
setField(controller, "trainStudentCheckinService", proxy(ITrainStudentCheckinService.class, (p, m, a) -> {
if ("selectCheckinsByLessonId".equals(m.getName()) && Long.valueOf(1001L).equals(a[0]))
{
return Collections.singletonList(row);
}
return Collections.emptyList();
}));
LoginUser loginUser = new LoginUser();
SysUser user = new SysUser();
user.setUserName("board_device");
loginUser.setUser(user);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()));
}
@After
public void tearDown()
{
SecurityContextHolder.clearContext();
}
@Test
public void uploadCheckin_ResolvesEnrollmentAndPassesAllFields()
{
AjaxResult result = controller.uploadCheckin(request("STU_1", "BP001", 1001L, "2026-11-02 08:30:00"));
assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG));
assertNotNull(capturedSignIn);
assertEquals(Long.valueOf(201L), capturedSignIn.getEnrollmentId());
assertEquals(Long.valueOf(1001L), capturedSignIn.getLessonId());
assertEquals("STU_1", capturedSignIn.getStudentId());
assertEquals("BP001", capturedSignIn.getBoardId());
assertEquals(DateUtils.parseDate("2026-11-02 08:30:00"), capturedSignIn.getSignInTime());
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
assertEquals(9001L, data.get("checkinId"));
assertEquals(201L, data.get("enrollmentId"));
assertEquals("STU_1", data.get("studentId"));
assertEquals("2026-11-02 08:30:00", data.get("signInTime"));
}
@Test
public void uploadCheckin_RejectsUnknownStudent()
{
AjaxResult result = controller.uploadCheckin(request("STU_UNKNOWN", "BP001", 1001L, null));
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertEquals(null, capturedSignIn);
}
@Test
public void uploadCheckin_RejectsInvalidSignInTime()
{
AjaxResult result = controller.uploadCheckin(request("STU_1", "BP001", 1001L, "not-a-time"));
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertEquals(null, capturedSignIn);
}
@SuppressWarnings("unchecked")
@Test
public void checkinList_ReturnsRowsWithStudentName()
{
AjaxResult result = controller.checkinList(1001L);
assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG));
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
assertEquals(1, data.get("total"));
List<TrainStudentCheckin> rows = (List<TrainStudentCheckin>) data.get("rows");
assertEquals(1, rows.size());
assertEquals("STU_1", rows.get(0).getStudentId());
assertEquals("张三", rows.get(0).getStudentName());
}
private TrainBoardApiController.BoardCheckinUploadRequest request(String studentId, String boardId,
Long lessonId, String signInTime)
{
TrainBoardApiController.BoardCheckinUploadRequest req = new TrainBoardApiController.BoardCheckinUploadRequest();
req.studentId = studentId;
req.boardId = boardId;
req.lessonId = lessonId;
req.signInTime = signInTime;
return req;
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private <T> T proxy(Class<T> type, InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (p, m, a) -> {
if (m.getDeclaringClass() == Object.class)
{
switch (m.getName())
{
case "toString":
return "proxy(" + type.getSimpleName() + ")";
case "hashCode":
return System.identityHashCode(p);
case "equals":
return p == a[0];
default:
return null;
}
}
return handler.invoke(p, m, a);
});
}
}
@@ -0,0 +1,282 @@
package com.ruoyi.web.controller.api;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.service.ITrainAccommodationService;
import com.ruoyi.train.service.ITrainDormitoryInfoService;
import com.ruoyi.train.service.ITrainEnrollmentService;
/**
* 报到重置接口契约测试:验证步骤顺序、参数透传、失败短路与状态重置。
*/
public class TrainRegisterApiControllerResetTest
{
private TrainRegisterApiController controller;
private String capturedFaceStudentId;
private Map<String, Object> capturedCheckoutBody;
private Long capturedUpdateEnrollmentId;
private Integer capturedUpdateStatus;
private String capturedAvatarSId;
private boolean studentExists = true;
private TrainEnrollment enrollment;
private TrainAccommodation accommodation;
private AjaxResult faceResult;
private String checkoutResponseBody;
@Before
public void setUp() throws Exception
{
enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(201L);
enrollment.setPlanId("PLAN_A");
enrollment.setStudentId("STU_1");
enrollment.setEnrollmentStatus(1);
accommodation = new TrainAccommodation();
accommodation.setAccommodationId(301L);
accommodation.setEnrollmentId(201L);
accommodation.setDormId("DORM-101");
accommodation.setLockApiBizNo("BIZ-301");
faceResult = AjaxResult.success();
checkoutResponseBody = "{\"code\":200,\"msg\":\"success\"}";
controller = new TrainRegisterApiController(null, null);
setField(controller, "trainFaceApiController", new TrainFaceApiController()
{
@Override
public AjaxResult deleteEmployee(String employeeId)
{
capturedFaceStudentId = employeeId;
return faceResult;
}
});
setField(controller, "trainLockApiController", new TrainLockApiController()
{
@Override
public ResponseEntity<String> checkout(Map<String, Object> body)
{
capturedCheckoutBody = body;
return ResponseEntity.ok(checkoutResponseBody);
}
});
setField(controller, "enrollmentService", proxy(ITrainEnrollmentService.class, (p, m, a) -> {
if ("selectTrainEnrollmentByPlanIdAndStudentId".equals(m.getName()))
{
return "PLAN_A".equals(a[0]) && "STU_1".equals(a[1]) ? enrollment : null;
}
if ("updateEnrollmentStatus".equals(m.getName()))
{
capturedUpdateEnrollmentId = (Long) a[0];
capturedUpdateStatus = (Integer) a[1];
return 1;
}
return null;
}));
setField(controller, "accommodationService", proxy(ITrainAccommodationService.class, (p, m, a) ->
"selectActiveAccommodationByStudentId".equals(m.getName()) ? accommodation : null));
setField(controller, "dormitoryInfoService", proxy(ITrainDormitoryInfoService.class, (p, m, a) ->
"selectTrainDormitoryInfoByDormId".equals(m.getName()) ? new TrainDormitoryInfo() : null));
setField(controller, "studentService", proxy(com.ruoyi.train.service.ITrainStudentService.class,
(p, m, a) -> null));
TrainStudent student = new TrainStudent();
student.setsId("STU_1");
setField(controller, "trainStudentMapper", proxy(TrainStudentMapper.class, (p, m, a) -> {
if ("selectTrainStudentBySId".equals(m.getName()))
{
return studentExists ? student : null;
}
if ("clearAvatarToNullBySId".equals(m.getName()))
{
capturedAvatarSId = (String) a[0];
return 1;
}
return null;
}));
LoginUser loginUser = new LoginUser();
SysUser user = new SysUser();
user.setUserName("register_device");
loginUser.setUser(user);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()));
}
@After
public void tearDown()
{
SecurityContextHolder.clearContext();
}
@Test
public void resetReport_Success_UnbindsFaceCheckoutsAndResetsStatus()
{
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG));
assertEquals("STU_1", capturedFaceStudentId);
assertNotNull(capturedCheckoutBody);
assertEquals("BIZ-301", capturedCheckoutBody.get("bizNo"));
assertEquals(Long.valueOf(201L), capturedUpdateEnrollmentId);
assertEquals(Integer.valueOf(0), capturedUpdateStatus);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
assertEquals("success", data.get("faceUnbind"));
assertEquals("success", data.get("lockCheckout"));
assertEquals(0, data.get("currentStatus"));
assertEquals(true, data.get("accommodationKept"));
assertEquals(1, data.get("previousStatus"));
assertEquals(true, data.get("avatarCleared"));
assertEquals("STU_1", data.get("avatarSId"));
assertEquals("STU_1", capturedAvatarSId);
}
@Test
public void resetReport_FaceUnbindFails_StatusNotChanged()
{
faceResult = AjaxResult.error("人脸设备返回 500");
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
String msg = String.valueOf(result.get(AjaxResult.MSG_TAG));
assertTrueContains(msg, "步骤1 人脸解绑失败");
assertNull(capturedUpdateEnrollmentId);
}
@Test
public void resetReport_LockCheckoutFails_StatusNotChanged()
{
checkoutResponseBody = "{\"code\":500,\"msg\":\"门禁服务不可用\"}";
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
String msg = String.valueOf(result.get(AjaxResult.MSG_TAG));
assertTrueContains(msg, "步骤2 门锁授权清除失败");
assertNull(capturedUpdateEnrollmentId);
assertEquals("STU_1", capturedFaceStudentId);
}
@Test
public void resetReport_EnrollmentNotFound_Rejected()
{
AjaxResult result = controller.resetReport("PLAN_A", "STU_UNKNOWN");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "未找到学员");
assertNull(capturedFaceStudentId);
}
@Test
public void resetReport_BlankParameter_Rejected()
{
AjaxResult result = controller.resetReport(" ", "STU_1");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertEquals("planId 和 studentId 不能为空", result.get(AjaxResult.MSG_TAG));
}
@Test
public void resetReport_CompletedEnrollment_Rejected()
{
enrollment.setEnrollmentStatus(4);
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "已结训");
assertNull(capturedFaceStudentId);
}
@Test
public void resetReport_NoAccommodation_SkipsCheckout()
{
accommodation = null;
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(200), result.get(AjaxResult.CODE_TAG));
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
assertEquals("skipped: 无有效住宿记录", data.get("lockCheckout"));
assertEquals(false, data.get("accommodationKept"));
assertEquals(Integer.valueOf(0), capturedUpdateStatus);
}
@Test
public void resetReport_StudentProfileMissing_ReturnsErrorWithProgress()
{
studentExists = false;
AjaxResult result = controller.resetReport("PLAN_A", "STU_1");
assertEquals(Integer.valueOf(500), result.get(AjaxResult.CODE_TAG));
assertTrueContains(String.valueOf(result.get(AjaxResult.MSG_TAG)), "未找到有效学员档案");
assertNull(capturedAvatarSId);
// 状态重置已执行,返回体保留已完成进度供人工核对
assertEquals(Integer.valueOf(0), capturedUpdateStatus);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
assertEquals(201L, data.get("enrollmentId"));
assertEquals(0, data.get("currentStatus"));
}
private void assertTrueContains(String text, String expected)
{
org.junit.Assert.assertTrue("实际内容:" + text, text != null && text.contains(expected));
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private <T> T proxy(Class<T> type, InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (p, m, a) -> {
if (m.getDeclaringClass() == Object.class)
{
switch (m.getName())
{
case "toString":
return "proxy(" + type.getSimpleName() + ")";
case "hashCode":
return System.identityHashCode(p);
case "equals":
return p == a[0];
default:
return null;
}
}
return handler.invoke(p, m, a);
});
}
}
@@ -1,4 +1,4 @@
package com.ruoyi.train.integration.dms.security;
package com.ruoyi.web.controller.train;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
@@ -6,6 +6,7 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.lang.reflect.Method;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -73,6 +74,13 @@ public class EnrollmentApiTest
assertNotNull(removeDelete);
assertEquals("/enrollments/{enrollmentId}", removeDelete.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:remove')", removeMethod.getAnnotation(PreAuthorize.class).value());
// 7. POST /train/enrollments/importTemplate (UC-05 批量导入模板下载,复用导入权限)
Method templateMethod = TrainEnrollmentController.class.getMethod("importTemplate", HttpServletResponse.class);
PostMapping templatePost = templateMethod.getAnnotation(PostMapping.class);
assertNotNull(templatePost);
assertEquals("/enrollments/importTemplate", templatePost.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:import')", templateMethod.getAnnotation(PreAuthorize.class).value());
}
@Test
@@ -106,6 +114,7 @@ public class EnrollmentApiTest
endpointCount++;
}
}
assertEquals("Controller 必须严格只有契约冻结的 6 个端点", 6, endpointCount);
// 6 个契约端点 + 1 个 UC-05 导入模板下载端点(模板下载复用 train:enrollment:import 权限)
assertEquals("Controller 必须严格只有契约冻结的 6 个端点与 1 个导入模板下载端点", 7, endpointCount);
}
}
@@ -3,9 +3,11 @@ package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.lang.reflect.Method;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.dto.StudentCreateDTO;
import com.ruoyi.train.domain.query.StudentQuery;
@@ -69,8 +71,17 @@ public class StudentApiTest
if (isEndpoint)
{
endpointCount++;
assertFalse("不得公开 export 接口",
method.getName().toLowerCase().contains("export"));
// 导出接口必须鉴权并留痕:防止越权拉取全量学员台账
if (method.getName().toLowerCase().contains("export"))
{
PreAuthorize exportPre = method.getAnnotation(PreAuthorize.class);
assertNotNull("导出接口必须配置权限校验", exportPre);
assertTrue("导出接口必须复用学员列表权限 train:student:list",
exportPre.value().contains("train:student:list"));
Log exportLog = method.getAnnotation(Log.class);
assertNotNull("导出接口必须记录操作日志", exportLog);
assertEquals(BusinessType.EXPORT, exportLog.businessType());
}
if (method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class))
{
assertEquals("edit", method.getName());
@@ -85,6 +96,8 @@ public class StudentApiTest
}
}
}
assertEquals("Controller包含经过授权的5个端点(list, getInfo, add, edit, remove)", 5, endpointCount);
// 当前端点:list / getInfo / genPersonNo / add / edit / importTemplate / importData /
// remove / export / extract / batchExtract / extractStudent
assertEquals("学员 Controller 端点数量与已授权清单不一致", 12, endpointCount);
}
}
@@ -87,7 +87,7 @@ public class StudentSelfServiceApiTest
detail.setsName("李四");
detail.setIdCardLast4("5678");
detail.setIdCardMasked("****************5678");
detail.setOrgName("供电段");
detail.setDeptName("供电段");
return detail;
}
return null;
@@ -77,7 +77,7 @@ public class TeacherSelfServiceApiTest
TrainTeacher teacher = new TrainTeacher();
teacher.setTId((String) args[0]);
teacher.settName("王老师");
teacher.setOrgName("机务段");
teacher.setDeptName("机务段");
return teacher;
}
return null;