diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java index 799586378..8dfaf5bb0 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java @@ -7,6 +7,7 @@ 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; @@ -127,6 +128,17 @@ public class TrainTeacherController extends BaseController return toAjax(trainTeacherService.updateTrainTeacher(trainTeacher)); } + /** + * 删除教师信息 (DELETE /train/teacher/{tId}) + */ + @PreAuthorize("@ss.hasPermi('train:teacher:remove')") + @Log(title = "教师管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{tId}") + public AjaxResult remove(@PathVariable("tId") String tId) + { + return toAjax(trainTeacherService.deleteTrainTeacherByTId(tId)); + } + private boolean isValidPhone(String phone) { return PHONE_PATTERN.matcher(phone).matches(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java index 13686f494..902a819a0 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java @@ -150,4 +150,12 @@ public interface TrainLessonMapper * @return 课次引用数 */ int countLessonsByClassRoomId(@Param("classRoomId") String classRoomId); + + /** + * 统计指定教师在排课课次中的引用数(train_lesson)。 + * + * @param tId 教师工号 + * @return 课次引用数 + */ + int countLessonsByTeacherId(@Param("tId") String tId); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java index a4618769c..a911fdfee 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java @@ -52,13 +52,21 @@ public interface TrainTeacherMapper public TrainTeacher checkPhoneUnique(String tPhone); /** - * 删除教师信息 + * 删除教师信息 (逻辑删除) * * @param tId 教师信息主键 * @return 结果 */ public int deleteTrainTeacherByTId(String tId); + /** + * 物理删除无业务关联且未绑定账号的教师档案 + * + * @param tId 教师信息主键 + * @return 影响行数 + */ + public int deleteTrainTeacherRealByTId(String tId); + /** * 批量删除教师信息 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java index 5fbad20af..3430ed9e5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainTeacherServiceImpl.java @@ -8,6 +8,8 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.uuid.IdUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.train.mapper.TrainLessonMapper; import com.ruoyi.train.mapper.TrainTeacherMapper; import com.ruoyi.train.domain.TrainTeacher; import com.ruoyi.train.service.ITrainTeacherService; @@ -24,6 +26,19 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService @Autowired private TrainTeacherMapper trainTeacherMapper; + @Autowired + private TrainLessonMapper trainLessonMapper; + + public TrainTeacherServiceImpl() + { + } + + public TrainTeacherServiceImpl(TrainTeacherMapper trainTeacherMapper, TrainLessonMapper trainLessonMapper) + { + this.trainTeacherMapper = trainTeacherMapper; + this.trainLessonMapper = trainLessonMapper; + } + /** * 查询教师信息 * @@ -165,26 +180,65 @@ public class TrainTeacherServiceImpl implements ITrainTeacherService } /** - * 批量删除教师信息(逻辑删除) + * 批量安全删除教师信息 * * @param tIds 需要删除的教师信息主键 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int deleteTrainTeacherByTIds(String[] tIds) { - return trainTeacherMapper.deleteTrainTeacherByTIds(tIds); + if (tIds == null || tIds.length == 0) + { + return 0; + } + for (String tId : tIds) + { + deleteTrainTeacherByTId(tId); + } + return tIds.length; } /** - * 删除教师信息(逻辑删除) + * 安全删除教师档案(ZERO-REFERENCE SAFE DELETE) + * 只有同时满足: + * A. 无任何业务引用(排课/授课课次) + * B. 无有效系统账号绑定 + * 才允许物理删除;否则强阻断并提示明确原因。 * * @param tId 教师信息主键 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int deleteTrainTeacherByTId(String tId) { - return trainTeacherMapper.deleteTrainTeacherByTId(tId); + if (StringUtils.isEmpty(tId)) + { + throw new ServiceException("教师工号不能为空"); + } + // 1. 查验教师档案是否存在 + TrainTeacher teacher = trainTeacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师不存在", 40401); + } + + // 2. 检查是否已绑定系统账号 + if (teacher.getUserId() != null) + { + throw new ServiceException("该教师已绑定系统账号,请先解除账号绑定后再删除。"); + } + + // 3. 检查是否存在排课/授课业务记录 + int lessonCount = trainLessonMapper.countLessonsByTeacherId(tId); + if (lessonCount > 0) + { + throw new ServiceException("该教师已存在授课或排课记录,无法删除,可将教师状态调整为停用。"); + } + + // 4. 执行零引用物理删除 + return trainTeacherMapper.deleteTrainTeacherRealByTId(tId); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml index 526a2ad8a..9e9044250 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml @@ -390,4 +390,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select count(1) from train_lesson where class_room_id = #{classRoomId} + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml index e04f7d89e..ba626c0e4 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml @@ -117,6 +117,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update train_teacher set del_flag = '2' where t_id = #{tId} + + + delete from train_teacher where t_id = #{tId} + + update train_teacher set del_flag = '2' where t_id in diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java index 01f409dd9..4012bc450 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java @@ -1073,6 +1073,20 @@ public class SchedulingAppServiceTest { return 0; } + + @Override + public int countLessonsByTeacherId(String tId) + { + int count = 0; + for (TrainLesson l : lessons.values()) + { + if (tId != null && tId.equals(l.getTId())) + { + count++; + } + } + return count; + } } /** diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TeacherZeroReferenceSafeDeleteTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TeacherZeroReferenceSafeDeleteTest.java new file mode 100644 index 000000000..9f0c3a81b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TeacherZeroReferenceSafeDeleteTest.java @@ -0,0 +1,214 @@ +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.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.TrainTeacher; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; + +/** + * 教师零引用安全删除测试与契约防护 (TeacherZeroReferenceSafeDeleteTest) + * + * 验证规则: + * 1. 零引用 + 无账号绑定 -> 删除成功 + * 2. 有账号绑定 -> 强阻断,提示解绑,严禁级联删除 sys_user + * 3. 有排课/授课引用 -> 强阻断,引导调整为停用状态 + * 4. 历史教师 -> 不可删除,支持 status=0 停用 + * 5. 契约保证具备 @Transactional 注解,未注入 sys_user 删除依赖 + */ +public class TeacherZeroReferenceSafeDeleteTest +{ + private Map teacherStore; + private int mockLessonCount; + private int deletedTeacherCount; + private TrainTeacherServiceImpl teacherService; + + @Before + public void setUp() + { + teacherStore = new HashMap<>(); + mockLessonCount = 0; + deletedTeacherCount = 0; + + TrainTeacherMapper teacherMapperProxy = (TrainTeacherMapper) Proxy.newProxyInstance( + TrainTeacherMapper.class.getClassLoader(), + new Class[]{ TrainTeacherMapper.class }, + (proxy, method, args) -> { + String methodName = method.getName(); + if ("selectTrainTeacherByTId".equals(methodName)) + { + return teacherStore.get((String) args[0]); + } + else if ("deleteTrainTeacherRealByTId".equals(methodName)) + { + String tId = (String) args[0]; + if (teacherStore.remove(tId) != null) + { + deletedTeacherCount++; + return 1; + } + return 0; + } + else if ("updateTrainTeacher".equals(methodName)) + { + TrainTeacher t = (TrainTeacher) args[0]; + if (teacherStore.containsKey(t.gettId())) + { + teacherStore.put(t.gettId(), t); + return 1; + } + return 0; + } + return null; + } + ); + + TrainLessonMapper lessonMapperProxy = (TrainLessonMapper) Proxy.newProxyInstance( + TrainLessonMapper.class.getClassLoader(), + new Class[]{ TrainLessonMapper.class }, + (proxy, method, args) -> { + if ("countLessonsByTeacherId".equals(method.getName())) + { + return mockLessonCount; + } + return 0; + } + ); + + teacherService = new TrainTeacherServiceImpl(teacherMapperProxy, lessonMapperProxy); + } + + @Test + public void testTeacherZeroReferenceSuccess() + { + TrainTeacher teacher = new TrainTeacher(); + teacher.settId("T_MOCK_001"); + teacher.settName("张老师"); + teacher.setUserId(null); + teacherStore.put("T_MOCK_001", teacher); + mockLessonCount = 0; + + int rows = teacherService.deleteTrainTeacherByTId("T_MOCK_001"); + assertEquals("零引用教师应删除成功", 1, rows); + assertEquals(1, deletedTeacherCount); + assertFalse("教师档案应已从存储中物理移除", teacherStore.containsKey("T_MOCK_001")); + } + + @Test + public void testTeacherWithAccountBindingBlocked() + { + TrainTeacher teacher = new TrainTeacher(); + teacher.settId("T_MOCK_002"); + teacher.settName("李老师"); + teacher.setUserId(1001L); + teacherStore.put("T_MOCK_002", teacher); + mockLessonCount = 0; + + try + { + teacherService.deleteTrainTeacherByTId("T_MOCK_002"); + fail("已绑定系统账号的教师应禁止删除"); + } + catch (ServiceException e) + { + assertTrue("提示文案应明确指出解绑要求", e.getMessage().contains("该教师已绑定系统账号,请先解除账号绑定后再删除。")); + assertEquals(0, deletedTeacherCount); + assertTrue("教师主档不得被物理删除", teacherStore.containsKey("T_MOCK_002")); + } + } + + @Test + public void testTeacherWithLessonReferenceBlocked() + { + TrainTeacher teacher = new TrainTeacher(); + teacher.settId("T_MOCK_003"); + teacher.settName("王老师"); + teacher.setUserId(null); + teacherStore.put("T_MOCK_003", teacher); + mockLessonCount = 3; + + try + { + teacherService.deleteTrainTeacherByTId("T_MOCK_003"); + fail("有排课或授课记录的教师应禁止删除"); + } + catch (ServiceException e) + { + assertTrue("提示文案应引导调整为停用", e.getMessage().contains("该教师已存在授课或排课记录,无法删除,可将教师状态调整为停用。")); + assertEquals(0, deletedTeacherCount); + assertTrue("教师主档不得被物理删除", teacherStore.containsKey("T_MOCK_003")); + } + } + + @Test + public void testTeacherHistoricalStatusDeactivateSupported() + { + TrainTeacher teacher = new TrainTeacher(); + teacher.settId("T_MOCK_004"); + teacher.settName("赵老师"); + teacher.setStatus(1); + teacherStore.put("T_MOCK_004", teacher); + mockLessonCount = 10; + + try + { + teacherService.deleteTrainTeacherByTId("T_MOCK_004"); + fail("历史教师应禁止物理删除"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("可将教师状态调整为停用")); + } + + teacher.setStatus(0); + int updateRows = teacherService.updateTrainTeacher(teacher); + assertEquals("历史教师应可调整状态为停用", 1, updateRows); + assertEquals(Integer.valueOf(0), teacherStore.get("T_MOCK_004").getStatus()); + } + + @Test + public void testContractNeverDeletesSysUser() + { + for (Field f : TrainTeacherServiceImpl.class.getDeclaredFields()) + { + assertFalse("TrainTeacherServiceImpl不得注入系统用户删除组件: " + f.getName(), + f.getType().getSimpleName().contains("SysUser")); + } + } + + @Test + public void testContractTransactionalAnnotationsPresent() throws Exception + { + Method teacherDelMethod = TrainTeacherServiceImpl.class.getMethod("deleteTrainTeacherByTId", String.class); + Transactional teacherTx = teacherDelMethod.getAnnotation(Transactional.class); + assertNotNull("教师安全删除方法必须具备 @Transactional 注解", teacherTx); + assertEquals(Exception.class, teacherTx.rollbackFor()[0]); + } + + @Test + public void testContractZeroOrphanRisk() + { + try + { + teacherService.deleteTrainTeacherByTId("T_NON_EXISTING"); + fail("不存在的教师应返回404"); + } + catch (ServiceException e) + { + assertEquals(Integer.valueOf(40401), e.getCode()); + } + } +} diff --git a/build_ui/src/api/train/teacher.js b/build_ui/src/api/train/teacher.js index 8ce6568f8..53442fce3 100644 --- a/build_ui/src/api/train/teacher.js +++ b/build_ui/src/api/train/teacher.js @@ -60,3 +60,11 @@ export function unbindTeacherUser(tId) { method: 'delete' }) } + +// 删除教师信息 +export function delTeacher(tId) { + return request({ + url: '/train/teacher/' + tId, + method: 'delete' + }) +} diff --git a/build_ui/src/views/train/teacher/index.vue b/build_ui/src/views/train/teacher/index.vue index ea7f2280a..19a2395cc 100644 --- a/build_ui/src/views/train/teacher/index.vue +++ b/build_ui/src/views/train/teacher/index.vue @@ -107,7 +107,7 @@ - + @@ -257,7 +265,7 @@