feat: 运动馆班级批量删除与菜单权限登记

新增 SportsHall/BatchDeleteClasses 接口:校验批量数量、本校运动馆班级归属及空班条件,在可串行化事务中清理班级和教练关联,并发送物联网删除同步。

学生批量删除增加 BatchDelete 动作权限校验;按实际页面菜单匹配权限,兼容生产库中重复的学生管理菜单。运动馆班级删除由已授权角色使用,不硬编码学校角色。

新增幂等菜单权限脚本,仅向学生管理和运动馆班级管理的有效页面菜单登记批量删除动作,不自动给角色授权。QA 与生产库已核验菜单动作;代码上线仍需独立部署。

验证:Debug/Release 构建通过,SQL 在 QA 重跑无重复写入。
This commit is contained in:
ltx 2026-10-15 14:51:56 +08:00
parent c4b01d2f53
commit 48151578a1
5 changed files with 142 additions and 0 deletions

View File

@ -27,6 +27,11 @@ namespace VOL.Business.IServices.SportsHall
/// <returns></returns>
Task ModifyClass(ModifySportsHallClassParam paramDto);
/// <summary>
/// 批量删除当前学校的运动馆空班级
/// </summary>
Task<int> BatchDeleteClasses(List<int> ids);
/// <summary>
/// 导入运动馆学生体测成绩
/// </summary>

View File

@ -440,6 +440,17 @@ namespace VOL.Business.Services.School
if (UserContext.Current.RoleId != 1 && UserContext.Current.RoleId != 2)
throw new Exception("仅管理员可以批量删除学生");
if (UserContext.Current.RoleId != 1)
{
var menuIds = await _studentRepository.DbContext.Set<Sys_Menu>()
.Where(menu => menu.Url == "/studentsManage" && menu.ParentId > 0)
.Select(menu => menu.Menu_Id)
.ToListAsync();
if (!UserContext.Current.Permissions.Any(permission =>
menuIds.Contains(permission.Menu_Id) && permission.UserAuthArr.Contains("BatchDelete")))
throw new Exception("没有批量删除学生的权限");
}
var studentIds = ids?.Where(x => x > 0).Distinct().ToList() ?? new List<int>();
if (studentIds.Count == 0)
throw new ArgumentException("请至少选择一名学生");

View File

@ -6,6 +6,7 @@ using Newtonsoft.Json;
using OfficeOpenXml.FormulaParsing.Excel.Functions.DateTime;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@ -40,6 +41,7 @@ namespace VOL.Business.Services.SportsHall
private readonly IS_GradeRepository _gradeRepository;
private readonly IS_ClassAssocTeacherRepository _classAssocTeacherRepository;
private readonly IS_StudentRepository _studentRepository;
private readonly IotDataSyncService _iotDataSyncService;
IN_HealthStandardsRepository _healthStandardsRepository;
IN_SportsTestResultRepository _sportsTestResultRepository;
@ -48,6 +50,7 @@ namespace VOL.Business.Services.SportsHall
IS_GradeRepository gradeRepository,
IS_ClassAssocTeacherRepository classAssocTeacherRepository,
IS_StudentRepository studentRepository,
IotDataSyncService iotDataSyncService,
IN_HealthStandardsRepository healthStandardsRepository,
IN_SportsTestResultRepository sportsTestResultRepository)
{
@ -55,6 +58,7 @@ namespace VOL.Business.Services.SportsHall
_gradeRepository = gradeRepository;
_classAssocTeacherRepository = classAssocTeacherRepository;
_studentRepository = studentRepository;
_iotDataSyncService = iotDataSyncService;
_healthStandardsRepository = healthStandardsRepository;
_sportsTestResultRepository = sportsTestResultRepository;
}
@ -221,6 +225,68 @@ namespace VOL.Business.Services.SportsHall
}
}
public async Task<int> BatchDeleteClasses(List<int> ids)
{
var user = UserContext.Current;
var classIds = ids?.Where(id => id > 0).Distinct().ToList() ?? new List<int>();
if (classIds.Count == 0 || classIds.Count > 100)
throw new ArgumentException("请选择1至100个班级");
if (user.RoleId != 1)
{
var menuIds = await _classRepository.DbContext.Set<Sys_Menu>()
.Where(menu => menu.Url == "/sportsHallClass" && menu.ParentId > 0)
.Select(menu => menu.Menu_Id)
.ToListAsync();
if (!user.Permissions.Any(permission =>
menuIds.Contains(permission.Menu_Id) && permission.UserAuthArr.Contains("BatchDelete")))
throw new Exception("没有批量删除运动馆班级的权限");
}
var gradeId = await _gradeRepository.FindAsIQueryable(grade => grade.GradeName == "运动馆")
.Select(grade => grade.Id)
.FirstOrDefaultAsync();
if (gradeId == 0)
throw new Exception("未找到运动馆年级");
await using var transaction = await _classRepository.DbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var classes = await _classRepository.DbContext.Set<S_Class>()
.Where(item => item.SchoolCode == user.TenantId && item.GradeId == gradeId && classIds.Contains(item.Id))
.ToListAsync();
if (classes.Count != classIds.Count)
throw new Exception("部分班级不存在或不属于当前学校,请刷新列表后重试");
var occupiedClassIds = await _classRepository.DbContext.Set<S_Student>()
.Where(student => classIds.Contains(student.ClassId))
.Select(student => student.ClassId)
.Distinct()
.ToListAsync();
if (occupiedClassIds.Count > 0)
{
var names = classes.Where(item => occupiedClassIds.Contains(item.Id)).Select(item => item.ClassName);
throw new Exception($"班级 {string.Join("、", names)} 仍有学生,请先调整学生后再删除");
}
var teacherAssociations = await _classRepository.DbContext.Set<S_ClassAssocTeacher>()
.Where(item => classIds.Contains(item.ClassId))
.ToListAsync();
_classRepository.DbContext.RemoveRange(teacherAssociations);
_classRepository.DbContext.RemoveRange(classes);
await _classRepository.SaveChangesAsync();
await transaction.CommitAsync();
foreach (var item in classes)
{
await _iotDataSyncService.DataSyncCallBack(new DataSyncCallBackParam
{
EventType = EventType.Delete.GetDisplayName(),
DataType = IOTDataSyncType.Class.GetDisplayName(),
Json = JsonConvert.SerializeObject(item)
});
}
return classes.Count;
}
/// <summary>
/// 导入运动馆学生体测成绩
/// </summary>

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using VOL.Business.IServices.School;
using VOL.Business.IServices.SportsHall;
@ -54,6 +55,16 @@ namespace VOL.WebApi.Controllers.SportsHall
return Ok("更新成功");
}
/// <summary>
/// 批量删除当前学校的运动馆空班级
/// </summary>
[HttpPost(nameof(BatchDeleteClasses))]
public async Task<ActionResult> BatchDeleteClasses([FromBody] List<int> ids)
{
var deletedCount = await _sportsHallService.BatchDeleteClasses(ids);
return Ok($"成功删除{deletedCount}个班级");
}
/// <summary>
/// 导入运动馆学生体测数据

