新增培训:实现学员主数据P0能力
This commit is contained in:
1 parent
4bd2f54856
commit
11370b1992
21 files changed
+1635
-673
No files matched your search
@@ -73,6 +73,13 @@
|
|||||||
<artifactId>ruoyi-train</artifactId>
|
<artifactId>ruoyi-train</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>4.13.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -130,4 +137,4 @@
|
|||||||
<finalName>${project.artifactId}</finalName>
|
<finalName>${project.artifactId}</finalName>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
+33
-77
@@ -1,104 +1,60 @@
|
|||||||
package com.ruoyi.web.controller.train;
|
package com.ruoyi.web.controller.train;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.validation.Valid;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import com.ruoyi.common.annotation.Log;
|
import com.ruoyi.common.annotation.Log;
|
||||||
import com.ruoyi.common.core.controller.BaseController;
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
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.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;
|
||||||
|
|
||||||
/**
|
/** ISSUE-01-02 学员主档 P0 API。 */
|
||||||
* 学生信息Controller
|
|
||||||
*
|
|
||||||
* @author ruoyi
|
|
||||||
* @date 2026-09-08
|
|
||||||
*/
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/train/student")
|
@RequestMapping("/train/student")
|
||||||
public class TrainStudentController extends BaseController
|
public class TrainStudentController extends BaseController
|
||||||
{
|
{
|
||||||
@Autowired
|
private final ITrainStudentService trainStudentService;
|
||||||
private ITrainStudentService trainStudentService;
|
|
||||||
|
|
||||||
/**
|
public TrainStudentController(ITrainStudentService trainStudentService)
|
||||||
* 查询学生信息列表
|
{
|
||||||
*/
|
this.trainStudentService = trainStudentService;
|
||||||
@PreAuthorize("@ss.hasPermi('system:student:list')")
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("@ss.hasPermi('train:student:list')")
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public TableDataInfo list(TrainStudent trainStudent)
|
public TableDataInfo list(StudentQuery query)
|
||||||
{
|
{
|
||||||
startPage();
|
startPage();
|
||||||
List<TrainStudent> list = trainStudentService.selectTrainStudentList(trainStudent);
|
List<StudentItemVO> list = trainStudentService.selectStudentList(query);
|
||||||
return getDataTable(list);
|
return getDataTable(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@PreAuthorize("@ss.hasPermi('train:student:query')")
|
||||||
* 导出学生信息列表
|
@GetMapping("/{sId}")
|
||||||
*/
|
|
||||||
@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}")
|
|
||||||
public AjaxResult getInfo(@PathVariable("sId") String sId)
|
public AjaxResult getInfo(@PathVariable("sId") String sId)
|
||||||
{
|
{
|
||||||
return success(trainStudentService.selectTrainStudentBySId(sId));
|
StudentDetailVO detail = trainStudentService.selectStudentDetail(sId);
|
||||||
|
return success(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@PreAuthorize("@ss.hasPermi('train:student:add')")
|
||||||
* 新增学生信息
|
@Log(title = "学员主档", businessType = BusinessType.INSERT,
|
||||||
*/
|
isSaveRequestData = false, isSaveResponseData = true)
|
||||||
@PreAuthorize("@ss.hasPermi('system:student:add')")
|
|
||||||
@Log(title = "学生信息", businessType = BusinessType.INSERT)
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public AjaxResult add(@RequestBody TrainStudent trainStudent)
|
public AjaxResult add(@Valid @RequestBody StudentCreateDTO dto)
|
||||||
{
|
{
|
||||||
return toAjax(trainStudentService.insertTrainStudent(trainStudent));
|
return success(trainStudentService.createStudent(dto));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改学生信息
|
|
||||||
*/
|
|
||||||
@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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,13 @@ spring:
|
|||||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||||
max-wait: -1ms
|
max-wait: -1ms
|
||||||
|
|
||||||
|
# 学员身份证安全配置只接受外部注入;仓库内不得保存实际密钥。
|
||||||
|
train:
|
||||||
|
student:
|
||||||
|
identity:
|
||||||
|
encryption-key: ${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:}
|
||||||
|
hash-key: ${TRAIN_STUDENT_IDENTITY_HASH_KEY:}
|
||||||
|
|
||||||
# token配置
|
# token配置
|
||||||
token:
|
token:
|
||||||
# 令牌自定义标识
|
# 令牌自定义标识
|
||||||
|
|||||||
+77
@@ -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,160 +2,221 @@ package com.ruoyi.train.domain;
|
|||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
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.ToStringBuilder;
|
||||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
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
|
* <p>该类型只用于持久化边界,不得由 Controller 直接接收或返回。</p>
|
||||||
* @date 2026-09-08
|
|
||||||
*/
|
*/
|
||||||
public class TrainStudent extends BaseEntity
|
public class TrainStudent extends BaseEntity
|
||||||
{
|
{
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
/** 学生学号 */
|
|
||||||
private String sId;
|
private String sId;
|
||||||
|
|
||||||
/** 学生姓名 */
|
|
||||||
@Excel(name = "学生姓名")
|
|
||||||
private String sName;
|
private String sName;
|
||||||
|
|
||||||
/** 性别:男 / 女 */
|
|
||||||
@Excel(name = "性别:男 / 女")
|
|
||||||
private String sGender;
|
private String sGender;
|
||||||
|
|
||||||
/** 出生日期 */
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||||
@Excel(name = "出生日期", width = 30, dateFormat = "yyyy-MM-dd")
|
|
||||||
private Date sBirth;
|
private Date sBirth;
|
||||||
|
|
||||||
/** 所属院系 */
|
private Long userId;
|
||||||
@Excel(name = "所属院系")
|
private String orgName;
|
||||||
private String sDepartment;
|
|
||||||
|
|
||||||
/** 专业 */
|
|
||||||
@Excel(name = "专业")
|
|
||||||
private String sMajor;
|
|
||||||
|
|
||||||
/** 班级 */
|
|
||||||
@Excel(name = "班级")
|
|
||||||
private String sClass;
|
|
||||||
|
|
||||||
/** 联系电话 */
|
|
||||||
@Excel(name = "联系电话")
|
|
||||||
private String sPhone;
|
private String sPhone;
|
||||||
|
private String sEmail;
|
||||||
|
|
||||||
/** 创建人 (管理员 / 教师) */
|
/** 可逆密文。严禁序列化到 API 或 Excel。 */
|
||||||
@Excel(name = "创建人 (管理员 / 教师)")
|
private String idCardCiphertext;
|
||||||
private String createUser;
|
|
||||||
|
|
||||||
public void setsId(String sId)
|
/** 确定性查重值。严禁序列化到 API 或 Excel。 */
|
||||||
{
|
private String idCardHash;
|
||||||
this.sId = sId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getsId()
|
/** 仅用于脱敏展示的末四位。 */
|
||||||
|
private String idCardLast4;
|
||||||
|
|
||||||
|
private String faceRef;
|
||||||
|
private Integer sourceType;
|
||||||
|
private Integer status;
|
||||||
|
private String delFlag;
|
||||||
|
|
||||||
|
public String getsId()
|
||||||
{
|
{
|
||||||
return sId;
|
return sId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsName(String sName)
|
public void setsId(String sId)
|
||||||
{
|
{
|
||||||
this.sName = sName;
|
this.sId = sId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getsName()
|
public String getsName()
|
||||||
{
|
{
|
||||||
return sName;
|
return sName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsGender(String sGender)
|
public void setsName(String sName)
|
||||||
{
|
{
|
||||||
this.sGender = sGender;
|
this.sName = sName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getsGender()
|
public String getsGender()
|
||||||
{
|
{
|
||||||
return sGender;
|
return sGender;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsBirth(Date sBirth)
|
public void setsGender(String sGender)
|
||||||
{
|
{
|
||||||
this.sBirth = sBirth;
|
this.sGender = sGender;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Date getsBirth()
|
public Date getsBirth()
|
||||||
{
|
{
|
||||||
return sBirth;
|
return sBirth;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsDepartment(String sDepartment)
|
public void setsBirth(Date sBirth)
|
||||||
{
|
{
|
||||||
this.sDepartment = sDepartment;
|
this.sBirth = sBirth;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getsDepartment()
|
public Long getUserId()
|
||||||
{
|
{
|
||||||
return sDepartment;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsMajor(String sMajor)
|
public void setUserId(Long userId)
|
||||||
{
|
{
|
||||||
this.sMajor = sMajor;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getsMajor()
|
public String getOrgName()
|
||||||
{
|
{
|
||||||
return sMajor;
|
return orgName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setsClass(String sClass)
|
public void setOrgName(String orgName)
|
||||||
{
|
{
|
||||||
this.sClass = sClass;
|
this.orgName = orgName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getsClass()
|
public String getsPhone()
|
||||||
{
|
|
||||||
return sClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setsPhone(String sPhone)
|
|
||||||
{
|
|
||||||
this.sPhone = sPhone;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getsPhone()
|
|
||||||
{
|
{
|
||||||
return sPhone;
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString()
|
||||||
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
|
{
|
||||||
|
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
|
||||||
.append("sId", getsId())
|
.append("sId", getsId())
|
||||||
.append("sName", getsName())
|
.append("sName", getsName())
|
||||||
.append("sGender", getsGender())
|
.append("sGender", getsGender())
|
||||||
.append("sBirth", getsBirth())
|
.append("sBirth", getsBirth())
|
||||||
.append("sDepartment", getsDepartment())
|
.append("userId", getUserId())
|
||||||
.append("sMajor", getsMajor())
|
.append("orgName", getOrgName())
|
||||||
.append("sClass", getsClass())
|
|
||||||
.append("sPhone", getsPhone())
|
.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("createTime", getCreateTime())
|
||||||
|
.append("updateBy", getUpdateBy())
|
||||||
.append("updateTime", getUpdateTime())
|
.append("updateTime", getUpdateTime())
|
||||||
.append("remark", getRemark())
|
.append("remark", getRemark())
|
||||||
.toString();
|
.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 java.util.List;
|
||||||
import com.ruoyi.train.domain.TrainStudent;
|
import com.ruoyi.train.domain.TrainStudent;
|
||||||
|
import com.ruoyi.train.domain.query.StudentQuery;
|
||||||
|
|
||||||
/**
|
/** 学员主档持久化 Mapper。 */
|
||||||
* 学生信息Mapper接口
|
public interface TrainStudentMapper
|
||||||
*
|
|
||||||
* @author ruoyi
|
|
||||||
* @date 2026-09-08
|
|
||||||
*/
|
|
||||||
public interface TrainStudentMapper
|
|
||||||
{
|
{
|
||||||
/**
|
TrainStudent selectTrainStudentBySId(String sId);
|
||||||
* 查询学生信息
|
|
||||||
*
|
|
||||||
* @param sId 学生信息主键
|
|
||||||
* @return 学生信息
|
|
||||||
*/
|
|
||||||
public TrainStudent selectTrainStudentBySId(String sId);
|
|
||||||
|
|
||||||
/**
|
List<TrainStudent> selectTrainStudentList(StudentQuery query);
|
||||||
* 查询学生信息列表
|
|
||||||
*
|
|
||||||
* @param trainStudent 学生信息
|
|
||||||
* @return 学生信息集合
|
|
||||||
*/
|
|
||||||
public List<TrainStudent> selectTrainStudentList(TrainStudent trainStudent);
|
|
||||||
|
|
||||||
/**
|
/** 使用冻结的 id_card_hash 做精确防重;存在时返回 1,不存在返回 null。 */
|
||||||
* 新增学生信息
|
Integer selectOneByIdCardHash(String idCardHash);
|
||||||
*
|
|
||||||
* @param trainStudent 学生信息
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
public int insertTrainStudent(TrainStudent trainStudent);
|
|
||||||
|
|
||||||
/**
|
int insertTrainStudent(TrainStudent trainStudent);
|
||||||
* 修改学生信息
|
|
||||||
*
|
|
||||||
* @param trainStudent 学生信息
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
public int updateTrainStudent(TrainStudent trainStudent);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除学生信息
|
|
||||||
*
|
|
||||||
* @param sId 学生信息主键
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
public int deleteTrainStudentBySId(String sId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量删除学生信息
|
|
||||||
*
|
|
||||||
* @param sIds 需要删除的数据主键集合
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
public int deleteTrainStudentBySIds(String[] sIds);
|
|
||||||
}
|
}
|
||||||
+168
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+9
-53
@@ -1,61 +1,17 @@
|
|||||||
package com.ruoyi.train.service;
|
package com.ruoyi.train.service;
|
||||||
|
|
||||||
import java.util.List;
|
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;
|
||||||
|
|
||||||
/**
|
/** ISSUE-01-02 学员主档应用服务边界。 */
|
||||||
* 学生信息Service接口
|
public interface ITrainStudentService
|
||||||
*
|
|
||||||
* @author ruoyi
|
|
||||||
* @date 2026-09-08
|
|
||||||
*/
|
|
||||||
public interface ITrainStudentService
|
|
||||||
{
|
{
|
||||||
/**
|
List<StudentItemVO> selectStudentList(StudentQuery query);
|
||||||
* 查询学生信息
|
|
||||||
*
|
|
||||||
* @param sId 学生信息主键
|
|
||||||
* @return 学生信息
|
|
||||||
*/
|
|
||||||
public TrainStudent selectTrainStudentBySId(String sId);
|
|
||||||
|
|
||||||
/**
|
StudentDetailVO selectStudentDetail(String sId);
|
||||||
* 查询学生信息列表
|
|
||||||
*
|
|
||||||
* @param trainStudent 学生信息
|
|
||||||
* @return 学生信息集合
|
|
||||||
*/
|
|
||||||
public List<TrainStudent> selectTrainStudentList(TrainStudent trainStudent);
|
|
||||||
|
|
||||||
/**
|
String createStudent(StudentCreateDTO dto);
|
||||||
* 新增学生信息
|
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
}
|
}
|
||||||
+189
-65
@@ -1,96 +1,220 @@
|
|||||||
package com.ruoyi.train.service.impl;
|
package com.ruoyi.train.service.impl;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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 com.ruoyi.common.utils.DateUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||||
|
import com.ruoyi.train.constant.StudentErrorCode;
|
||||||
import com.ruoyi.train.domain.TrainStudent;
|
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 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
|
@Service
|
||||||
public class TrainStudentServiceImpl implements ITrainStudentService
|
public class TrainStudentServiceImpl implements ITrainStudentService
|
||||||
{
|
{
|
||||||
@Autowired
|
private static final Pattern ID_CARD_PATTERN = Pattern.compile("^[0-9]{17}[0-9X]$");
|
||||||
private TrainStudentMapper trainStudentMapper;
|
private static final int MANUAL_SOURCE_TYPE = 1;
|
||||||
|
private static final int ENABLED_STATUS = 1;
|
||||||
|
|
||||||
/**
|
private final TrainStudentMapper trainStudentMapper;
|
||||||
* 查询学生信息
|
private final StudentIdentityProtection identityProtection;
|
||||||
*
|
|
||||||
* @param sId 学生信息主键
|
public TrainStudentServiceImpl(TrainStudentMapper trainStudentMapper,
|
||||||
* @return 学生信息
|
StudentIdentityProtection identityProtection)
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public TrainStudent selectTrainStudentBySId(String sId)
|
|
||||||
{
|
{
|
||||||
return trainStudentMapper.selectTrainStudentBySId(sId);
|
this.trainStudentMapper = trainStudentMapper;
|
||||||
|
this.identityProtection = identityProtection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询学生信息列表
|
|
||||||
*
|
|
||||||
* @param trainStudent 学生信息
|
|
||||||
* @return 学生信息
|
|
||||||
*/
|
|
||||||
@Override
|
@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
|
@Override
|
||||||
public int insertTrainStudent(TrainStudent trainStudent)
|
public StudentDetailVO selectStudentDetail(String sId)
|
||||||
{
|
{
|
||||||
trainStudent.setCreateTime(DateUtils.getNowDate());
|
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sId);
|
||||||
return trainStudentMapper.insertTrainStudent(trainStudent);
|
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
|
@Override
|
||||||
public int updateTrainStudent(TrainStudent trainStudent)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public String createStudent(StudentCreateDTO dto)
|
||||||
{
|
{
|
||||||
trainStudent.setUpdateTime(DateUtils.getNowDate());
|
validate(dto);
|
||||||
return trainStudentMapper.updateTrainStudent(trainStudent);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private StudentItemVO toItemVO(TrainStudent student)
|
||||||
* 批量删除学生信息
|
|
||||||
*
|
|
||||||
* @param sIds 需要删除的学生信息主键
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public int deleteTrainStudentBySIds(String[] sIds)
|
|
||||||
{
|
{
|
||||||
return trainStudentMapper.deleteTrainStudentBySIds(sIds);
|
StudentItemVO item = new StudentItemVO();
|
||||||
|
copyCommon(student, item);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void copyCommon(TrainStudent student, StudentItemVO target)
|
||||||
* 删除学生信息信息
|
|
||||||
*
|
|
||||||
* @param sId 学生信息主键
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public int deleteTrainStudentBySId(String sId)
|
|
||||||
{
|
{
|
||||||
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<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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,103 +3,77 @@
|
|||||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.ruoyi.train.mapper.TrainStudentMapper">
|
<mapper namespace="com.ruoyi.train.mapper.TrainStudentMapper">
|
||||||
|
|
||||||
<resultMap type="TrainStudent" id="TrainStudentResult">
|
<resultMap type="TrainStudent" id="TrainStudentResult">
|
||||||
<result property="sId" column="s_id" />
|
<result property="sId" column="s_id" />
|
||||||
<result property="sName" column="s_name" />
|
<result property="sName" column="s_name" />
|
||||||
<result property="sGender" column="s_gender" />
|
<result property="sGender" column="s_gender" />
|
||||||
<result property="sBirth" column="s_birth" />
|
<result property="sBirth" column="s_birth" />
|
||||||
<result property="sDepartment" column="s_department" />
|
<result property="userId" column="user_id" />
|
||||||
<result property="sMajor" column="s_major" />
|
<result property="orgName" column="org_name" />
|
||||||
<result property="sClass" column="s_class" />
|
<result property="sPhone" column="s_phone" />
|
||||||
<result property="sPhone" column="s_phone" />
|
<result property="sEmail" column="s_email" />
|
||||||
<result property="createUser" column="create_user" />
|
<result property="idCardCiphertext" column="id_card_ciphertext" />
|
||||||
<result property="createTime" column="create_time" />
|
<result property="idCardHash" column="id_card_hash" />
|
||||||
<result property="updateTime" column="update_time" />
|
<result property="idCardLast4" column="id_card_last4" />
|
||||||
<result property="remark" column="remark" />
|
<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>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="selectTrainStudentVo">
|
<sql id="selectTrainStudentDomain">
|
||||||
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
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="selectTrainStudentList" parameterType="TrainStudent" resultMap="TrainStudentResult">
|
<select id="selectTrainStudentList"
|
||||||
<include refid="selectTrainStudentVo"/>
|
parameterType="com.ruoyi.train.domain.query.StudentQuery"
|
||||||
<where>
|
resultMap="TrainStudentResult">
|
||||||
<if test="sName != null and sName != ''"> and s_name like concat('%', #{sName}, '%')</if>
|
<include refid="selectTrainStudentDomain" />
|
||||||
<if test="sGender != null and sGender != ''"> and s_gender = #{sGender}</if>
|
<where>
|
||||||
<if test="sBirth != null "> and s_birth = #{sBirth}</if>
|
del_flag = '0'
|
||||||
<if test="sDepartment != null and sDepartment != ''"> and s_department = #{sDepartment}</if>
|
<if test="sName != null and sName != ''">
|
||||||
<if test="sMajor != null and sMajor != ''"> and s_major = #{sMajor}</if>
|
and s_name like concat('%', #{sName}, '%')
|
||||||
<if test="sClass != null and sClass != ''"> and s_class = #{sClass}</if>
|
</if>
|
||||||
<if test="sPhone != null and sPhone != ''"> and s_phone = #{sPhone}</if>
|
<if test="orgName != null and orgName != ''">
|
||||||
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
|
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>
|
</where>
|
||||||
|
order by create_time desc, s_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectTrainStudentBySId" parameterType="String" resultMap="TrainStudentResult">
|
<select id="selectTrainStudentBySId" parameterType="String" resultMap="TrainStudentResult">
|
||||||
<include refid="selectTrainStudentVo"/>
|
<include refid="selectTrainStudentDomain" />
|
||||||
where s_id = #{sId}
|
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>
|
</select>
|
||||||
|
|
||||||
<insert id="insertTrainStudent" parameterType="TrainStudent">
|
<insert id="insertTrainStudent" parameterType="TrainStudent">
|
||||||
insert into train_student
|
insert into train_student (
|
||||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
s_id, s_name, s_gender, s_birth, user_id, org_name, s_phone, s_email,
|
||||||
<if test="sId != null">s_id,</if>
|
id_card_ciphertext, id_card_hash, id_card_last4, face_ref, source_type,
|
||||||
<if test="sName != null and sName != ''">s_name,</if>
|
status, create_by, create_time, update_by, update_time, remark, del_flag
|
||||||
<if test="sGender != null">s_gender,</if>
|
) values (
|
||||||
<if test="sBirth != null">s_birth,</if>
|
#{sId}, #{sName}, #{sGender}, #{sBirth}, #{userId}, #{orgName}, #{sPhone}, #{sEmail},
|
||||||
<if test="sDepartment != null and sDepartment != ''">s_department,</if>
|
#{idCardCiphertext}, #{idCardHash}, #{idCardLast4}, #{faceRef}, #{sourceType},
|
||||||
<if test="sMajor != null and sMajor != ''">s_major,</if>
|
#{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag}
|
||||||
<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>
|
</insert>
|
||||||
|
</mapper>
|
||||||
<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>
|
|
||||||
+80
@@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+292
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
// 查询学生信息列表
|
// 查询学员主档列表
|
||||||
export function listStudent(query) {
|
export function listStudent(query) {
|
||||||
return request({
|
return request({
|
||||||
url: '/train/student/list',
|
url: '/train/student/list',
|
||||||
@@ -9,7 +9,7 @@ export function listStudent(query) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询学生信息详细
|
// 查询学员主档详情(身份证默认脱敏)
|
||||||
export function getStudent(sId) {
|
export function getStudent(sId) {
|
||||||
return request({
|
return request({
|
||||||
url: '/train/student/' + sId,
|
url: '/train/student/' + sId,
|
||||||
@@ -17,7 +17,7 @@ export function getStudent(sId) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增学生信息
|
// 新增学员主档
|
||||||
export function addStudent(data) {
|
export function addStudent(data) {
|
||||||
return request({
|
return request({
|
||||||
url: '/train/student',
|
url: '/train/student',
|
||||||
@@ -25,20 +25,3 @@ export function addStudent(data) {
|
|||||||
data: 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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
<el-form
|
||||||
<el-form-item label="学生姓名" prop="sName">
|
v-show="showSearch"
|
||||||
|
ref="queryForm"
|
||||||
|
:model="queryParams"
|
||||||
|
size="small"
|
||||||
|
:inline="true"
|
||||||
|
label-width="68px"
|
||||||
|
>
|
||||||
|
<el-form-item label="学员姓名" prop="sName">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.sName"
|
v-model="queryParams.sName"
|
||||||
placeholder="请输入学生姓名"
|
placeholder="请输入学员姓名"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter.native="handleQuery"
|
@keyup.enter.native="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="性别:男 / 女" prop="sGender">
|
<el-form-item label="所属单位" prop="orgName">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.sGender"
|
v-model="queryParams.orgName"
|
||||||
placeholder="请输入性别:男 / 女"
|
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="请输入班级"
|
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter.native="handleQuery"
|
@keyup.enter.native="handleQuery"
|
||||||
/>
|
/>
|
||||||
@@ -57,13 +32,11 @@
|
|||||||
@keyup.enter.native="handleQuery"
|
@keyup.enter.native="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-input
|
<el-select v-model="queryParams.status" placeholder="学员状态" clearable>
|
||||||
v-model="queryParams.createUser"
|
<el-option label="正常" :value="1" />
|
||||||
placeholder="请输入创建人 (管理员 / 教师)"
|
<el-option label="停用" :value="0" />
|
||||||
clearable
|
</el-select>
|
||||||
@keyup.enter.native="handleQuery"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||||
@@ -74,298 +47,254 @@
|
|||||||
<el-row :gutter="10" class="mb8">
|
<el-row :gutter="10" class="mb8">
|
||||||
<el-col :span="1.5">
|
<el-col :span="1.5">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-hasPermi="['train:student:add']"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
icon="el-icon-plus"
|
icon="el-icon-plus"
|
||||||
size="mini"
|
size="mini"
|
||||||
@click="handleAdd"
|
@click="handleAdd"
|
||||||
v-hasPermi="['train:student:add']"
|
|
||||||
>新增</el-button>
|
>新增</el-button>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="1.5">
|
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||||
<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>
|
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="studentList" @selection-change="handleSelectionChange">
|
<el-table v-loading="loading" :data="studentList">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column label="学员编号" align="center" prop="sId" min-width="160" />
|
||||||
<el-table-column label="学生学号" align="center" prop="sId" />
|
<el-table-column label="学员姓名" align="center" prop="sName" min-width="100" />
|
||||||
<el-table-column label="学生姓名" align="center" prop="sName" />
|
<el-table-column label="性别" align="center" prop="sGender" width="70" />
|
||||||
<el-table-column label="性别:男 / 女" align="center" prop="sGender" />
|
<el-table-column label="出生日期" align="center" prop="sBirth" width="110">
|
||||||
<el-table-column label="出生日期" align="center" prop="sBirth" width="180">
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ parseTime(scope.row.sBirth, '{y}-{m}-{d}') }}</span>
|
<span>{{ parseTime(scope.row.sBirth, '{y}-{m}-{d}') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="所属院系" align="center" prop="sDepartment" />
|
<el-table-column label="所属单位" align="center" prop="orgName" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column label="专业" align="center" prop="sMajor" />
|
<el-table-column label="联系电话" align="center" prop="sPhone" width="130" />
|
||||||
<el-table-column label="班级" align="center" prop="sClass" />
|
<el-table-column label="邮箱" align="center" prop="sEmail" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="联系电话" align="center" prop="sPhone" />
|
<el-table-column label="身份证号" align="center" prop="idCardMasked" min-width="190" />
|
||||||
<el-table-column label="创建人 (管理员 / 教师)" align="center" prop="createUser" />
|
<el-table-column label="来源" align="center" prop="sourceType" width="100">
|
||||||
<el-table-column label="备注" align="center" prop="remark" />
|
<template slot-scope="scope">
|
||||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
<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">
|
<template slot-scope="scope">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-hasPermi="['train:student:query']"
|
||||||
size="mini"
|
size="mini"
|
||||||
type="text"
|
type="text"
|
||||||
icon="el-icon-edit"
|
icon="el-icon-view"
|
||||||
@click="handleUpdate(scope.row)"
|
@click="handleDetail(scope.row)"
|
||||||
v-hasPermi="['train:student:edit']"
|
>详情</el-button>
|
||||||
>修改</el-button>
|
|
||||||
<el-button
|
|
||||||
size="mini"
|
|
||||||
type="text"
|
|
||||||
icon="el-icon-delete"
|
|
||||||
@click="handleDelete(scope.row)"
|
|
||||||
v-hasPermi="['train:student:remove']"
|
|
||||||
>删除</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<pagination
|
<pagination
|
||||||
v-show="total>0"
|
v-show="total > 0"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page.sync="queryParams.pageNum"
|
:page.sync="queryParams.pageNum"
|
||||||
:limit.sync="queryParams.pageSize"
|
:limit.sync="queryParams.pageSize"
|
||||||
@pagination="getList"
|
@pagination="getList"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 添加或修改学生信息对话框 -->
|
<el-dialog title="新增学员主档" :visible.sync="createOpen" width="560px" append-to-body @closed="resetCreate">
|
||||||
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
|
<el-form ref="createForm" :model="createForm" :rules="rules" label-width="90px">
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
<el-form-item label="学员姓名" prop="sName">
|
||||||
<el-form-item label="学生姓名" prop="sName">
|
<el-input v-model="createForm.sName" placeholder="请输入学员姓名" maxlength="50" show-word-limit />
|
||||||
<el-input v-model="form.sName" placeholder="请输入学生姓名" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="性别:男 / 女" prop="sGender">
|
<el-form-item label="性别" prop="sGender">
|
||||||
<el-input v-model="form.sGender" placeholder="请输入性别:男 / 女" />
|
<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>
|
||||||
<el-form-item label="出生日期" prop="sBirth">
|
<el-form-item label="出生日期" prop="sBirth">
|
||||||
<el-date-picker clearable
|
<el-date-picker
|
||||||
v-model="form.sBirth"
|
v-model="createForm.sBirth"
|
||||||
|
clearable
|
||||||
type="date"
|
type="date"
|
||||||
value-format="yyyy-MM-dd"
|
value-format="yyyy-MM-dd"
|
||||||
placeholder="请选择出生日期">
|
placeholder="请选择出生日期"
|
||||||
</el-date-picker>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属院系" prop="sDepartment">
|
<el-form-item label="所属单位" prop="orgName">
|
||||||
<el-input v-model="form.sDepartment" placeholder="请输入所属院系" />
|
<el-input v-model="createForm.orgName" placeholder="请输入所属单位/部门" maxlength="100" />
|
||||||
</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>
|
</el-form-item>
|
||||||
<el-form-item label="联系电话" prop="sPhone">
|
<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>
|
||||||
<el-form-item label="创建人 (管理员 / 教师)" prop="createUser">
|
<el-form-item label="邮箱" prop="sEmail">
|
||||||
<el-input v-model="form.createUser" placeholder="请输入创建人 (管理员 / 教师)" />
|
<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>
|
||||||
<el-form-item label="备注" prop="remark">
|
<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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div slot="footer" class="dialog-footer">
|
<div slot="footer" class="dialog-footer">
|
||||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
<el-button type="primary" :loading="submitting" @click="submitCreate">确 定</el-button>
|
||||||
<el-button @click="cancel">取 消</el-button>
|
<el-button @click="createOpen = false">取 消</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { listStudent, getStudent, delStudent, addStudent, updateStudent } from "@/api/train/student"
|
import { addStudent, getStudent, listStudent } from '@/api/train/student'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Student",
|
name: 'Student',
|
||||||
data() {
|
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 {
|
return {
|
||||||
// 遮罩层
|
|
||||||
loading: true,
|
loading: true,
|
||||||
// 选中数组
|
submitting: false,
|
||||||
ids: [],
|
|
||||||
// 非单个禁用
|
|
||||||
single: true,
|
|
||||||
// 非多个禁用
|
|
||||||
multiple: true,
|
|
||||||
// 显示搜索条件
|
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
// 总条数
|
|
||||||
total: 0,
|
total: 0,
|
||||||
// 学生信息表格数据
|
|
||||||
studentList: [],
|
studentList: [],
|
||||||
// 弹出层标题
|
createOpen: false,
|
||||||
title: "",
|
detailOpen: false,
|
||||||
// 是否显示弹出层
|
detail: {},
|
||||||
open: false,
|
|
||||||
// 查询参数
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
sName: null,
|
sName: null,
|
||||||
sGender: null,
|
orgName: null,
|
||||||
sBirth: null,
|
|
||||||
sDepartment: null,
|
|
||||||
sMajor: null,
|
|
||||||
sClass: null,
|
|
||||||
sPhone: null,
|
sPhone: null,
|
||||||
createUser: null,
|
status: null
|
||||||
},
|
},
|
||||||
// 表单参数
|
createForm: {},
|
||||||
form: {},
|
|
||||||
// 表单校验
|
|
||||||
rules: {
|
rules: {
|
||||||
sName: [
|
sName: [
|
||||||
{ required: true, message: "学生姓名不能为空", trigger: "blur" }
|
{ required: true, message: '学员姓名不能为空', trigger: 'blur' },
|
||||||
|
{ max: 50, message: '学员姓名长度不能超过50个字符', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
sDepartment: [
|
sEmail: [
|
||||||
{ required: true, message: "所属院系不能为空", trigger: "blur" }
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
|
||||||
],
|
|
||||||
sMajor: [
|
|
||||||
{ required: true, message: "专业不能为空", trigger: "blur" }
|
|
||||||
],
|
|
||||||
createUser: [
|
|
||||||
{ required: true, message: "创建人 (管理员 / 教师)不能为空", trigger: "blur" }
|
|
||||||
],
|
],
|
||||||
|
idCard: [
|
||||||
|
{ validator: validateIdCard, trigger: 'blur' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
this.resetCreate()
|
||||||
this.getList()
|
this.getList()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
/** 查询学生信息列表 */
|
|
||||||
getList() {
|
getList() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
listStudent(this.queryParams).then(response => {
|
listStudent(this.queryParams).then(response => {
|
||||||
this.studentList = response.rows
|
this.studentList = response.rows
|
||||||
this.total = response.total
|
this.total = response.total
|
||||||
|
}).finally(() => {
|
||||||
this.loading = false
|
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() {
|
handleQuery() {
|
||||||
this.queryParams.pageNum = 1
|
this.queryParams.pageNum = 1
|
||||||
this.getList()
|
this.getList()
|
||||||
},
|
},
|
||||||
/** 重置按钮操作 */
|
|
||||||
resetQuery() {
|
resetQuery() {
|
||||||
this.resetForm("queryForm")
|
this.resetForm('queryForm')
|
||||||
this.handleQuery()
|
this.handleQuery()
|
||||||
},
|
},
|
||||||
// 多选框选中数据
|
|
||||||
handleSelectionChange(selection) {
|
|
||||||
this.ids = selection.map(item => item.sId)
|
|
||||||
this.single = selection.length!==1
|
|
||||||
this.multiple = !selection.length
|
|
||||||
},
|
|
||||||
/** 新增按钮操作 */
|
|
||||||
handleAdd() {
|
handleAdd() {
|
||||||
this.reset()
|
this.resetCreate()
|
||||||
this.open = true
|
this.createOpen = true
|
||||||
this.title = "添加学生信息"
|
|
||||||
},
|
},
|
||||||
/** 修改按钮操作 */
|
resetCreate() {
|
||||||
handleUpdate(row) {
|
this.createForm = {
|
||||||
this.reset()
|
sName: null,
|
||||||
const sId = row.sId || this.ids
|
sGender: '男',
|
||||||
getStudent(sId).then(response => {
|
sBirth: null,
|
||||||
this.form = response.data
|
orgName: null,
|
||||||
this.open = true
|
sPhone: null,
|
||||||
this.title = "修改学生信息"
|
sEmail: null,
|
||||||
})
|
idCard: null,
|
||||||
|
faceRef: null,
|
||||||
|
remark: null
|
||||||
|
}
|
||||||
|
if (this.$refs.createForm) {
|
||||||
|
this.resetForm('createForm')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
/** 提交按钮 */
|
submitCreate() {
|
||||||
submitForm() {
|
this.$refs.createForm.validate(valid => {
|
||||||
this.$refs["form"].validate(valid => {
|
if (!valid) {
|
||||||
if (valid) {
|
return
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.submitting = true
|
||||||
|
addStudent(this.createForm).then(() => {
|
||||||
|
this.$modal.msgSuccess('新增成功')
|
||||||
|
this.createOpen = false
|
||||||
|
this.getList()
|
||||||
|
}).finally(() => {
|
||||||
|
this.submitting = false
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
/** 删除按钮操作 */
|
handleDetail(row) {
|
||||||
handleDelete(row) {
|
getStudent(row.sId).then(response => {
|
||||||
const sIds = row.sId || this.ids
|
this.detail = response.data || {}
|
||||||
this.$modal.confirm('是否确认删除学生信息编号为"' + sIds + '"的数据项?').then(function() {
|
this.detailOpen = true
|
||||||
return delStudent(sIds)
|
})
|
||||||
}).then(() => {
|
|
||||||
this.getList()
|
|
||||||
this.$modal.msgSuccess("删除成功")
|
|
||||||
}).catch(() => {})
|
|
||||||
},
|
},
|
||||||
/** 导出按钮操作 */
|
sourceTypeLabel(sourceType) {
|
||||||
handleExport() {
|
const labels = {
|
||||||
this.download('train/student/export', {
|
1: '管理员录入',
|
||||||
...this.queryParams
|
2: 'Excel导入',
|
||||||
}, `student_${new Date().getTime()}.xlsx`)
|
3: '平台同步'
|
||||||
|
}
|
||||||
|
return labels[sourceType] || '-'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-detail ::v-deep .el-form-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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` -> 成功通过
|
||||||
Reference in new issue
Block a user