新增学员:支持零业务引用档案安全删除与引用保护

This commit is contained in:
wangzhibiao committed 2026-09-20 16:16:58 +08:00
1 parent 2dc22b06f8
commit 225f58481a
13 files changed
+469 -5

No files matched your search

@@ -6,6 +6,7 @@ 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.DeleteMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -74,4 +75,12 @@ public class TrainStudentController extends BaseController
{
return success(trainStudentService.createStudent(dto));
}
@PreAuthorize("@ss.hasPermi('train:student:remove')")
@Log(title = "学员主档", businessType = BusinessType.DELETE)
@DeleteMapping("/{sId}")
public AjaxResult remove(@PathVariable("sId") String sId)
{
return toAjax(trainStudentService.deleteTrainStudentBySId(sId));
}
}
@@ -23,16 +23,21 @@ public class StudentApiTest
Method list = TrainStudentController.class.getMethod("list", StudentQuery.class);
Method get = TrainStudentController.class.getMethod("getInfo", String.class);
Method add = TrainStudentController.class.getMethod("add", StudentCreateDTO.class);
Method remove = TrainStudentController.class.getMethod("remove", String.class);
assertEquals("/list", list.getAnnotation(GetMapping.class).value()[0]);
assertEquals("/{sId}", get.getAnnotation(GetMapping.class).value()[0]);
assertNotNull(add.getAnnotation(PostMapping.class));
assertEquals("/{sId}", remove.getAnnotation(org.springframework.web.bind.annotation.DeleteMapping.class).value()[0]);
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());
assertEquals("@ss.hasPermi('train:student:remove')",
remove.getAnnotation(PreAuthorize.class).value());
assertFalse(add.getAnnotation(Log.class).isSaveRequestData());
}
@@ -66,12 +71,16 @@ public class StudentApiTest
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"));
if (method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class))
{
assertEquals("remove", method.getName());
assertEquals("@ss.hasPermi('train:student:remove')",
method.getAnnotation(PreAuthorize.class).value());
}
}
}
assertEquals("Controller必须严格只有3个P0端点", 3, endpointCount);
assertEquals("Controller包含经过授权的4个端点(list, getInfo, add, remove)", 4, endpointCount);
}
}
@@ -179,4 +179,20 @@ public interface TrainEnrollmentMapper
* @return 参训记录列表
*/
List<EnrollmentItemVO> selectEnrollmentsByStudentId(@Param("studentId") String studentId);
/**
* 统计指定学员的参训记录数 (train_enrollment)。
*
* @param studentId 学员ID
* @return 参训记录数
*/
int countEnrollmentsByStudentId(@Param("studentId") String studentId);
/**
* 统计指定学员下游业务事实总记录数(考勤签到、成绩、报到、住宿、自主学习等)。
*
* @param studentId 学员ID
* @return 业务记录数
*/
int countActiveBusinessByStudentId(@Param("studentId") String studentId);
}
@@ -50,4 +50,12 @@ public interface TrainStudentMapper
* @return 影响行数
*/
int unbindUserId(@org.apache.ibatis.annotations.Param("sId") String sId);
/**
* 物理删除无业务关联且未绑定账号的学员档案
*
* @param sId 学员ID
* @return 影响行数
*/
int deleteTrainStudentBySId(@org.apache.ibatis.annotations.Param("sId") String sId);
}
@@ -14,4 +14,12 @@ public interface ITrainStudentService
StudentDetailVO selectStudentDetail(String sId);
String createStudent(StudentCreateDTO dto);
/**
* 安全删除学员档案(ZERO-REFERENCE SAFE DELETE)
*
* @param sId 学员主档ID
* @return 影响行数
*/
int deleteTrainStudentBySId(String sId);
}
@@ -16,9 +16,11 @@ 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.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.security.StudentIdentityProtection;
import com.ruoyi.train.service.ITrainStudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -33,6 +35,7 @@ public class TrainStudentServiceImpl implements ITrainStudentService
private final TrainStudentMapper trainStudentMapper;
private final StudentIdentityProtection identityProtection;
private TrainEnrollmentMapper trainEnrollmentMapper;
public TrainStudentServiceImpl(TrainStudentMapper trainStudentMapper,
StudentIdentityProtection identityProtection)
@@ -41,6 +44,16 @@ public class TrainStudentServiceImpl implements ITrainStudentService
this.identityProtection = identityProtection;
}
@Autowired
public TrainStudentServiceImpl(TrainStudentMapper trainStudentMapper,
StudentIdentityProtection identityProtection,
@Autowired(required = false) TrainEnrollmentMapper trainEnrollmentMapper)
{
this.trainStudentMapper = trainStudentMapper;
this.identityProtection = identityProtection;
this.trainEnrollmentMapper = trainEnrollmentMapper;
}
@Override
public List<StudentItemVO> selectStudentList(StudentQuery query)
{
@@ -217,4 +230,56 @@ public class TrainStudentServiceImpl implements ITrainStudentService
return new ServiceException(StudentErrorCode.STUDENT_IDCARD_DUPLICATED_MESSAGE,
StudentErrorCode.STUDENT_IDCARD_DUPLICATED);
}
/**
* 安全删除学员主档(ZERO-REFERENCE SAFE DELETE)
* 只有同时满足:
* A. 无任何历史业务引用(参训记录、考勤、成绩、报到、住宿、自主学习等)
* B. 无有效系统账号绑定
* 才允许物理删除;否则强阻断并提示明确原因。
*
* @param sId 学员主档ID
* @return 影响行数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteTrainStudentBySId(String sId)
{
if (StringUtils.isEmpty(sId))
{
throw new ServiceException("学员ID不能为空");
}
// 1. 查验学员主档是否存在
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sId);
if (student == null)
{
throw new ServiceException("学员不存在", 40401);
}
// 2. 检查是否已绑定系统账号
if (student.getUserId() != null)
{
throw new ServiceException("该学员已绑定系统账号,请先解除账号绑定后再删除。");
}
// 3. 检查下游具体业务记录(住宿、考勤、成绩、报到、自主学习等)
if (trainEnrollmentMapper != null)
{
int businessCount = trainEnrollmentMapper.countActiveBusinessByStudentId(sId);
if (businessCount > 0)
{
throw new ServiceException("该学员已产生住宿/考勤/成绩等业务数据,无法删除。");
}
// 4. 检查是否已存在参训记录
int enrollmentCount = trainEnrollmentMapper.countEnrollmentsByStudentId(sId);
if (enrollmentCount > 0)
{
throw new ServiceException("该学员已存在参训记录,无法删除。");
}
}
// 5. 执行零引用物理删除
return trainStudentMapper.deleteTrainStudentBySId(sId);
}
}
@@ -201,4 +201,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by e.create_time desc
</select>
<!-- 统计指定学员的参训记录数 -->
<select id="countEnrollmentsByStudentId" parameterType="String" resultType="int">
select count(1) from train_enrollment where student_id = #{studentId}
</select>
<!-- 统计指定学员下游业务事实总记录数(考勤打卡、成绩、报到、住宿、门禁授权、自主学习) -->
<select id="countActiveBusinessByStudentId" parameterType="String" resultType="int">
select (
(select count(1) from train_student_checkin c join train_enrollment e on c.enrollment_id = e.enrollment_id where e.student_id = #{studentId}) +
(select count(1) from train_student_score s join train_enrollment e on s.enrollment_id = e.enrollment_id where e.student_id = #{studentId}) +
(select count(1) from train_class_report r join train_enrollment e on r.enrollment_id = e.enrollment_id where e.student_id = #{studentId}) +
(select count(1) from train_accommodation a join train_enrollment e on a.enrollment_id = e.enrollment_id where e.student_id = #{studentId}) +
(select count(1) from train_dorm_auth auth join train_accommodation a on auth.accommodation_id = a.accommodation_id join train_enrollment e on a.enrollment_id = e.enrollment_id where e.student_id = #{studentId}) +
(select count(1) from train_study_duration d join train_enrollment e on d.enrollment_id = e.enrollment_id where e.student_id = #{studentId})
)
</select>
</mapper>
@@ -106,4 +106,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="unbindUserId">
update train_student set user_id = null, update_time = CURRENT_TIMESTAMP where s_id = #{sId}
</update>
<!-- 物理删除无业务关联且未绑定账号的学员档案 -->
<delete id="deleteTrainStudentBySId" parameterType="String">
delete from train_student where s_id = #{sId}
</delete>
</mapper>
@@ -1001,6 +1001,12 @@ public class EnrollmentAppServiceImplTest
{
return 0;
}
@Override
public int deleteTrainStudentBySId(String sId)
{
return studentStore.remove(sId) != null ? 1 : 0;
}
}
private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper
@@ -1138,5 +1144,17 @@ public class EnrollmentAppServiceImplTest
{
return Collections.emptyList();
}
@Override
public int countEnrollmentsByStudentId(String studentId)
{
return 0;
}
@Override
public int countActiveBusinessByStudentId(String studentId)
{
return 0;
}
}
}
@@ -0,0 +1,276 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.security.JceStudentIdentityProtection;
import com.ruoyi.train.security.StudentIdentityProtection;
/**
* 学员零引用安全删除测试与契约防护 (StudentZeroReferenceSafeDeleteTest)
*
* 验证规则:
* 1. 零引用 + 无账号绑定 -> 删除成功
* 2. 有账号绑定 -> 强阻断,提示解绑,严禁级联删除 sys_user
* 3. 有参训记录 (Enrollment) -> 强阻断
* 4. 有考勤/报到等业务记录 -> 强阻断
* 5. 有住宿/门禁授权记录 -> 强阻断
* 6. 有成绩/自主学习记录 -> 强阻断
* 7. 契约保证具备 @Transactional 注解,未注入 sys_user 删除依赖,零孤儿风险
*/
public class StudentZeroReferenceSafeDeleteTest
{
private StudentIdentityProtection protection;
private Map<String, TrainStudent> studentStore;
private int mockEnrollmentCount;
private int mockActiveBusinessCount;
private int deletedStudentCount;
private TrainStudentServiceImpl studentService;
@Before
public void setUp()
{
byte[] encKey = new byte[32];
byte[] hashKey = new byte[32];
Arrays.fill(encKey, (byte) 0x33);
Arrays.fill(hashKey, (byte) 0x77);
protection = new JceStudentIdentityProtection(
Base64.getEncoder().encodeToString(encKey),
Base64.getEncoder().encodeToString(hashKey)
);
studentStore = new HashMap<>();
mockEnrollmentCount = 0;
mockActiveBusinessCount = 0;
deletedStudentCount = 0;
TrainStudentMapper studentMapperProxy = (TrainStudentMapper) Proxy.newProxyInstance(
TrainStudentMapper.class.getClassLoader(),
new Class<?>[]{ TrainStudentMapper.class },
(proxy, method, args) -> {
String methodName = method.getName();
if ("selectTrainStudentBySId".equals(methodName))
{
return studentStore.get((String) args[0]);
}
else if ("deleteTrainStudentBySId".equals(methodName))
{
String sId = (String) args[0];
if (studentStore.remove(sId) != null)
{
deletedStudentCount++;
return 1;
}
return 0;
}
return null;
}
);
TrainEnrollmentMapper enrollmentMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance(
TrainEnrollmentMapper.class.getClassLoader(),
new Class<?>[]{ TrainEnrollmentMapper.class },
(proxy, method, args) -> {
String methodName = method.getName();
if ("countEnrollmentsByStudentId".equals(methodName))
{
return mockEnrollmentCount;
}
else if ("countActiveBusinessByStudentId".equals(methodName))
{
return mockActiveBusinessCount;
}
return 0;
}
);
studentService = new TrainStudentServiceImpl(studentMapperProxy, protection, enrollmentMapperProxy);
}
@Test
public void testStudentZeroReferenceSuccess()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_001");
student.setsName("小明");
student.setUserId(null);
studentStore.put("S_MOCK_001", student);
mockEnrollmentCount = 0;
mockActiveBusinessCount = 0;
int rows = studentService.deleteTrainStudentBySId("S_MOCK_001");
assertEquals("零引用学员应删除成功", 1, rows);
assertEquals(1, deletedStudentCount);
assertFalse("学员主档应已从存储中物理移除", studentStore.containsKey("S_MOCK_001"));
}
@Test
public void testStudentWithAccountBindingBlocked()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_002");
student.setsName("小红");
student.setUserId(2001L);
studentStore.put("S_MOCK_002", student);
mockEnrollmentCount = 0;
mockActiveBusinessCount = 0;
try
{
studentService.deleteTrainStudentBySId("S_MOCK_002");
fail("已绑定系统账号的学员应禁止删除");
}
catch (ServiceException e)
{
assertTrue("提示文案应明确指出解绑要求", e.getMessage().contains("该学员已绑定系统账号,请先解除账号绑定后再删除。"));
assertEquals(0, deletedStudentCount);
assertTrue("学员主档不得被物理删除", studentStore.containsKey("S_MOCK_002"));
}
}
@Test
public void testStudentWithEnrollmentBlocked()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_003");
student.setsName("小华");
student.setUserId(null);
studentStore.put("S_MOCK_003", student);
mockEnrollmentCount = 1;
mockActiveBusinessCount = 0;
try
{
studentService.deleteTrainStudentBySId("S_MOCK_003");
fail("存在参训记录的学员应禁止删除");
}
catch (ServiceException e)
{
assertTrue("提示文案应明确指出参训记录阻断", e.getMessage().contains("该学员已存在参训记录,无法删除。"));
assertEquals(0, deletedStudentCount);
assertTrue("学员主档不得被物理删除", studentStore.containsKey("S_MOCK_003"));
}
}
@Test
public void testStudentWithActiveBusinessBlocked_AttendanceOrReport()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_004");
student.setsName("小强");
student.setUserId(null);
studentStore.put("S_MOCK_004", student);
mockEnrollmentCount = 1;
mockActiveBusinessCount = 2;
try
{
studentService.deleteTrainStudentBySId("S_MOCK_004");
fail("存在考勤打卡或报到的学员应禁止删除");
}
catch (ServiceException e)
{
assertTrue("提示文案应指出业务数据阻断", e.getMessage().contains("该学员已产生住宿/考勤/成绩等业务数据,无法删除。"));
assertEquals(0, deletedStudentCount);
assertTrue("学员主档不得被物理删除", studentStore.containsKey("S_MOCK_004"));
}
}
@Test
public void testStudentWithActiveBusinessBlocked_AccommodationOrDoorAuth()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_005");
student.setsName("小刚");
student.setUserId(null);
studentStore.put("S_MOCK_005", student);
mockEnrollmentCount = 1;
mockActiveBusinessCount = 1;
try
{
studentService.deleteTrainStudentBySId("S_MOCK_005");
fail("存在住宿或门禁授权的学员应禁止删除");
}
catch (ServiceException e)
{
assertTrue("提示文案应指出业务数据阻断", e.getMessage().contains("该学员已产生住宿/考勤/成绩等业务数据,无法删除。"));
assertEquals(0, deletedStudentCount);
assertTrue("学员主档不得被物理删除", studentStore.containsKey("S_MOCK_005"));
}
}
@Test
public void testStudentWithActiveBusinessBlocked_ScoreOrStudyDuration()
{
TrainStudent student = new TrainStudent();
student.setsId("S_MOCK_006");
student.setsName("小芳");
student.setUserId(null);
studentStore.put("S_MOCK_006", student);
mockEnrollmentCount = 1;
mockActiveBusinessCount = 1;
try
{
studentService.deleteTrainStudentBySId("S_MOCK_006");
fail("存在成绩或自主学时记录的学员应禁止删除");
}
catch (ServiceException e)
{
assertTrue("提示文案应指出业务数据阻断", e.getMessage().contains("该学员已产生住宿/考勤/成绩等业务数据,无法删除。"));
assertEquals(0, deletedStudentCount);
assertTrue("学员主档不得被物理删除", studentStore.containsKey("S_MOCK_006"));
}
}
@Test
public void testContractNeverDeletesSysUser()
{
for (Field f : TrainStudentServiceImpl.class.getDeclaredFields())
{
assertFalse("TrainStudentServiceImpl不得注入系统用户删除组件: " + f.getName(),
f.getType().getSimpleName().contains("SysUser"));
}
}
@Test
public void testContractTransactionalAnnotationsPresent() throws Exception
{
Method studentDelMethod = TrainStudentServiceImpl.class.getMethod("deleteTrainStudentBySId", String.class);
Transactional studentTx = studentDelMethod.getAnnotation(Transactional.class);
assertNotNull("学员安全删除方法必须具备 @Transactional 注解", studentTx);
assertEquals(Exception.class, studentTx.rollbackFor()[0]);
}
@Test
public void testContractZeroOrphanRisk()
{
try
{
studentService.deleteTrainStudentBySId("S_NON_EXISTING");
fail("不存在的学员应返回404");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(40401), e.getCode());
}
}
}
@@ -330,5 +330,11 @@ public class TrainStudentServiceImplTest
{
return 0;
}
@Override
public int deleteTrainStudentBySId(String sId)
{
return store.remove(sId) != null ? 1 : 0;
}
}
}
+8
View File
@@ -51,3 +51,11 @@ export function unbindStudentUser(sId) {
method: 'delete'
})
}
// 删除学员主档
export function delStudent(sId) {
return request({
url: '/train/student/' + sId,
method: 'delete'
})
}
+21 -2
View File
@@ -98,7 +98,7 @@
<el-tag v-else type="info" size="mini">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="210">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:student:query']"
@@ -124,6 +124,14 @@
style="color: #F56C6C;"
@click="handleUnbind(scope.row)"
>解绑账号</el-button>
<el-button
v-hasPermi="['train:student:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -267,7 +275,7 @@
</template>
<script>
import { addStudent, getStudent, listStudent, getStudentBindableUsers, bindStudentUser, unbindStudentUser } from '@/api/train/student'
import { addStudent, getStudent, listStudent, delStudent, getStudentBindableUsers, bindStudentUser, unbindStudentUser } from '@/api/train/student'
export default {
name: 'Student',
@@ -440,6 +448,17 @@ export default {
this.getList()
})
},
/** 删除按钮操作 */
handleDelete(row) {
const sId = row.sId;
const sName = row.sName;
this.$modal.confirm(`确定删除学员【${sName} / ${sId}】吗?`).then(() => {
return delStudent(sId);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
sourceTypeLabel(sourceType) {
const labels = {
1: '管理员录入',