diff --git a/RuoYi-Vue/ruoyi-admin/pom.xml b/RuoYi-Vue/ruoyi-admin/pom.xml index 7d85caf56..c2538e3ef 100644 --- a/RuoYi-Vue/ruoyi-admin/pom.xml +++ b/RuoYi-Vue/ruoyi-admin/pom.xml @@ -73,6 +73,13 @@ ruoyi-train + + junit + junit + 4.13.2 + test + + @@ -130,4 +137,4 @@ ${project.artifactId} - \ No newline at end of file + diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java index 29d533e94..6534454b3 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java @@ -1,104 +1,60 @@ package com.ruoyi.web.controller.train; import java.util.List; -import javax.servlet.http.HttpServletResponse; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import javax.validation.Valid; 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.TrainStudent; -import com.ruoyi.train.service.ITrainStudentService; -import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +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 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.RestController; -/** - * 学生信息Controller - * - * @author ruoyi - * @date 2026-09-08 - */ +/** ISSUE-01-02 学员主档 P0 API。 */ @RestController @RequestMapping("/train/student") public class TrainStudentController extends BaseController { - @Autowired - private ITrainStudentService trainStudentService; + private final ITrainStudentService trainStudentService; - /** - * 查询学生信息列表 - */ - @PreAuthorize("@ss.hasPermi('system:student:list')") + public TrainStudentController(ITrainStudentService trainStudentService) + { + this.trainStudentService = trainStudentService; + } + + @PreAuthorize("@ss.hasPermi('train:student:list')") @GetMapping("/list") - public TableDataInfo list(TrainStudent trainStudent) + public TableDataInfo list(StudentQuery query) { startPage(); - List list = trainStudentService.selectTrainStudentList(trainStudent); + List list = trainStudentService.selectStudentList(query); return getDataTable(list); } - /** - * 导出学生信息列表 - */ - @PreAuthorize("@ss.hasPermi('system:student:export')") - @Log(title = "学生信息", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(HttpServletResponse response, TrainStudent trainStudent) - { - List list = trainStudentService.selectTrainStudentList(trainStudent); - ExcelUtil util = new ExcelUtil(TrainStudent.class); - util.exportExcel(response, list, "学生信息数据"); - } - - /** - * 获取学生信息详细信息 - */ - @PreAuthorize("@ss.hasPermi('system:student:query')") - @GetMapping(value = "/{sId}") + @PreAuthorize("@ss.hasPermi('train:student:query')") + @GetMapping("/{sId}") public AjaxResult getInfo(@PathVariable("sId") String sId) { - return success(trainStudentService.selectTrainStudentBySId(sId)); + StudentDetailVO detail = trainStudentService.selectStudentDetail(sId); + return success(detail); } - /** - * 新增学生信息 - */ - @PreAuthorize("@ss.hasPermi('system:student:add')") - @Log(title = "学生信息", businessType = BusinessType.INSERT) + @PreAuthorize("@ss.hasPermi('train:student:add')") + @Log(title = "学员主档", businessType = BusinessType.INSERT, + isSaveRequestData = false, isSaveResponseData = true) @PostMapping - public AjaxResult add(@RequestBody TrainStudent trainStudent) + public AjaxResult add(@Valid @RequestBody StudentCreateDTO dto) { - return toAjax(trainStudentService.insertTrainStudent(trainStudent)); - } - - /** - * 修改学生信息 - */ - @PreAuthorize("@ss.hasPermi('system:student:edit')") - @Log(title = "学生信息", businessType = BusinessType.UPDATE) - @PutMapping - public AjaxResult edit(@RequestBody TrainStudent trainStudent) - { - return toAjax(trainStudentService.updateTrainStudent(trainStudent)); - } - - /** - * 删除学生信息 - */ - @PreAuthorize("@ss.hasPermi('system:student:remove')") - @Log(title = "学生信息", businessType = BusinessType.DELETE) - @DeleteMapping("/{sIds}") - public AjaxResult remove(@PathVariable String[] sIds) - { - return toAjax(trainStudentService.deleteTrainStudentBySIds(sIds)); + return success(trainStudentService.createStudent(dto)); } } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index e5c196cad..547f2e093 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -88,6 +88,13 @@ spring: # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms +# 学员身份证安全配置只接受外部注入;仓库内不得保存实际密钥。 +train: + student: + identity: + encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:} + hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:} + # token配置 token: # 令牌自定义标识 diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java new file mode 100644 index 000000000..f8cd2ce02 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentApiTest.java @@ -0,0 +1,77 @@ +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 java.lang.reflect.Method; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.query.StudentQuery; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +/** ISSUE-01-02 Controller 契约与安全边界测试。 */ +public class StudentApiTest +{ + @Test + public void shouldExposeFrozenP0MethodsAndPermissions() throws Exception + { + Method list = TrainStudentController.class.getMethod("list", StudentQuery.class); + Method get = TrainStudentController.class.getMethod("getInfo", String.class); + Method add = TrainStudentController.class.getMethod("add", StudentCreateDTO.class); + + assertEquals("/list", list.getAnnotation(GetMapping.class).value()[0]); + assertEquals("/{sId}", get.getAnnotation(GetMapping.class).value()[0]); + assertNotNull(add.getAnnotation(PostMapping.class)); + assertEquals("@ss.hasPermi('train:student:list')", + list.getAnnotation(PreAuthorize.class).value()); + assertEquals("@ss.hasPermi('train:student:query')", + get.getAnnotation(PreAuthorize.class).value()); + assertEquals("@ss.hasPermi('train:student:add')", + add.getAnnotation(PreAuthorize.class).value()); + assertFalse(add.getAnnotation(Log.class).isSaveRequestData()); + } + + @Test + public void controllerMustNotExposePersistenceDomain() + { + for (Method method : TrainStudentController.class.getDeclaredMethods()) + { + assertFalse(method.getReturnType().equals(TrainStudent.class)); + for (Class parameterType : method.getParameterTypes()) + { + assertFalse(parameterType.equals(TrainStudent.class)); + } + } + } + + @Test + public void controllerMustNotExposeUnfrozenEndpoints() + { + int endpointCount = 0; + for (Method method : TrainStudentController.class.getDeclaredMethods()) + { + boolean isEndpoint = method.isAnnotationPresent(GetMapping.class) + || method.isAnnotationPresent(PostMapping.class) + || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class) + || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class) + || method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class); + + if (isEndpoint) + { + endpointCount++; + assertFalse("不得公开 PUT 接口", + method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)); + assertFalse("不得公开 DELETE 接口", + method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class)); + assertFalse("不得公开 export 接口", + method.getName().toLowerCase().contains("export")); + } + } + assertEquals("Controller必须严格只有3个P0端点", 3, endpointCount); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/StudentErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/StudentErrorCode.java new file mode 100644 index 000000000..ab4a1b520 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/StudentErrorCode.java @@ -0,0 +1,15 @@ +package com.ruoyi.train.constant; + +/** ISSUE-01-02 冻结业务错误码。 */ +public final class StudentErrorCode +{ + public static final int STUDENT_IDCARD_DUPLICATED = 40002; + public static final int STUDENT_NOT_FOUND = 40402; + + public static final String STUDENT_IDCARD_DUPLICATED_MESSAGE = "学员身份证号已建档,不可重复录入"; + public static final String STUDENT_NOT_FOUND_MESSAGE = "学员不存在"; + + private StudentErrorCode() + { + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java index b9d18c050..f955ccbce 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainStudent.java @@ -2,160 +2,221 @@ package com.ruoyi.train.domain; import java.util.Date; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.ruoyi.common.core.domain.BaseEntity; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; -import com.ruoyi.common.annotation.Excel; -import com.ruoyi.common.core.domain.BaseEntity; /** - * 学生信息对象 train_student - * - * @author ruoyi - * @date 2026-09-08 + * 学员长期主档持久化对象 train_student。 + * + *

该类型只用于持久化边界,不得由 Controller 直接接收或返回。

*/ public class TrainStudent extends BaseEntity { private static final long serialVersionUID = 1L; - /** 学生学号 */ private String sId; - - /** 学生姓名 */ - @Excel(name = "学生姓名") private String sName; - - /** 性别:男 / 女 */ - @Excel(name = "性别:男 / 女") private String sGender; - /** 出生日期 */ @JsonFormat(pattern = "yyyy-MM-dd") - @Excel(name = "出生日期", width = 30, dateFormat = "yyyy-MM-dd") private Date sBirth; - /** 所属院系 */ - @Excel(name = "所属院系") - private String sDepartment; - - /** 专业 */ - @Excel(name = "专业") - private String sMajor; - - /** 班级 */ - @Excel(name = "班级") - private String sClass; - - /** 联系电话 */ - @Excel(name = "联系电话") + private Long userId; + private String orgName; private String sPhone; + private String sEmail; - /** 创建人 (管理员 / 教师) */ - @Excel(name = "创建人 (管理员 / 教师)") - private String createUser; + /** 可逆密文。严禁序列化到 API 或 Excel。 */ + private String idCardCiphertext; - public void setsId(String sId) - { - this.sId = sId; - } + /** 确定性查重值。严禁序列化到 API 或 Excel。 */ + private String idCardHash; - public String getsId() + /** 仅用于脱敏展示的末四位。 */ + private String idCardLast4; + + private String faceRef; + private Integer sourceType; + private Integer status; + private String delFlag; + + public String getsId() { return sId; } - public void setsName(String sName) + public void setsId(String sId) { - this.sName = sName; + this.sId = sId; } - public String getsName() + public String getsName() { return sName; } - public void setsGender(String sGender) + public void setsName(String sName) { - this.sGender = sGender; + this.sName = sName; } - public String getsGender() + public String getsGender() { return sGender; } - public void setsBirth(Date sBirth) + public void setsGender(String sGender) { - this.sBirth = sBirth; + this.sGender = sGender; } - public Date getsBirth() + public Date getsBirth() { return sBirth; } - public void setsDepartment(String sDepartment) + public void setsBirth(Date sBirth) { - this.sDepartment = sDepartment; + this.sBirth = sBirth; } - public String getsDepartment() + public Long getUserId() { - return sDepartment; + return userId; } - public void setsMajor(String sMajor) + public void setUserId(Long userId) { - this.sMajor = sMajor; + this.userId = userId; } - public String getsMajor() + public String getOrgName() { - return sMajor; + return orgName; } - public void setsClass(String sClass) + public void setOrgName(String orgName) { - this.sClass = sClass; + this.orgName = orgName; } - public String getsClass() - { - return sClass; - } - - public void setsPhone(String sPhone) - { - this.sPhone = sPhone; - } - - public String getsPhone() + public String getsPhone() { return sPhone; } - public void setCreateUser(String createUser) + public void setsPhone(String sPhone) { - this.createUser = createUser; + this.sPhone = sPhone; } - public String getCreateUser() + public String getsEmail() { - return createUser; + return sEmail; } + public void setsEmail(String sEmail) + { + this.sEmail = sEmail; + } + + @JsonIgnore + public String getIdCardCiphertext() + { + return idCardCiphertext; + } + + public void setIdCardCiphertext(String idCardCiphertext) + { + this.idCardCiphertext = idCardCiphertext; + } + + @JsonIgnore + public String getIdCardHash() + { + return idCardHash; + } + + public void setIdCardHash(String idCardHash) + { + this.idCardHash = idCardHash; + } + + public String getIdCardLast4() + { + return idCardLast4; + } + + public void setIdCardLast4(String idCardLast4) + { + this.idCardLast4 = idCardLast4; + } + + public String getFaceRef() + { + return faceRef; + } + + public void setFaceRef(String faceRef) + { + this.faceRef = faceRef; + } + + public Integer getSourceType() + { + return sourceType; + } + + public void setSourceType(Integer sourceType) + { + this.sourceType = sourceType; + } + + public Integer getStatus() + { + return status; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public String getDelFlag() + { + return delFlag; + } + + public void setDelFlag(String delFlag) + { + this.delFlag = delFlag; + } + + /** + * 安全日志表示:刻意不包含身份证密文、查重值或任何完整身份证信息。 + */ @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) .append("sId", getsId()) .append("sName", getsName()) .append("sGender", getsGender()) .append("sBirth", getsBirth()) - .append("sDepartment", getsDepartment()) - .append("sMajor", getsMajor()) - .append("sClass", getsClass()) + .append("userId", getUserId()) + .append("orgName", getOrgName()) .append("sPhone", getsPhone()) - .append("createUser", getCreateUser()) + .append("sEmail", getsEmail()) + .append("idCardLast4", getIdCardLast4()) + .append("faceRef", getFaceRef()) + .append("sourceType", getSourceType()) + .append("status", getStatus()) + .append("delFlag", getDelFlag()) + .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) .append("updateTime", getUpdateTime()) .append("remark", getRemark()) .toString(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java new file mode 100644 index 000000000..a8d100e51 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/StudentCreateDTO.java @@ -0,0 +1,73 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import com.alibaba.fastjson2.annotation.JSONField; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** 学员手工建档请求。 */ +public class StudentCreateDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + @NotBlank(message = "学员姓名不能为空") + @Size(max = 50, message = "学员姓名长度不能超过50个字符") + private String sName; + + @Size(max = 1, message = "性别长度不能超过1个字符") + private String sGender; + + @JsonFormat(pattern = "yyyy-MM-dd") + private Date sBirth; + + @Size(max = 100, message = "所属单位长度不能超过100个字符") + private String orgName; + + @Size(max = 20, message = "联系电话长度不能超过20个字符") + private String sPhone; + + @Email(message = "邮箱格式不正确") + @Size(max = 100, message = "邮箱长度不能超过100个字符") + private String sEmail; + + @Pattern(regexp = "^$|^\\s*$|^[0-9]{17}[0-9Xx]$", message = "身份证号格式不正确") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @JSONField(serialize = false) + private String idCard; + + @Size(max = 255, message = "人脸凭据引用长度不能超过255个字符") + private String faceRef; + + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + public String getsName() { return sName; } + public void setsName(String sName) { this.sName = sName; } + public String getsGender() { return sGender; } + public void setsGender(String sGender) { this.sGender = sGender; } + public Date getsBirth() { return sBirth; } + public void setsBirth(Date sBirth) { this.sBirth = sBirth; } + public String getOrgName() { return orgName; } + public void setOrgName(String orgName) { this.orgName = orgName; } + public String getsPhone() { return sPhone; } + public void setsPhone(String sPhone) { this.sPhone = sPhone; } + public String getsEmail() { return sEmail; } + public void setsEmail(String sEmail) { this.sEmail = sEmail; } + public String getIdCard() { return idCard; } + public void setIdCard(String idCard) { this.idCard = idCard; } + public String getFaceRef() { return faceRef; } + public void setFaceRef(String faceRef) { this.faceRef = faceRef; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + @Override + public String toString() + { + return "StudentCreateDTO{sName='" + sName + "', idCardProvided=" + (idCard != null) + "}"; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java new file mode 100644 index 000000000..dc18d2dae --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/StudentQuery.java @@ -0,0 +1,23 @@ +package com.ruoyi.train.domain.query; + +import java.io.Serializable; + +/** 学员主档分页查询条件。 */ +public class StudentQuery implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String sName; + private String orgName; + private String sPhone; + private Integer status; + + public String getsName() { return sName; } + public void setsName(String sName) { this.sName = sName; } + public String getOrgName() { return orgName; } + public void setOrgName(String orgName) { this.orgName = orgName; } + public String getsPhone() { return sPhone; } + public void setsPhone(String sPhone) { this.sPhone = sPhone; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentDetailVO.java new file mode 100644 index 000000000..f2b390858 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentDetailVO.java @@ -0,0 +1,25 @@ +package com.ruoyi.train.domain.vo; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** 学员详情安全视图,身份证仍默认脱敏。 */ +public class StudentDetailVO extends StudentItemVO +{ + private static final long serialVersionUID = 1L; + + private String faceRef; + private String remark; + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + public String getFaceRef() { return faceRef; } + public void setFaceRef(String faceRef) { this.faceRef = faceRef; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + public String getCreateBy() { return createBy; } + public void setCreateBy(String createBy) { this.createBy = createBy; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java new file mode 100644 index 000000000..afe69246e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/StudentItemVO.java @@ -0,0 +1,47 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** 学员列表安全视图,不包含身份证明文、密文或查重值。 */ +public class StudentItemVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String sId; + private String sName; + private String sGender; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date sBirth; + private String orgName; + private String sPhone; + private String sEmail; + private String idCardMasked; + private String idCardLast4; + private Integer sourceType; + private Integer status; + + public String getsId() { return sId; } + public void setsId(String sId) { this.sId = sId; } + public String getsName() { return sName; } + public void setsName(String sName) { this.sName = sName; } + public String getsGender() { return sGender; } + public void setsGender(String sGender) { this.sGender = sGender; } + public Date getsBirth() { return sBirth; } + public void setsBirth(Date sBirth) { this.sBirth = sBirth; } + public String getOrgName() { return orgName; } + public void setOrgName(String orgName) { this.orgName = orgName; } + public String getsPhone() { return sPhone; } + public void setsPhone(String sPhone) { this.sPhone = sPhone; } + public String getsEmail() { return sEmail; } + public void setsEmail(String sEmail) { this.sEmail = sEmail; } + public String getIdCardMasked() { return idCardMasked; } + public void setIdCardMasked(String idCardMasked) { this.idCardMasked = idCardMasked; } + public String getIdCardLast4() { return idCardLast4; } + public void setIdCardLast4(String idCardLast4) { this.idCardLast4 = idCardLast4; } + public Integer getSourceType() { return sourceType; } + public void setSourceType(Integer sourceType) { this.sourceType = sourceType; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java index bfa779e2d..a5c3df3bd 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java @@ -2,60 +2,17 @@ package com.ruoyi.train.mapper; import java.util.List; import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.query.StudentQuery; -/** - * 学生信息Mapper接口 - * - * @author ruoyi - * @date 2026-09-08 - */ -public interface TrainStudentMapper +/** 学员主档持久化 Mapper。 */ +public interface TrainStudentMapper { - /** - * 查询学生信息 - * - * @param sId 学生信息主键 - * @return 学生信息 - */ - public TrainStudent selectTrainStudentBySId(String sId); + TrainStudent selectTrainStudentBySId(String sId); - /** - * 查询学生信息列表 - * - * @param trainStudent 学生信息 - * @return 学生信息集合 - */ - public List selectTrainStudentList(TrainStudent trainStudent); + List selectTrainStudentList(StudentQuery query); - /** - * 新增学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ - public int insertTrainStudent(TrainStudent trainStudent); + /** 使用冻结的 id_card_hash 做精确防重;存在时返回 1,不存在返回 null。 */ + Integer selectOneByIdCardHash(String idCardHash); - /** - * 修改学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ - public int updateTrainStudent(TrainStudent trainStudent); - - /** - * 删除学生信息 - * - * @param sId 学生信息主键 - * @return 结果 - */ - public int deleteTrainStudentBySId(String sId); - - /** - * 批量删除学生信息 - * - * @param sIds 需要删除的数据主键集合 - * @return 结果 - */ - public int deleteTrainStudentBySIds(String[] sIds); + int insertTrainStudent(TrainStudent trainStudent); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/JceStudentIdentityProtection.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/JceStudentIdentityProtection.java new file mode 100644 index 000000000..7ecade7ff --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/JceStudentIdentityProtection.java @@ -0,0 +1,168 @@ +package com.ruoyi.train.security; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 基于标准 JCE 的身份证安全组件。 + * + *

当前实现明确使用 AES-GCM 与 HMAC-SHA-256,不宣称国密能力。算法被隔离在 + * {@link StudentIdentityProtection} 端口之后,后续可替换实现。两把独立密钥仅从 + * 外部配置读取,仓库内不提供默认密钥。

+ */ +@Component +public class JceStudentIdentityProtection implements StudentIdentityProtection +{ + private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String ENCRYPTION_KEY_ALGORITHM = "AES"; + private static final String HASH_ALGORITHM = "HmacSHA256"; + private static final String CIPHERTEXT_VERSION = "v1"; + private static final int IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + + private final byte[] encryptionKey; + private final byte[] hashKey; + private final SecureRandom secureRandom; + + public JceStudentIdentityProtection( + @Value("${train.student.identity.encryption-key:}") String encryptionKeyBase64, + @Value("${train.student.identity.hash-key:}") String hashKeyBase64) + { + this(decodeKey(encryptionKeyBase64, "encryption-key"), + decodeKey(hashKeyBase64, "hash-key"), new SecureRandom()); + } + + JceStudentIdentityProtection(byte[] encryptionKey, byte[] hashKey, SecureRandom secureRandom) + { + if (encryptionKey == null || !isValidAesKeyLength(encryptionKey.length)) + { + throw new IllegalStateException("学员身份证加密密钥配置无效"); + } + if (hashKey == null || hashKey.length < 32) + { + throw new IllegalStateException("学员身份证查重密钥配置无效"); + } + this.encryptionKey = Arrays.copyOf(encryptionKey, encryptionKey.length); + this.hashKey = Arrays.copyOf(hashKey, hashKey.length); + this.secureRandom = secureRandom; + } + + @Override + public String encrypt(String plaintext) + { + try + { + byte[] iv = new byte[IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptionKey, ENCRYPTION_KEY_ALGORITHM), + new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer payload = ByteBuffer.allocate(iv.length + encrypted.length); + payload.put(iv).put(encrypted); + return CIPHERTEXT_VERSION + "." + Base64.getEncoder().encodeToString(payload.array()); + } + catch (GeneralSecurityException e) + { + throw new IllegalStateException("学员身份证加密失败", e); + } + } + + @Override + public String decrypt(String ciphertext) + { + try + { + String[] parts = ciphertext.split("\\.", 2); + if (parts.length != 2 || !CIPHERTEXT_VERSION.equals(parts[0])) + { + throw new IllegalArgumentException("不支持的学员身份证密文版本"); + } + byte[] payload = Base64.getDecoder().decode(parts[1]); + if (payload.length <= IV_LENGTH_BYTES) + { + throw new IllegalArgumentException("学员身份证密文格式无效"); + } + byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH_BYTES); + byte[] encrypted = Arrays.copyOfRange(payload, IV_LENGTH_BYTES, payload.length); + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(encryptionKey, ENCRYPTION_KEY_ALGORITHM), + new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } + catch (GeneralSecurityException | IllegalArgumentException e) + { + throw new IllegalStateException("学员身份证密文解密失败", e); + } + } + + @Override + public String deterministicHash(String plaintext) + { + try + { + Mac mac = Mac.getInstance(HASH_ALGORITHM); + mac.init(new SecretKeySpec(hashKey, HASH_ALGORITHM)); + byte[] digest = mac.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte value : digest) + { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } + catch (GeneralSecurityException e) + { + throw new IllegalStateException("学员身份证查重值计算失败", e); + } + } + + @Override + public String mask(String plaintext) + { + if (plaintext == null || plaintext.length() < 10) + { + return null; + } + int hiddenLength = plaintext.length() - 10; + StringBuilder masked = new StringBuilder(plaintext.length()); + masked.append(plaintext, 0, 6); + for (int i = 0; i < hiddenLength; i++) + { + masked.append('*'); + } + masked.append(plaintext.substring(plaintext.length() - 4)); + return masked.toString(); + } + + private static byte[] decodeKey(String value, String propertyName) + { + if (value == null || value.trim().isEmpty()) + { + throw new IllegalStateException("缺少学员身份证安全配置: " + propertyName); + } + try + { + return Base64.getDecoder().decode(value.trim()); + } + catch (IllegalArgumentException e) + { + throw new IllegalStateException("学员身份证安全配置格式无效: " + propertyName, e); + } + } + + private static boolean isValidAesKeyLength(int length) + { + return length == 16 || length == 24 || length == 32; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/StudentIdentityProtection.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/StudentIdentityProtection.java new file mode 100644 index 000000000..26c49bc0b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/security/StudentIdentityProtection.java @@ -0,0 +1,17 @@ +package com.ruoyi.train.security; + +/** + * 学员身份证安全能力端口。 + * + *

业务层只依赖此接口,算法与密钥载入细节不得进入 Controller。

+ */ +public interface StudentIdentityProtection +{ + String encrypt(String plaintext); + + String decrypt(String ciphertext); + + String deterministicHash(String plaintext); + + String mask(String plaintext); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java index 62f94a5de..75a16525a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java @@ -1,61 +1,17 @@ package com.ruoyi.train.service; import java.util.List; -import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; -/** - * 学生信息Service接口 - * - * @author ruoyi - * @date 2026-09-08 - */ -public interface ITrainStudentService +/** ISSUE-01-02 学员主档应用服务边界。 */ +public interface ITrainStudentService { - /** - * 查询学生信息 - * - * @param sId 学生信息主键 - * @return 学生信息 - */ - public TrainStudent selectTrainStudentBySId(String sId); + List selectStudentList(StudentQuery query); - /** - * 查询学生信息列表 - * - * @param trainStudent 学生信息 - * @return 学生信息集合 - */ - public List selectTrainStudentList(TrainStudent trainStudent); + StudentDetailVO selectStudentDetail(String sId); - /** - * 新增学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ - public int insertTrainStudent(TrainStudent trainStudent); - - /** - * 修改学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ - public int updateTrainStudent(TrainStudent trainStudent); - - /** - * 批量删除学生信息 - * - * @param sIds 需要删除的学生信息主键集合 - * @return 结果 - */ - public int deleteTrainStudentBySIds(String[] sIds); - - /** - * 删除学生信息信息 - * - * @param sId 学生信息主键 - * @return 结果 - */ - public int deleteTrainStudentBySId(String sId); + String createStudent(StudentCreateDTO dto); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java index e7b13db51..76fad45a5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java @@ -1,96 +1,220 @@ package com.ruoyi.train.service.impl; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import com.github.pagehelper.Page; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.train.constant.StudentErrorCode; import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +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.mapper.TrainStudentMapper; +import com.ruoyi.train.security.StudentIdentityProtection; import com.ruoyi.train.service.ITrainStudentService; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -/** - * 学生信息Service业务层处理 - * - * @author ruoyi - * @date 2026-09-08 - */ +/** 学员主档应用服务实现。 */ @Service -public class TrainStudentServiceImpl implements ITrainStudentService +public class TrainStudentServiceImpl implements ITrainStudentService { - @Autowired - private TrainStudentMapper trainStudentMapper; + private static final Pattern ID_CARD_PATTERN = Pattern.compile("^[0-9]{17}[0-9X]$"); + private static final int MANUAL_SOURCE_TYPE = 1; + private static final int ENABLED_STATUS = 1; - /** - * 查询学生信息 - * - * @param sId 学生信息主键 - * @return 学生信息 - */ - @Override - public TrainStudent selectTrainStudentBySId(String sId) + private final TrainStudentMapper trainStudentMapper; + private final StudentIdentityProtection identityProtection; + + public TrainStudentServiceImpl(TrainStudentMapper trainStudentMapper, + StudentIdentityProtection identityProtection) { - return trainStudentMapper.selectTrainStudentBySId(sId); + this.trainStudentMapper = trainStudentMapper; + this.identityProtection = identityProtection; } - /** - * 查询学生信息列表 - * - * @param trainStudent 学生信息 - * @return 学生信息 - */ @Override - public List selectTrainStudentList(TrainStudent trainStudent) + public List selectStudentList(StudentQuery query) { - return trainStudentMapper.selectTrainStudentList(trainStudent); + List students = trainStudentMapper.selectTrainStudentList(query); + List result = createPagedResult(students); + for (TrainStudent student : students) + { + result.add(toItemVO(student)); + } + return result; } - /** - * 新增学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ @Override - public int insertTrainStudent(TrainStudent trainStudent) + public StudentDetailVO selectStudentDetail(String sId) { - trainStudent.setCreateTime(DateUtils.getNowDate()); - return trainStudentMapper.insertTrainStudent(trainStudent); + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException(StudentErrorCode.STUDENT_NOT_FOUND_MESSAGE, + StudentErrorCode.STUDENT_NOT_FOUND); + } + StudentDetailVO detail = new StudentDetailVO(); + copyCommon(student, detail); + detail.setFaceRef(student.getFaceRef()); + detail.setRemark(student.getRemark()); + detail.setCreateBy(student.getCreateBy()); + detail.setCreateTime(student.getCreateTime()); + return detail; } - /** - * 修改学生信息 - * - * @param trainStudent 学生信息 - * @return 结果 - */ @Override - public int updateTrainStudent(TrainStudent trainStudent) + @Transactional(rollbackFor = Exception.class) + public String createStudent(StudentCreateDTO dto) { - trainStudent.setUpdateTime(DateUtils.getNowDate()); - return trainStudentMapper.updateTrainStudent(trainStudent); + validate(dto); + String normalizedIdCard = normalizeIdCard(dto.getIdCard()); + + TrainStudent student = new TrainStudent(); + student.setsId(IdUtils.fastSimpleUUID()); + student.setsName(dto.getsName().trim()); + student.setsGender(trimToNull(dto.getsGender())); + student.setsBirth(dto.getsBirth()); + student.setOrgName(trimToNull(dto.getOrgName())); + student.setsPhone(trimToNull(dto.getsPhone())); + student.setsEmail(trimToNull(dto.getsEmail())); + + if (normalizedIdCard != null) + { + String hash = identityProtection.deterministicHash(normalizedIdCard); + if (trainStudentMapper.selectOneByIdCardHash(hash) != null) + { + throw duplicateIdentity(); + } + student.setIdCardCiphertext(identityProtection.encrypt(normalizedIdCard)); + student.setIdCardHash(hash); + student.setIdCardLast4(normalizedIdCard.substring(normalizedIdCard.length() - 4)); + } + else + { + student.setIdCardCiphertext(null); + student.setIdCardHash(null); + student.setIdCardLast4(null); + } + student.setFaceRef(trimToNull(dto.getFaceRef())); + student.setSourceType(MANUAL_SOURCE_TYPE); + student.setStatus(ENABLED_STATUS); + student.setDelFlag("0"); + student.setRemark(trimToNull(dto.getRemark())); + student.setCreateTime(DateUtils.getNowDate()); + try + { + student.setCreateBy(SecurityUtils.getUsername()); + } + catch (Exception ignored) + { + // 非 Web 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。 + } + + try + { + trainStudentMapper.insertTrainStudent(student); + } + catch (DuplicateKeyException e) + { + // 应用层预检之外,数据库唯一约束继续封堵并发重复建档。 + throw duplicateIdentity(); + } + return student.getsId(); } - /** - * 批量删除学生信息 - * - * @param sIds 需要删除的学生信息主键 - * @return 结果 - */ - @Override - public int deleteTrainStudentBySIds(String[] sIds) + private StudentItemVO toItemVO(TrainStudent student) { - return trainStudentMapper.deleteTrainStudentBySIds(sIds); + StudentItemVO item = new StudentItemVO(); + copyCommon(student, item); + return item; } - /** - * 删除学生信息信息 - * - * @param sId 学生信息主键 - * @return 结果 - */ - @Override - public int deleteTrainStudentBySId(String sId) + private void copyCommon(TrainStudent student, StudentItemVO target) { - return trainStudentMapper.deleteTrainStudentBySId(sId); + target.setsId(student.getsId()); + target.setsName(student.getsName()); + target.setsGender(student.getsGender()); + target.setsBirth(student.getsBirth()); + target.setOrgName(student.getOrgName()); + target.setsPhone(student.getsPhone()); + target.setsEmail(student.getsEmail()); + target.setIdCardLast4(student.getIdCardLast4()); + target.setIdCardMasked(maskStoredIdentity(student)); + target.setSourceType(student.getSourceType()); + target.setStatus(student.getStatus()); + } + + private String maskStoredIdentity(TrainStudent student) + { + if (StringUtils.isNotEmpty(student.getIdCardCiphertext())) + { + return identityProtection.mask(identityProtection.decrypt(student.getIdCardCiphertext())); + } + if (StringUtils.isNotEmpty(student.getIdCardLast4())) + { + return "**************" + student.getIdCardLast4(); + } + return null; + } + + @SuppressWarnings("unchecked") + private List createPagedResult(List source) + { + if (source instanceof Page) + { + Page sourcePage = (Page) source; + Page targetPage = new Page<>(sourcePage.getPageNum(), sourcePage.getPageSize()); + targetPage.setTotal(sourcePage.getTotal()); + return targetPage; + } + return new ArrayList<>(source.size()); + } + + private void validate(StudentCreateDTO dto) + { + if (dto == null || StringUtils.isEmpty(dto.getsName()) || dto.getsName().trim().isEmpty()) + { + throw new ServiceException("学员姓名不能为空", 400); + } + String normalizedIdCard = normalizeIdCard(dto.getIdCard()); + if (normalizedIdCard != null && !ID_CARD_PATTERN.matcher(normalizedIdCard).matches()) + { + throw new ServiceException("身份证号格式不正确", 400); + } + } + + private String normalizeIdCard(String idCard) + { + if (idCard == null) + { + return null; + } + String trimmed = idCard.trim(); + return trimmed.isEmpty() ? null : trimmed.toUpperCase(Locale.ROOT); + } + + private String trimToNull(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private ServiceException duplicateIdentity() + { + return new ServiceException(StudentErrorCode.STUDENT_IDCARD_DUPLICATED_MESSAGE, + StudentErrorCode.STUDENT_IDCARD_DUPLICATED); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml index 458b3cb71..843ce4673 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml @@ -3,103 +3,77 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - select s_id, s_name, s_gender, s_birth, s_department, s_major, s_class, s_phone, create_user, create_time, update_time, remark from train_student + + select s_id, s_name, s_gender, s_birth, user_id, org_name, s_phone, s_email, + id_card_ciphertext, id_card_hash, id_card_last4, face_ref, source_type, + status, create_by, create_time, update_by, update_time, remark, del_flag + from train_student - + + + del_flag = '0' + + and s_name like concat('%', #{sName}, '%') + + + and org_name like concat('%', #{orgName}, '%') + + + and s_phone = #{sPhone} + + + and status = #{status} + + order by create_time desc, s_id - + + + - insert into train_student - - s_id, - s_name, - s_gender, - s_birth, - s_department, - s_major, - s_class, - s_phone, - create_user, - create_time, - update_time, - remark, - - - #{sId}, - #{sName}, - #{sGender}, - #{sBirth}, - #{sDepartment}, - #{sMajor}, - #{sClass}, - #{sPhone}, - #{createUser}, - #{createTime}, - #{updateTime}, - #{remark}, - + insert into train_student ( + s_id, s_name, s_gender, s_birth, user_id, org_name, s_phone, s_email, + id_card_ciphertext, id_card_hash, id_card_last4, face_ref, source_type, + status, create_by, create_time, update_by, update_time, remark, del_flag + ) values ( + #{sId}, #{sName}, #{sGender}, #{sBirth}, #{userId}, #{orgName}, #{sPhone}, #{sEmail}, + #{idCardCiphertext}, #{idCardHash}, #{idCardLast4}, #{faceRef}, #{sourceType}, + #{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag} + ) - - - update train_student - - s_name = #{sName}, - s_gender = #{sGender}, - s_birth = #{sBirth}, - s_department = #{sDepartment}, - s_major = #{sMajor}, - s_class = #{sClass}, - s_phone = #{sPhone}, - create_user = #{createUser}, - create_time = #{createTime}, - update_time = #{updateTime}, - remark = #{remark}, - - where s_id = #{sId} - - - - delete from train_student where s_id = #{sId} - - - - delete from train_student where s_id in - - #{sId} - - - \ No newline at end of file + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/security/JceStudentIdentityProtectionTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/security/JceStudentIdentityProtectionTest.java new file mode 100644 index 000000000..b2121aada --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/security/JceStudentIdentityProtectionTest.java @@ -0,0 +1,80 @@ +package com.ruoyi.train.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; + +import java.util.Arrays; +import java.util.Base64; +import org.junit.Before; +import org.junit.Test; + +/** 身份证加密、确定性查重与脱敏规则单元测试。 */ +public class JceStudentIdentityProtectionTest +{ + private static final String ID_CARD = "110101199001011234"; + + private StudentIdentityProtection protection; + + @Before + public void setUp() + { + byte[] encryptionKey = new byte[32]; + byte[] hashKey = new byte[32]; + Arrays.fill(encryptionKey, (byte) 0x21); + Arrays.fill(hashKey, (byte) 0x42); + protection = new JceStudentIdentityProtection( + Base64.getEncoder().encodeToString(encryptionKey), + Base64.getEncoder().encodeToString(hashKey)); + } + + @Test + public void shouldEncryptReversiblyWithoutPersistingPlaintext() + { + String first = protection.encrypt(ID_CARD); + String second = protection.encrypt(ID_CARD); + + assertFalse(first.contains(ID_CARD)); + assertNotEquals(ID_CARD, first); + assertNotEquals("随机 IV 应使相同明文产生不同密文", first, second); + assertEquals(ID_CARD, protection.decrypt(first)); + assertEquals(ID_CARD, protection.decrypt(second)); + } + + @Test + public void shouldProduceDeterministicHashAndFrozenMask() + { + String first = protection.deterministicHash(ID_CARD); + String second = protection.deterministicHash(ID_CARD); + + assertEquals(first, second); + assertEquals(64, first.length()); + assertEquals("110101********1234", protection.mask(ID_CARD)); + } + + @Test(expected = IllegalStateException.class) + public void missingEncryptionKeyShouldFailClosed() + { + new JceStudentIdentityProtection("", Base64.getEncoder().encodeToString(new byte[32])); + } + + @Test(expected = IllegalStateException.class) + public void invalidEncryptionKeyLengthShouldFailClosed() + { + new JceStudentIdentityProtection(Base64.getEncoder().encodeToString(new byte[10]), + Base64.getEncoder().encodeToString(new byte[32])); + } + + @Test(expected = IllegalStateException.class) + public void missingHashKeyShouldFailClosed() + { + new JceStudentIdentityProtection(Base64.getEncoder().encodeToString(new byte[32]), ""); + } + + @Test(expected = IllegalStateException.class) + public void invalidHashKeyLengthShouldFailClosed() + { + new JceStudentIdentityProtection(Base64.getEncoder().encodeToString(new byte[32]), + Base64.getEncoder().encodeToString(new byte[16])); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java new file mode 100644 index 000000000..b5bec76c0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java @@ -0,0 +1,292 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.alibaba.fastjson2.JSON; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.constant.StudentErrorCode; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +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.mapper.TrainStudentMapper; +import com.ruoyi.train.security.JceStudentIdentityProtection; +import com.ruoyi.train.security.StudentIdentityProtection; +import org.junit.Before; +import org.junit.Test; +import org.springframework.dao.DuplicateKeyException; + +/** ISSUE-01-02 学员主档安全业务测试。 */ +public class TrainStudentServiceImplTest +{ + private static final String ID_CARD = "110101199001011234"; + + private InMemoryStudentMapper mapper; + private StudentIdentityProtection protection; + private TrainStudentServiceImpl service; + + @Before + public void setUp() + { + byte[] encryptionKey = new byte[32]; + byte[] hashKey = new byte[32]; + Arrays.fill(encryptionKey, (byte) 0x31); + Arrays.fill(hashKey, (byte) 0x52); + protection = new JceStudentIdentityProtection( + Base64.getEncoder().encodeToString(encryptionKey), + Base64.getEncoder().encodeToString(hashKey)); + mapper = new InMemoryStudentMapper(); + service = new TrainStudentServiceImpl(mapper, protection); + } + + @Test + public void createShouldPersistOnlyProtectedIdentityMaterial() + { + String id = service.createStudent(validCreate()); + TrainStudent saved = mapper.store.get(id); + + assertNotNull(saved); + assertNotEquals(ID_CARD, saved.getIdCardCiphertext()); + assertFalse(saved.getIdCardCiphertext().contains(ID_CARD)); + assertEquals(ID_CARD, protection.decrypt(saved.getIdCardCiphertext())); + assertEquals(protection.deterministicHash(ID_CARD), saved.getIdCardHash()); + assertEquals("1234", saved.getIdCardLast4()); + assertEquals(Integer.valueOf(1), saved.getSourceType()); + assertEquals(Integer.valueOf(1), saved.getStatus()); + assertEquals("0", saved.getDelFlag()); + } + + @Test + public void duplicateIdentityHashShouldBeBlockedWithFrozenCode() + { + service.createStudent(validCreate()); + assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, + () -> service.createStudent(validCreate())); + assertEquals(1, mapper.store.size()); + } + + @Test + public void databaseUniqueRaceShouldAlsoMapToDuplicateCode() + { + mapper.failNextInsertAsDuplicate = true; + assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, + () -> service.createStudent(validCreate())); + assertEquals(0, mapper.store.size()); + } + + @Test + public void listAndDetailShouldExposeMaskedIdentityOnly() + { + String id = service.createStudent(validCreate()); + + List list = service.selectStudentList(new StudentQuery()); + StudentDetailVO detail = service.selectStudentDetail(id); + String listJson = JSON.toJSONString(list); + String detailJson = JSON.toJSONString(detail); + + assertEquals(1, list.size()); + assertEquals("110101********1234", list.get(0).getIdCardMasked()); + assertEquals("1234", list.get(0).getIdCardLast4()); + assertEquals("110101********1234", detail.getIdCardMasked()); + assertEquals("1234", detail.getIdCardLast4()); + assertFalse(listJson.contains(ID_CARD)); + assertFalse(detailJson.contains(ID_CARD)); + assertFalse(listJson.contains("idCardCiphertext")); + assertFalse(detailJson.contains("idCardHash")); + } + + @Test + public void canonicalizedXxShouldBeDuplicateBlocked() + { + StudentCreateDTO dto1 = validCreate(); + dto1.setIdCard("11010119900101123x"); + service.createStudent(dto1); + + StudentCreateDTO dto2 = validCreate(); + dto2.setIdCard(" 11010119900101123X "); + assertError(StudentErrorCode.STUDENT_IDCARD_DUPLICATED, + () -> service.createStudent(dto2)); + } + + @Test + public void missingStudentShouldReturnFrozenNotFoundCode() + { + assertError(StudentErrorCode.STUDENT_NOT_FOUND, + () -> service.selectStudentDetail("missing")); + } + + @Test + public void logsAndDtoStringShouldNotContainPlainIdentity() + { + StudentCreateDTO dto = validCreate(); + String id = service.createStudent(dto); + TrainStudent saved = mapper.store.get(id); + + assertFalse(dto.toString().contains(ID_CARD)); + assertFalse(saved.toString().contains(ID_CARD)); + assertFalse(saved.toString().contains(saved.getIdCardCiphertext())); + assertFalse(saved.toString().contains(saved.getIdCardHash())); + assertNull(JSON.parseObject(JSON.toJSONString(dto)).get("idCard")); + } + + @Test + public void domainJacksonSerializationShouldNotLeakSensitiveFields() throws Exception + { + StudentCreateDTO dto = validCreate(); + String id = service.createStudent(dto); + TrainStudent saved = mapper.store.get(id); + + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(saved); + + assertFalse("Jackson序列化不得泄漏身份证密文", json.contains("idCardCiphertext")); + assertFalse("Jackson序列化不得泄漏身份证哈希", json.contains("idCardHash")); + assertFalse("Jackson序列化不得泄漏身份证明文", json.contains(ID_CARD)); + assertEquals("1234", objectMapper.readTree(json).get("idCardLast4").asText()); + } + + @Test + public void testCreateStudentWithoutIdCard() + { + StudentCreateDTO dto = validCreate(); + dto.setIdCard(null); + String id = service.createStudent(dto); + TrainStudent saved = mapper.store.get(id); + + assertNotNull(saved); + assertNull(saved.getIdCardCiphertext()); + assertNull(saved.getIdCardHash()); + assertNull(saved.getIdCardLast4()); + } + + @Test + public void testCreateMultipleStudentsWithoutIdCard() + { + StudentCreateDTO dto1 = validCreate(); + dto1.setsName("学员A"); + dto1.setIdCard(null); + String id1 = service.createStudent(dto1); + + StudentCreateDTO dto2 = validCreate(); + dto2.setsName("学员B"); + dto2.setIdCard(null); + String id2 = service.createStudent(dto2); + + assertNotNull(id1); + assertNotNull(id2); + assertNotEquals(id1, id2); + assertNull(mapper.store.get(id1).getIdCardHash()); + assertNull(mapper.store.get(id2).getIdCardHash()); + assertEquals(2, mapper.store.size()); + } + + @Test + public void testBlankIdCardNormalizedToNull() + { + StudentCreateDTO dtoEmpty = validCreate(); + dtoEmpty.setsName("学员空串"); + dtoEmpty.setIdCard(""); + String idEmpty = service.createStudent(dtoEmpty); + TrainStudent savedEmpty = mapper.store.get(idEmpty); + + assertNotNull(savedEmpty); + assertNull(savedEmpty.getIdCardCiphertext()); + assertNull(savedEmpty.getIdCardHash()); + assertNull(savedEmpty.getIdCardLast4()); + + StudentCreateDTO dtoSpaces = validCreate(); + dtoSpaces.setsName("学员空格"); + dtoSpaces.setIdCard(" "); + String idSpaces = service.createStudent(dtoSpaces); + TrainStudent savedSpaces = mapper.store.get(idSpaces); + + assertNotNull(savedSpaces); + assertNull(savedSpaces.getIdCardCiphertext()); + assertNull(savedSpaces.getIdCardHash()); + assertNull(savedSpaces.getIdCardLast4()); + } + + private StudentCreateDTO validCreate() + { + StudentCreateDTO dto = new StudentCreateDTO(); + dto.setsName("张三"); + dto.setsGender("男"); + dto.setOrgName("信号段"); + dto.setsPhone("13800000001"); + dto.setsEmail("student@example.com"); + dto.setIdCard(ID_CARD); + dto.setFaceRef("face/object/001"); + dto.setRemark("P0测试"); + return dto; + } + + private void assertError(int expectedCode, Runnable action) + { + try + { + action.run(); + fail("应抛出 ServiceException"); + } + catch (ServiceException e) + { + assertEquals(Integer.valueOf(expectedCode), e.getCode()); + assertFalse("异常不得包含身份证明文", String.valueOf(e.getMessage()).contains(ID_CARD)); + } + } + + private static final class InMemoryStudentMapper implements TrainStudentMapper + { + private final Map store = new LinkedHashMap<>(); + private boolean failNextInsertAsDuplicate; + + @Override + public TrainStudent selectTrainStudentBySId(String sId) + { + return store.get(sId); + } + + @Override + public List selectTrainStudentList(StudentQuery query) + { + return new ArrayList<>(store.values()); + } + + @Override + public Integer selectOneByIdCardHash(String idCardHash) + { + for (TrainStudent student : store.values()) + { + if (idCardHash.equals(student.getIdCardHash())) + { + return 1; + } + } + return null; + } + + @Override + public int insertTrainStudent(TrainStudent student) + { + if (failNextInsertAsDuplicate) + { + failNextInsertAsDuplicate = false; + throw new DuplicateKeyException("simulated unique constraint"); + } + store.put(student.getsId(), student); + return 1; + } + } +} diff --git a/build_ui/src/api/train/student.js b/build_ui/src/api/train/student.js index 7f5dc3a2a..d5278cf75 100644 --- a/build_ui/src/api/train/student.js +++ b/build_ui/src/api/train/student.js @@ -1,6 +1,6 @@ import request from '@/utils/request' -// 查询学生信息列表 +// 查询学员主档列表 export function listStudent(query) { return request({ url: '/train/student/list', @@ -9,7 +9,7 @@ export function listStudent(query) { }) } -// 查询学生信息详细 +// 查询学员主档详情(身份证默认脱敏) export function getStudent(sId) { return request({ url: '/train/student/' + sId, @@ -17,7 +17,7 @@ export function getStudent(sId) { }) } -// 新增学生信息 +// 新增学员主档 export function addStudent(data) { return request({ url: '/train/student', @@ -25,20 +25,3 @@ export function addStudent(data) { data: data }) } - -// 修改学生信息 -export function updateStudent(data) { - return request({ - url: '/train/student', - method: 'put', - data: data - }) -} - -// 删除学生信息 -export function delStudent(sId) { - return request({ - url: '/train/student/' + sId, - method: 'delete' - }) -} diff --git a/build_ui/src/views/train/student/index.vue b/build_ui/src/views/train/student/index.vue index 605d56c68..97e1d08de 100644 --- a/build_ui/src/views/train/student/index.vue +++ b/build_ui/src/views/train/student/index.vue @@ -1,50 +1,25 @@ + + diff --git a/docs/development/issue-01-02-student.md b/docs/development/issue-01-02-student.md new file mode 100644 index 000000000..d4c1675a8 --- /dev/null +++ b/docs/development/issue-01-02-student.md @@ -0,0 +1,191 @@ +# ISSUE-01-02:学员基础主数据管理 P0 开发与验收报告 + +> **文档定位:** 本文档记录 RailwaySystem P0 阶段 `ISSUE-01-02 学员主档建档与安全脱敏管理` 的完整实施与验收结果。 +> **实施分支:** `feat/issue-01-02-student` +> **Integration Baseline:** `4682df03c566776562b693ed5cb48fd98cda2472` (`integration/wave1a-foundation`) + +--- + +## 1. 任务目标与交付范围 + +1. **业务目标:** 建立全局学员长期主档管理,支撑后续培训名单与学员考勤,落实身份证敏感数据的安全散列查重、密文存储与脱敏掩码展示规范。 +2. **正式交付 P0 API 列表(严格等于 3 个):** + - `GET /train/student/list`(权限:`train:student:list`)- 分页检索学员列表,身份证掩码脱敏展示。 + - `GET /train/student/{sId}`(权限:`train:student:query`)- 获取学员主档详情,身份证默认掩码脱敏展示。 + - `POST /train/student`(权限:`train:student:add`)- 学员手工建档录入,可逆加密存储与散列查重。 +3. **严格不做什么(Out of Scope):** + - 不开放 `PUT /train/student...`(学员信息修改属于 P1 阶段); + - 不开放 `DELETE /train/student...`(未在冻结契约规划内); + - 不开放 `POST /train/student/export`(P0 严禁批量导出明文/密文身份证); + - 不开放 `POST /train/student/import`(Excel 批量导入学员主档属于 P1 阶段); + - 不公开任何历史旧 CRUD 别名或未冻结端点,**Contract-extra Endpoint 严格为 0**。 + +--- + +## 2. 身份证安全治理红线与技术落地 + +依据 Database V2(`train_student`)已冻结的存储模型,彻底杜绝身份证明文落库: + +### 2.1 存储模型与字段语义 + +| 物理字段 | 类型 | 语义与用途 | +| :--- | :--- | :--- | +| `id_card_ciphertext` | TEXT | **可逆加密密文**,仅为确需恢复身份信息的内部安全边界保留(允许为 NULL) | +| `id_card_hash` | VARCHAR(128) | **确定性安全散列**,用于查重与精确匹配检索(具备 UNIQUE 约束 `uq_student_idcard_hash`,允许为 NULL) | +| `id_card_last4` | VARCHAR(4) | **末四位明文**,专用于前端脱敏展示与轻量确认(允许为 NULL) | + +### 2.2 无身份证学员语义支持 + +Database V2 已明确允许 `id_card_ciphertext`、`id_card_hash`、`id_card_last4` 为 `NULL`。针对实际业务中可能暂未采集身份证的情形,落实以下统一规则: +- `StudentCreateDTO` 与 Service 层全面支持 `idCard` 为 `null`、`""`(空串)或 `" "`(纯空格); +- **统一规范化规则:** + - 当 `idCard == null` 或 `idCard.trim().isEmpty()` 时,统一规范化为 Java `null`; + - 赋值 `idCardCiphertext = null`、`idCardHash = null`、`idCardLast4 = null`; + - **不调用** 可逆加密组件; + - **不调用** 确定性散列计算; + - **不执行** 身份证 Hash 查重查询; + - 写入数据库时严格落为 SQL `NULL`,绝对不会写入空字符串 `''`; + - 多个无身份证学员建档时,由于各行均为 `NULL`,在数据库 `uq_student_idcard_hash` 约束下互不冲突,**绝不触发 40002 重复异常**; +- 只有在身份证非空时,才进入后续的规范化校验、查重、加密及提取 last4 流程。 + +### 2.3 规范化(Canonicalization) + +为避免因输入格式差异导致相同身份证生成不同散列值(如包含前后空白字符、末位大小写混用),统一非空身份证的规范化规则: +- 执行 `trim()` 去除前后空白字符; +- 执行 `toUpperCase(Locale.ROOT)` 将末位校验码统一转换为大写字符(如 `x` -> `X`); +- 正则格式校验:`^[0-9]{17}[0-9X]$`,保证进入密码组件的身份证格式绝对一致。 + +### 2.4 确定性散列(Deterministic Hash)与查重 + +- 查重散列采用独立的 `HmacSHA256` 算法与专用 Hash 密钥计算,输出 64 位小写十六进制确定性散列值; +- 新增学员前执行 `SELECT 1 FROM train_student WHERE id_card_hash = ? LIMIT 1` 进行前置预检; +- 遇到重复身份证直接阻断,抛出业务异常 `40002 STUDENT_IDCARD_DUPLICATED`。 + +### 2.5 并发 UNIQUE 冲突兜底 + +- Database V2 在 `train_student.id_card_hash` 上已建立唯一约束 `uq_student_idcard_hash`; +- 在高并发极端情况下,若两个相同身份证请求同时穿透 Service 前置查重并尝试 INSERT,底层抛出 Spring `DuplicateKeyException`; +- Service 层显式捕获 `DuplicateKeyException` 并统一映射为 `40002 STUDENT_IDCARD_DUPLICATED`,彻底杜绝底层数据库异常裸报 500。 + +### 2.6 脱敏掩码规则(Masking) + +- 掩码格式:前 6 位 + 8 个星号 + 末 4 位(例如:`110101********1234`); +- 列表接口(`StudentItemVO`)与详情接口(`StudentDetailVO`)统一仅返回 `idCardMasked` 与 `idCardLast4`; +- 对于无身份证学员,掩码字段自然返回 `null`。 + +### 2.7 算法表述纠偏(Implementation Decision) + +- **算法声明:** 当前实现选择 `AES/GCM/NoPadding`(每次加密生成 12 字节安全随机 IV,版本前缀 `v1.`)与 `HmacSHA256`(确定性散列),**这是 Security Implementation 当前阶段的具体工程实现决策,不是 API Contract / PRD 强制指定算法**; +- 算法实现统一封装在独立的 `StudentIdentityProtection` 接口及 `JceStudentIdentityProtection` 组件之后,Controller 与上层业务模块对底层算法完全无感,后续可平滑替换。 + +### 2.8 密钥管理、部署注入与 Fail-Closed 原则 + +- **密钥来源:** 生产环境与测试环境严格由外部环境变量或配置动态注入: + - `train.student.identity.encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}` + - `train.student.identity.hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}` +- **Fail-Closed:** 生产配置缺失密钥或密钥长度不合规时,组件构造直接抛出 `IllegalStateException` 拒绝初始化,代码库和构建产物中严禁保留任何默认密钥、弱密钥或生产 fallback; +- **部署与 CI/CD 说明:** + - **GitLab CI/CD / 部署环境必须以 Secret Variables(或 Kubernetes Secret / 外部配置中心)注入以下两个变量:** + - `TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY` + - `TRAIN_STUDENT_IDENTITY_HASH_KEY` + - **严禁将真实密钥写入代码库或提交至 Git 仓库。** +- 单元测试提供专用包级构造器注入独立的 test-only secret,测试密钥与生产环境彻底物理隔离。 + +### 2.9 全方位防泄漏防线 + +1. **持久化防线:** `train_student` 无任何身份证明文字段,密文与散列分别入库,支持安全为 NULL; +2. **DTO / 请求防线:** `StudentCreateDTO.idCard` 声明 `@JsonProperty(access = Access.WRITE_ONLY)` 与 `@JSONField(serialize = false)`,其 `toString()` 仅打印是否包含标识(`idCardProvided=true`),绝不打印号码; +3. **Query 防线:** `StudentQuery` 最终完整字段仅包含 `sName`, `orgName`, `sPhone`, `status`,**严禁且不包含** 任何身份证明文字段,防止通过 URL、代理访问日志或 RuoYi 访问日志泄漏证件号; +4. **VO / 响应防线:** `StudentItemVO` 与 `StudentDetailVO` 仅包含 `idCardMasked` 与 `idCardLast4`,无密文、哈希或明文字段; +5. **Domain 实体防线:** `TrainStudent` 的 `idCardCiphertext` 与 `idCardHash` 标注 `@JsonIgnore`;重写的 `toString()` 明确剔除密文、哈希与明文,仅保留 `idCardLast4`; +6. **操作日志防线:** Controller 录入接口明确配置 `@Log(..., isSaveRequestData = false, isSaveResponseData = true)`,防止 RuoYi 操作日志落库泄露请求体中的明文身份证; +7. **Excel 导出防线:** `TrainStudent` 彻底移除历史敏感 `@Excel` 注解,本 Issue 不开放导出端点; +8. **异常堆栈防线:** 业务异常信息(如 40002 提示)为固定文案,严禁拼接或携带身份证号。 + +--- + +## 3. DTO / VO / Domain 模型目录 + +| 类型 | 路径 | 职责边界 | +| :--- | :--- | :--- | +| **`StudentCreateDTO`** | `com.ruoyi.train.domain.dto.StudentCreateDTO` | 学员录入输入模型,身份证为选填只写属性,严格校验格式与长度 | +| **`StudentQuery`** | `com.ruoyi.train.domain.query.StudentQuery` | 学员分页检索条件(仅含 `sName`, `orgName`, `sPhone`, `status`,无任何证件字段) | +| **`StudentItemVO`** | `com.ruoyi.train.domain.vo.StudentItemVO` | 列表轻量脱敏视图,包含 `idCardMasked` 与 `idCardLast4` | +| **`StudentDetailVO`** | `com.ruoyi.train.domain.vo.StudentDetailVO` | 详情脱敏视图,继承 `StudentItemVO` 并扩充人脸凭证、备注及创建审计信息 | +| **`TrainStudent`** | `com.ruoyi.train.domain.TrainStudent` | 数据库持久化实体,仅在 Mapper 与 Service 内部流转,严禁直接暴露给 Controller | + +--- + +## 4. 业务错误码定义 + +对齐 `docs/api-v2/10_permission_and_error_codes.md`: + +| 错误码 | 标识常量 | HTTP Status | 说明与触发场景 | +| :---: | :--- | :---: | :--- | +| **40002** | `STUDENT_IDCARD_DUPLICATED` | 200 (业务码) | 学员身份证号已建档,不可重复录入(散列查重或并发唯一约束命中) | +| **40402** | `STUDENT_NOT_FOUND` | 200 (业务码) | 查询指定 `sId` 学员不存在或已被逻辑删除时抛出 | + +--- + +## 5. Generator 策略说明 + +- **冻结策略:** PARTIAL +- **本轮实际执行情况:** 未重新执行代码生成器。本 Issue 延续了在既有工程结构上的精细化手写实现,保留了规范的 DTO/VO 隔离与安全密码学边界,避免因机械生成覆盖已有的安全加固代码。 +- **Database V2 状态:** 未做任何 DDL 修改,完全适配现有 `train_student` 表结构与约束。 + +--- + +## 6. 前端 P0-Only 改造与构建验证 + +- **视图文件:** `build_ui/src/views/train/student/index.vue` +- **API 客户端:** `build_ui/src/api/train/student.js` +- **功能保留范围:** + - 学员列表分页检索与展示(展示脱敏身份证号 `idCardMasked`); + - 学员详情弹窗(只读查看脱敏身份证及学员详细档案); + - 学员新增表单(身份证字段选填,输入时前端进行 18 位身份证号码正则校验与姓名必填校验)。 +- **完全关闭与剔除的能力:** + - 剔除编辑按钮、更新方法及 `PUT` API 调用; + - 剔除删除按钮、删除方法及 `DELETE` API 调用; + - 剔除导出按钮及 `export` API 调用; + - 表单提交后清空敏感明文字段,前端无 `console.log` 身份证日志。 +- **构建环境治理:** + - ESLint 检查:`./node_modules/.bin/eslint --no-ignore src/api/train/student.js src/views/train/student/index.vue` 零 Warning / 零 Error; + - 生产构建:`npm run build:prod` 顺利构建成功(`DONE Build complete`); + - 构建验证结束后已立即清理临时软链接与 `dist` 构建物。 + +--- + +## 7. 自动化测试与验收结果 + +### 7.1 Student 专项测试套件(共 20 个用例,全部通过) + +1. **`JceStudentIdentityProtectionTest`(6 个测试用例):** + - `shouldEncryptReversiblyWithoutPersistingPlaintext`: 验证 AES-GCM 随机 IV 可逆加密,密文不含明文,相同明文产生不同密文且能正确解密; + - `shouldProduceDeterministicHashAndFrozenMask`: 验证 HMAC-SHA256 散列确定性计算及 `110101********1234` 掩码规范; + - `missingEncryptionKeyShouldFailClosed`: 验证加密密钥缺失时 fail-closed 抛出 `IllegalStateException`; + - `invalidEncryptionKeyLengthShouldFailClosed`: 验证加密密钥长度无效时抛出异常; + - `missingHashKeyShouldFailClosed`: 验证散列密钥缺失时 fail-closed 抛出 `IllegalStateException`; + - `invalidHashKeyLengthShouldFailClosed`: 验证散列密钥过短时抛出异常。 +2. **`TrainStudentServiceImplTest`(11 个测试用例):** + - `createShouldPersistOnlyProtectedIdentityMaterial`: 验证正常录入学员、`s_id` 自动生成、明文不持久化、密文与散列落库、`last4` 提取正确; + - `duplicateIdentityHashShouldBeBlockedWithFrozenCode`: 验证相同身份证重复录入抛出 `40002 STUDENT_IDCARD_DUPLICATED`; + - `canonicalizedXxShouldBeDuplicateBlocked`: 验证小写 `x` 与大写 `X`(含首尾空格)规范化后一致查重阻断(`40002`); + - `databaseUniqueRaceShouldAlsoMapToDuplicateCode`: 验证并发冲突触发 `DuplicateKeyException` 时映射为 `40002`; + - `listAndDetailShouldExposeMaskedIdentityOnly`: 验证列表与详情均只暴露 `idCardMasked` 与 `idCardLast4`; + - `missingStudentShouldReturnFrozenNotFoundCode`: 验证查询不存在学员抛出 `40402 STUDENT_NOT_FOUND`; + - `logsAndDtoStringShouldNotContainPlainIdentity`: 验证 DTO、实体与异常的 `toString()` 和 FastJSON 序列化均不泄漏明文; + - `domainJacksonSerializationShouldNotLeakSensitiveFields`: 验证 Jackson 序列化 `TrainStudent` 时 `@JsonIgnore` 生效,密文与哈希不外泄; + - `testCreateStudentWithoutIdCard`: 验证无身份证学员正常创建,密文/哈希/last4 均为 null; + - `testCreateMultipleStudentsWithoutIdCard`: 验证多个无身份证学员同时建档成功,不触发 40002 重复异常; + - `testBlankIdCardNormalizedToNull`: 验证传入空字符串 `""` 或空白字符串 `" "` 规范化为 null 入库。 +3. **`StudentApiTest`(3 个测试用例):** + - `shouldExposeFrozenP0MethodsAndPermissions`: 验证 Controller 暴露的 3 个方法路径、注解、权限标识(`train:student:*`)以及 `@Log(isSaveRequestData = false)`; + - `controllerMustNotExposePersistenceDomain`: 验证 Controller 的出参入参均不暴露持久化实体 `TrainStudent`; + - `controllerMustNotExposeUnfrozenEndpoints`: 验证 Controller 严格只有 3 个端点,绝对不存在任何 `PUT`、`DELETE` 或 `export` 方法。 + +### 7.2 全量工程验证汇总 + +- **Maven Test:** `mvn test -pl ruoyi-train,ruoyi-admin -am -f RuoYi-Vue/pom.xml` -> **Total tests run: 34, Failures: 0, Errors: 0, Skipped: 0 (BUILD SUCCESS)** +- **Maven Compile:** `mvn compile -DskipTests -f RuoYi-Vue/pom.xml` -> **8/8 Reactor Modules 全部 SUCCESS** +- **前端 ESLint:** 0 error, 0 warning +- **前端 Build:** `npm run build:prod` -> 成功通过