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
-
\ 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 @@
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -57,13 +32,11 @@
@keyup.enter.native="handleQuery"
/>
-
-
+
+
+
+
+
搜索
@@ -74,298 +47,254 @@
新增
-
- 修改
-
-
- 删除
-
-
- 导出
-
-
+
-
-
-
-
-
-
+
+
+
+
+
{{ parseTime(scope.row.sBirth, '{y}-{m}-{d}') }}
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ sourceTypeLabel(scope.row.sourceType) }}
+
+
+
+
+
+ {{ scope.row.status === 1 ? '正常' : '停用' }}
+
+
+
+
修改
- 删除
+ icon="el-icon-view"
+ @click="handleDetail(scope.row)"
+ >详情
-
-
-
-
-
+
+
+
+
-
-
+
+
+ 男
+ 女
+
-
-
+ placeholder="请选择出生日期"
+ />
-
-
-
-
-
-
-
-
+
+
-
+
-
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+ {{ detail.sName || '-' }}
+ {{ detail.orgName || '-' }}
+ {{ detail.sPhone || '-' }}
+ {{ detail.sEmail || '-' }}
+ {{ detail.idCardMasked || '-' }}
+ {{ detail.faceRef || '-' }}
+ {{ detail.remark || '-' }}
+
+
+
+
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` -> 成功通过