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

This commit is contained in:
wangzhibiao committed 2026-09-20 16:16:54 +08:00
1 parent 740c59d5dd
commit 2dc22b06f8
10 files changed
+353 -7

No files matched your search

@@ -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();
@@ -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);
}
@@ -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);
/**
* 批量删除教师信息
*
@@ -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);
}
}
@@ -390,4 +390,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select count(1) from train_lesson where class_room_id = #{classRoomId}
</select>
<select id="countLessonsByTeacherId" parameterType="String" resultType="int">
select count(1) from train_lesson where t_id = #{tId}
</select>
</mapper>
@@ -117,6 +117,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update train_teacher set del_flag = '2' where t_id = #{tId}
</update>
<!-- 物理删除:彻底清除无业务引用且未绑定账号的错误录入档案 -->
<delete id="deleteTrainTeacherRealByTId" parameterType="String">
delete from train_teacher where t_id = #{tId}
</delete>
<!-- 批量逻辑删除:将 del_flag 置为 '2' -->
<update id="deleteTrainTeacherByTIds" parameterType="String">
update train_teacher set del_flag = '2' where t_id in
@@ -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;
}
}
/**
@@ -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<String, TrainTeacher> 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());
}
}
}
+8
View File
@@ -60,3 +60,11 @@ export function unbindTeacherUser(tId) {
method: 'delete'
})
}
// 删除教师信息
export function delTeacher(tId) {
return request({
url: '/train/teacher/' + tId,
method: 'delete'
})
}
+21 -2
View File
@@ -107,7 +107,7 @@
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<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:teacher:edit']"
@@ -133,6 +133,14 @@
style="color: #F56C6C;"
@click="handleUnbind(scope.row)"
>解绑账号</el-button>
<el-button
v-hasPermi="['train:teacher:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -257,7 +265,7 @@
</template>
<script>
import { listTeacher, getTeacher, addTeacher, updateTeacher, getTeacherBindableUsers, bindTeacherUser, unbindTeacherUser } from '@/api/train/teacher'
import { listTeacher, getTeacher, delTeacher, addTeacher, updateTeacher, getTeacherBindableUsers, bindTeacherUser, unbindTeacherUser } from '@/api/train/teacher'
export default {
name: 'Teacher',
@@ -453,6 +461,17 @@ export default {
this.getList()
})
},
/** 删除按钮操作 */
handleDelete(row) {
const tId = row.tId;
const tName = row.tName;
this.$modal.confirm(`确定删除教师【${tName}】吗?`).then(() => {
return delTeacher(tId);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'