View File

@ -0,0 +1,49 @@
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @menuUrl nvarchar(100);
DECLARE @menuId int;
DECLARE menu_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT Url FROM (VALUES (N'/studentsManage'), (N'/sportsHallClass')) AS menus(Url);
OPEN menu_cursor;
FETCH NEXT FROM menu_cursor INTO @menuUrl;
WHILE @@FETCH_STATUS = 0
BEGIN
IF (SELECT COUNT(*) FROM Sys_Menu
WHERE Url = @menuUrl AND ParentId > 0 AND MenuType = 0 AND Enable IN (1, 2)) <> 1
THROW 51001, N'页面菜单不存在或不唯一,请检查菜单配置', 1;
SELECT @menuId = Menu_Id FROM Sys_Menu
WHERE Url = @menuUrl AND ParentId > 0 AND MenuType = 0 AND Enable IN (1, 2);
IF EXISTS (SELECT 1 FROM Sys_Menu WHERE Menu_Id = @menuId
AND NULLIF(LTRIM(RTRIM(Auth)), N'') IS NOT NULL
AND (ISJSON(Auth) <> 1 OR LEFT(LTRIM(Auth), 1) <> N'['))
THROW 51002, N'菜单权限不是有效 JSON 数组,请先检查原始数据', 1;
UPDATE Sys_Menu
SET Auth = COALESCE(NULLIF(Auth, N''), N'[]')
WHERE Menu_Id = @menuId AND (Auth IS NULL OR Auth = N'');
UPDATE menu
SET Auth = JSON_MODIFY(menu.Auth, 'append $',
JSON_QUERY(N'{"text":"批量删除","value":"BatchDelete"}'))
FROM Sys_Menu AS menu
WHERE menu.Menu_Id = @menuId
AND NOT EXISTS (SELECT 1 FROM OPENJSON(menu.Auth)
WITH (Value nvarchar(100) '$.value') AS action
WHERE action.Value = N'BatchDelete');
FETCH NEXT FROM menu_cursor INTO @menuUrl;
END;
CLOSE menu_cursor;
DEALLOCATE menu_cursor;
COMMIT TRANSACTION;
SELECT MenuName, Url, Auth
FROM Sys_Menu
WHERE Url IN (N'/studentsManage', N'/sportsHallClass')
AND ParentId > 0 AND MenuType = 0;
GO