新增培训:实现学员主数据P0能力

This commit is contained in:
wangzhibiao committed 2026-09-14 13:08:57 +08:00
1 parent 4bd2f54856
commit 11370b1992
21 files changed
+1611 -649

No files matched your search

+7
View File
@@ -73,6 +73,13 @@
<artifactId>ruoyi-train</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -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<TrainStudent> list = trainStudentService.selectTrainStudentList(trainStudent);
List<StudentItemVO> 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<TrainStudent> list = trainStudentService.selectTrainStudentList(trainStudent);
ExcelUtil<TrainStudent> util = new ExcelUtil<TrainStudent>(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));
}
}
@@ -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:
# 令牌自定义标识
@@ -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);
}
}
@@ -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()
{
}
}
@@ -2,70 +2,54 @@ 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
* 学员长期主档持久化对象 train_student。
*
* @author ruoyi
* @date 2026-09-08
* <p>该类型只用于持久化边界,不得由 Controller 直接接收或返回。</p>
*/
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;
/** 仅用于脱敏展示的末四位。 */
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()
@@ -73,9 +57,9 @@ public class TrainStudent extends BaseEntity
return sName;
}
public void setsGender(String sGender)
public void setsName(String sName)
{
this.sGender = sGender;
this.sName = sName;
}
public String getsGender()
@@ -83,9 +67,9 @@ public class TrainStudent extends BaseEntity
return sGender;
}
public void setsBirth(Date sBirth)
public void setsGender(String sGender)
{
this.sBirth = sBirth;
this.sGender = sGender;
}
public Date getsBirth()
@@ -93,39 +77,29 @@ public class TrainStudent extends BaseEntity
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;
}
public String getsClass()
{
return sClass;
}
public void setsPhone(String sPhone)
{
this.sPhone = sPhone;
this.orgName = orgName;
}
public String getsPhone()
@@ -133,29 +107,116 @@ public class TrainStudent extends BaseEntity
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() {
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();
@@ -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) + "}";
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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
*/
/** 学员主档持久化 Mapper。 */
public interface TrainStudentMapper
{
/**
* 查询学生信息
*
* @param sId 学生信息主键
* @return 学生信息
*/
public TrainStudent selectTrainStudentBySId(String sId);
TrainStudent selectTrainStudentBySId(String sId);
/**
* 查询学生信息列表
*
* @param trainStudent 学生信息
* @return 学生信息集合
*/
public List<TrainStudent> selectTrainStudentList(TrainStudent trainStudent);
List<TrainStudent> 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);
}
@@ -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 的身份证安全组件。
*
* <p>当前实现明确使用 AES-GCM 与 HMAC-SHA-256,不宣称国密能力。算法被隔离在
* {@link StudentIdentityProtection} 端口之后,后续可替换实现。两把独立密钥仅从
* 外部配置读取,仓库内不提供默认密钥。</p>
*/
@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;
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.train.security;
/**
* 学员身份证安全能力端口。
*
* <p>业务层只依赖此接口,算法与密钥载入细节不得进入 Controller。</p>
*/
public interface StudentIdentityProtection
{
String encrypt(String plaintext);
String decrypt(String ciphertext);
String deterministicHash(String plaintext);
String mask(String plaintext);
}
@@ -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
*/
/** ISSUE-01-02 学员主档应用服务边界。 */
public interface ITrainStudentService
{
/**
* 查询学生信息
*
* @param sId 学生信息主键
* @return 学生信息
*/
public TrainStudent selectTrainStudentBySId(String sId);
List<StudentItemVO> selectStudentList(StudentQuery query);
/**
* 查询学生信息列表
*
* @param trainStudent 学生信息
* @return 学生信息集合
*/
public List<TrainStudent> 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);
}
@@ -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
{
@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<TrainStudent> selectTrainStudentList(TrainStudent trainStudent)
public List<StudentItemVO> selectStudentList(StudentQuery query)
{
return trainStudentMapper.selectTrainStudentList(trainStudent);
List<TrainStudent> students = trainStudentMapper.selectTrainStudentList(query);
List<StudentItemVO> 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 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。
}
/**
* 批量删除学生信息
*
* @param sIds 需要删除的学生信息主键
* @return 结果
*/
@Override
public int deleteTrainStudentBySIds(String[] sIds)
try
{
return trainStudentMapper.deleteTrainStudentBySIds(sIds);
trainStudentMapper.insertTrainStudent(student);
}
catch (DuplicateKeyException e)
{
// 应用层预检之外,数据库唯一约束继续封堵并发重复建档。
throw duplicateIdentity();
}
return student.getsId();
}
/**
* 删除学生信息信息
*
* @param sId 学生信息主键
* @return 结果
*/
@Override
public int deleteTrainStudentBySId(String sId)
private StudentItemVO toItemVO(TrainStudent student)
{
return trainStudentMapper.deleteTrainStudentBySId(sId);
StudentItemVO item = new StudentItemVO();
copyCommon(student, item);
return item;
}
private void copyCommon(TrainStudent student, StudentItemVO target)
{
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<StudentItemVO> createPagedResult(List<TrainStudent> source)
{
if (source instanceof Page)
{
Page<TrainStudent> sourcePage = (Page<TrainStudent>) source;
Page<StudentItemVO> 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);
}
}
@@ -9,97 +9,71 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="sName" column="s_name" />
<result property="sGender" column="s_gender" />
<result property="sBirth" column="s_birth" />
<result property="sDepartment" column="s_department" />
<result property="sMajor" column="s_major" />
<result property="sClass" column="s_class" />
<result property="userId" column="user_id" />
<result property="orgName" column="org_name" />
<result property="sPhone" column="s_phone" />
<result property="createUser" column="create_user" />
<result property="sEmail" column="s_email" />
<result property="idCardCiphertext" column="id_card_ciphertext" />
<result property="idCardHash" column="id_card_hash" />
<result property="idCardLast4" column="id_card_last4" />
<result property="faceRef" column="face_ref" />
<result property="sourceType" column="source_type" />
<result property="status" column="status" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="delFlag" column="del_flag" />
</resultMap>
<sql id="selectTrainStudentVo">
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
<sql id="selectTrainStudentDomain">
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
</sql>
<select id="selectTrainStudentList" parameterType="TrainStudent" resultMap="TrainStudentResult">
<include refid="selectTrainStudentVo"/>
<select id="selectTrainStudentList"
parameterType="com.ruoyi.train.domain.query.StudentQuery"
resultMap="TrainStudentResult">
<include refid="selectTrainStudentDomain" />
<where>
<if test="sName != null and sName != ''"> and s_name like concat('%', #{sName}, '%')</if>
<if test="sGender != null and sGender != ''"> and s_gender = #{sGender}</if>
<if test="sBirth != null "> and s_birth = #{sBirth}</if>
<if test="sDepartment != null and sDepartment != ''"> and s_department = #{sDepartment}</if>
<if test="sMajor != null and sMajor != ''"> and s_major = #{sMajor}</if>
<if test="sClass != null and sClass != ''"> and s_class = #{sClass}</if>
<if test="sPhone != null and sPhone != ''"> and s_phone = #{sPhone}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
del_flag = '0'
<if test="sName != null and sName != ''">
and s_name like concat('%', #{sName}, '%')
</if>
<if test="orgName != null and orgName != ''">
and org_name like concat('%', #{orgName}, '%')
</if>
<if test="sPhone != null and sPhone != ''">
and s_phone = #{sPhone}
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
order by create_time desc, s_id
</select>
<select id="selectTrainStudentBySId" parameterType="String" resultMap="TrainStudentResult">
<include refid="selectTrainStudentVo"/>
where s_id = #{sId}
<include refid="selectTrainStudentDomain" />
where s_id = #{sId} and del_flag = '0'
</select>
<select id="selectOneByIdCardHash" parameterType="String" resultType="java.lang.Integer">
select 1 from train_student where id_card_hash = #{idCardHash} limit 1
</select>
<insert id="insertTrainStudent" parameterType="TrainStudent">
insert into train_student
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="sId != null">s_id,</if>
<if test="sName != null and sName != ''">s_name,</if>
<if test="sGender != null">s_gender,</if>
<if test="sBirth != null">s_birth,</if>
<if test="sDepartment != null and sDepartment != ''">s_department,</if>
<if test="sMajor != null and sMajor != ''">s_major,</if>
<if test="sClass != null">s_class,</if>
<if test="sPhone != null">s_phone,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="sId != null">#{sId},</if>
<if test="sName != null and sName != ''">#{sName},</if>
<if test="sGender != null">#{sGender},</if>
<if test="sBirth != null">#{sBirth},</if>
<if test="sDepartment != null and sDepartment != ''">#{sDepartment},</if>
<if test="sMajor != null and sMajor != ''">#{sMajor},</if>
<if test="sClass != null">#{sClass},</if>
<if test="sPhone != null">#{sPhone},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
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}
)
</insert>
<update id="updateTrainStudent" parameterType="TrainStudent">
update train_student
<trim prefix="SET" suffixOverrides=",">
<if test="sName != null and sName != ''">s_name = #{sName},</if>
<if test="sGender != null">s_gender = #{sGender},</if>
<if test="sBirth != null">s_birth = #{sBirth},</if>
<if test="sDepartment != null and sDepartment != ''">s_department = #{sDepartment},</if>
<if test="sMajor != null and sMajor != ''">s_major = #{sMajor},</if>
<if test="sClass != null">s_class = #{sClass},</if>
<if test="sPhone != null">s_phone = #{sPhone},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where s_id = #{sId}
</update>
<delete id="deleteTrainStudentBySId" parameterType="String">
delete from train_student where s_id = #{sId}
</delete>
<delete id="deleteTrainStudentBySIds" parameterType="String">
delete from train_student where s_id in
<foreach item="sId" collection="array" open="(" separator="," close=")">
#{sId}
</foreach>
</delete>
</mapper>
@@ -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]));
}
}
@@ -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<StudentItemVO> 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<String, TrainStudent> store = new LinkedHashMap<>();
private boolean failNextInsertAsDuplicate;
@Override
public TrainStudent selectTrainStudentBySId(String sId)
{
return store.get(sId);
}
@Override
public List<TrainStudent> 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;
}
}
}
+3 -20
View File
@@ -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'
})
}
+169 -240
View File
@@ -1,50 +1,25 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="学生姓名" prop="sName">
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="68px"
>
<el-form-item label="学员姓名" prop="sName">
<el-input
v-model="queryParams.sName"
placeholder="请输入学生姓名"
placeholder="请输入学员姓名"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="性别:男 / 女" prop="sGender">
<el-form-item label="所属单位" prop="orgName">
<el-input
v-model="queryParams.sGender"
placeholder="请输入性别:男 / 女"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="出生日期" prop="sBirth">
<el-date-picker clearable
v-model="queryParams.sBirth"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择出生日期">
</el-date-picker>
</el-form-item>
<el-form-item label="所属院系" prop="sDepartment">
<el-input
v-model="queryParams.sDepartment"
placeholder="请输入所属院系"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="专业" prop="sMajor">
<el-input
v-model="queryParams.sMajor"
placeholder="请输入专业"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="班级" prop="sClass">
<el-input
v-model="queryParams.sClass"
placeholder="请输入班级"
v-model="queryParams.orgName"
placeholder="请输入所属单位"
clearable
@keyup.enter.native="handleQuery"
/>
@@ -57,13 +32,11 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建人 (管理员 / 教师)"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="学员状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -74,81 +47,51 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:student:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:student:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:student:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:student:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:student:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="studentList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="学生学号" align="center" prop="sId" />
<el-table-column label="学生姓名" align="center" prop="sName" />
<el-table-column label="性别:男 / 女" align="center" prop="sGender" />
<el-table-column label="出生日期" align="center" prop="sBirth" width="180">
<el-table v-loading="loading" :data="studentList">
<el-table-column label="学员编号" align="center" prop="sId" min-width="160" />
<el-table-column label="学员姓名" align="center" prop="sName" min-width="100" />
<el-table-column label="性别" align="center" prop="sGender" width="70" />
<el-table-column label="出生日期" align="center" prop="sBirth" width="110">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.sBirth, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="所属院系" align="center" prop="sDepartment" />
<el-table-column label="专业" align="center" prop="sMajor" />
<el-table-column label="班级" align="center" prop="sClass" />
<el-table-column label="联系电话" align="center" prop="sPhone" />
<el-table-column label="创建人 (管理员 / 教师)" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="所属单位" align="center" prop="orgName" min-width="140" show-overflow-tooltip />
<el-table-column label="联系电话" align="center" prop="sPhone" width="130" />
<el-table-column label="邮箱" align="center" prop="sEmail" min-width="160" show-overflow-tooltip />
<el-table-column label="身份证号" align="center" prop="idCardMasked" min-width="190" />
<el-table-column label="来源" align="center" prop="sourceType" width="100">
<template slot-scope="scope">
<span>{{ sourceTypeLabel(scope.row.sourceType) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="90">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:student:query']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:student:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:student:remove']"
>删除</el-button>
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
</template>
</el-table-column>
</el-table>
@@ -161,211 +104,197 @@
@pagination="getList"
/>
<!-- 添加或修改学生信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="学生姓名" prop="sName">
<el-input v-model="form.sName" placeholder="请输入学生姓名" />
<el-dialog title="新增学员主档" :visible.sync="createOpen" width="560px" append-to-body @closed="resetCreate">
<el-form ref="createForm" :model="createForm" :rules="rules" label-width="90px">
<el-form-item label="学员姓名" prop="sName">
<el-input v-model="createForm.sName" placeholder="请输入学员姓名" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="性别:男 / 女" prop="sGender">
<el-input v-model="form.sGender" placeholder="请输入性别:男 / 女" />
<el-form-item label="性别" prop="sGender">
<el-radio-group v-model="createForm.sGender">
<el-radio label="男">男</el-radio>
<el-radio label="女">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="出生日期" prop="sBirth">
<el-date-picker clearable
v-model="form.sBirth"
<el-date-picker
v-model="createForm.sBirth"
clearable
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择出生日期">
</el-date-picker>
placeholder="请选择出生日期"
/>
</el-form-item>
<el-form-item label="所属院系" prop="sDepartment">
<el-input v-model="form.sDepartment" placeholder="请输入所属院系" />
</el-form-item>
<el-form-item label="专业" prop="sMajor">
<el-input v-model="form.sMajor" placeholder="请输入专业" />
</el-form-item>
<el-form-item label="班级" prop="sClass">
<el-input v-model="form.sClass" placeholder="请输入班级" />
<el-form-item label="所属单位" prop="orgName">
<el-input v-model="createForm.orgName" placeholder="请输入所属单位/部门" maxlength="100" />
</el-form-item>
<el-form-item label="联系电话" prop="sPhone">
<el-input v-model="form.sPhone" placeholder="请输入联系电话" />
<el-input v-model="createForm.sPhone" placeholder="请输入联系电话" maxlength="20" />
</el-form-item>
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建人 (管理员 / 教师)" />
<el-form-item label="邮箱" prop="sEmail">
<el-input v-model="createForm.sEmail" placeholder="请输入电子邮箱" maxlength="100" />
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-input
v-model.trim="createForm.idCard"
placeholder="请输入18位身份证号(选填)"
maxlength="18"
autocomplete="off"
show-password
/>
</el-form-item>
<el-form-item label="人脸凭据" prop="faceRef">
<el-input v-model="createForm.faceRef" placeholder="请输入已授权的人脸凭据引用" maxlength="255" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="createForm.remark" type="textarea" placeholder="请输入备注" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" :loading="submitting" @click="submitCreate">确 定</el-button>
<el-button @click="createOpen = false">取 消</el-button>
</div>
</el-dialog>
<el-dialog title="学员主档详情" :visible.sync="detailOpen" width="560px" append-to-body>
<el-form :model="detail" label-width="90px" class="student-detail">
<el-form-item label="学员姓名"><span>{{ detail.sName || '-' }}</span></el-form-item>
<el-form-item label="所属单位"><span>{{ detail.orgName || '-' }}</span></el-form-item>
<el-form-item label="联系电话"><span>{{ detail.sPhone || '-' }}</span></el-form-item>
<el-form-item label="邮箱"><span>{{ detail.sEmail || '-' }}</span></el-form-item>
<el-form-item label="身份证号"><span>{{ detail.idCardMasked || '-' }}</span></el-form-item>
<el-form-item label="人脸凭据"><span>{{ detail.faceRef || '-' }}</span></el-form-item>
<el-form-item label="备注"><span>{{ detail.remark || '-' }}</span></el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script>
import { listStudent, getStudent, delStudent, addStudent, updateStudent } from "@/api/train/student"
import { addStudent, getStudent, listStudent } from '@/api/train/student'
export default {
name: "Student",
name: 'Student',
data() {
const validateIdCard = (rule, value, callback) => {
if (!value || value.trim() === '') {
callback()
return
}
if (!/^[0-9]{17}[0-9Xx]$/.test(value.trim())) {
callback(new Error('请输入18位身份证号'))
return
}
callback()
}
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
submitting: false,
showSearch: true,
// 总条数
total: 0,
// 学生信息表格数据
studentList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 查询参数
createOpen: false,
detailOpen: false,
detail: {},
queryParams: {
pageNum: 1,
pageSize: 10,
sName: null,
sGender: null,
sBirth: null,
sDepartment: null,
sMajor: null,
sClass: null,
orgName: null,
sPhone: null,
createUser: null,
status: null
},
// 表单参数
form: {},
// 表单校验
createForm: {},
rules: {
sName: [
{ required: true, message: "学生姓名不能为空", trigger: "blur" }
{ required: true, message: '学员姓名不能为空', trigger: 'blur' },
{ max: 50, message: '学员姓名长度不能超过50个字符', trigger: 'blur' }
],
sDepartment: [
{ required: true, message: "所属院系不能为空", trigger: "blur" }
],
sMajor: [
{ required: true, message: "专业不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建人 (管理员 / 教师)不能为空", trigger: "blur" }
sEmail: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
],
idCard: [
{ validator: validateIdCard, trigger: 'blur' }
]
}
}
},
created() {
this.resetCreate()
this.getList()
},
methods: {
/** 查询学生信息列表 */
getList() {
this.loading = true
listStudent(this.queryParams).then(response => {
this.studentList = response.rows
this.total = response.total
}).finally(() => {
this.loading = false
})
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
sId: null,
sName: null,
sGender: null,
sBirth: null,
sDepartment: null,
sMajor: null,
sClass: null,
sPhone: null,
createUser: null,
createTime: null,
updateTime: null,
remark: null
}
this.resetForm("form")
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.sId)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加学生信息"
this.resetCreate()
this.createOpen = true
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const sId = row.sId || this.ids
getStudent(sId).then(response => {
this.form = response.data
this.open = true
this.title = "修改学生信息"
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.sId != null) {
updateStudent(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addStudent(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
resetCreate() {
this.createForm = {
sName: null,
sGender: '男',
sBirth: null,
orgName: null,
sPhone: null,
sEmail: null,
idCard: null,
faceRef: null,
remark: null
}
if (this.$refs.createForm) {
this.resetForm('createForm')
}
},
submitCreate() {
this.$refs.createForm.validate(valid => {
if (!valid) {
return
}
this.submitting = true
addStudent(this.createForm).then(() => {
this.$modal.msgSuccess('新增成功')
this.createOpen = false
this.getList()
}).finally(() => {
this.submitting = false
})
})
},
/** 删除按钮操作 */
handleDelete(row) {
const sIds = row.sId || this.ids
this.$modal.confirm('是否确认删除学生信息编号为"' + sIds + '"的数据项?').then(function() {
return delStudent(sIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
handleDetail(row) {
getStudent(row.sId).then(response => {
this.detail = response.data || {}
this.detailOpen = true
})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/student/export', {
...this.queryParams
}, `student_${new Date().getTime()}.xlsx`)
sourceTypeLabel(sourceType) {
const labels = {
1: '管理员录入',
2: 'Excel导入',
3: '平台同步'
}
return labels[sourceType] || '-'
}
}
}
</script>
<style scoped>
.student-detail ::v-deep .el-form-item {
margin-bottom: 8px;
}
</style>
+191
View File
@@ -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` -> 成功通过