diff --git a/YD_Event.Application/Common/Excel/NpoiExcelReader.cs b/YD_Event.Application/Common/Excel/NpoiExcelReader.cs new file mode 100644 index 0000000..225f114 --- /dev/null +++ b/YD_Event.Application/Common/Excel/NpoiExcelReader.cs @@ -0,0 +1,196 @@ +using System.Globalization; +using System.Reflection; +using NPOI.SS.UserModel; + +namespace YD_Event.Application.Common.Excel; + +/// +/// Excel 表头列名映射特性,标注属性所对应的表头单元格文本 +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ExcelHeaderAttribute : Attribute +{ + /// + /// 表头文本 + /// + public string Name { get; } + + public ExcelHeaderAttribute(string name) + { + Name = name; + } +} + +/// +/// 基于 NPOI 的 Excel 读取器,统一支持 .xls(HSSF) 与 .xlsx(XSSF),按表头名称映射为对象 +/// +public static class NpoiExcelReader +{ + /// + /// 按文件流自动识别格式创建工作簿,调用方无需区分 .xls/.xlsx + /// + public static IWorkbook CreateWorkbook(Stream stream) + { + // 缓冲到内存,避免上传流不可寻址导致 NPOI 无法按文件头识别格式 + var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + memoryStream.Position = 0; + return WorkbookFactory.Create(memoryStream); + } + + /// + /// 读取指定名称的工作表 + /// + public static List ReadSheet(IWorkbook workbook, string sheetName, int headerSearchRows = 10) + where T : new() + { + var sheet = workbook.GetSheet(sheetName) + ?? throw new InvalidDataException($"缺少工作表:{sheetName}"); + return ReadSheet(sheet, headerSearchRows); + } + + /// + /// 读取指定序号的工作表(序号从0开始) + /// + public static List ReadSheet(IWorkbook workbook, int sheetIndex, int headerSearchRows = 10) + where T : new() + { + if (sheetIndex < 0 || sheetIndex >= workbook.NumberOfSheets) + throw new InvalidDataException($"工作表序号超出范围:{sheetIndex}"); + return ReadSheet(workbook.GetSheetAt(sheetIndex), headerSearchRows); + } + + /// + /// 读取工作表:在前若干行内定位表头行,按表头名称逐行映射为对象,整行为空则跳过 + /// + public static List ReadSheet(ISheet sheet, int headerSearchRows = 10) where T : new() + { + var headerMap = BuildHeaderMap(); + var formatter = new DataFormatter(); + + var headerRow = LocateHeaderRow(sheet, headerMap.Keys, headerSearchRows, formatter) + ?? throw new InvalidDataException($"工作表「{sheet.SheetName}」缺少表头"); + + // 列序号到属性的映射 + var columnProperties = new Dictionary(); + foreach (var headerCell in headerRow.Cells) + { + var headerText = formatter.FormatCellValue(headerCell).Trim(); + if (headerMap.TryGetValue(headerText, out var property)) + columnProperties[headerCell.ColumnIndex] = property; + } + + var result = new List(); + for (var rowIndex = headerRow.RowNum + 1; rowIndex <= sheet.LastRowNum; rowIndex++) + { + var row = sheet.GetRow(rowIndex); + if (row == null) continue; + + var item = new T(); + var hasValue = false; + foreach (var (columnIndex, property) in columnProperties) + { + var cell = row.GetCell(columnIndex); + // 以单元格原始文本是否为空判断整行是否有数据,避免值类型默认值0误判 + var rawText = cell == null ? null : formatter.FormatCellValue(cell).Trim(); + if (!string.IsNullOrWhiteSpace(rawText)) hasValue = true; + var value = ConvertCellValue(cell, property.PropertyType, formatter); + property.SetValue(item, value); + } + + // 整行没有任何有效数据时视为空行跳过 + if (hasValue) result.Add(item); + } + return result; + } + + /// + /// 构建表头文本到属性的映射 + /// + private static Dictionary BuildHeaderMap() + { + var map = new Dictionary(); + foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var header = property.GetCustomAttribute(); + if (header != null) map[header.Name] = property; + } + return map; + } + + /// + /// 在表头搜索范围内,取命中映射表头数量最多的一行作为表头行 + /// + private static IRow? LocateHeaderRow(ISheet sheet, ICollection headers, + int searchRows, DataFormatter formatter) + { + var lastRow = Math.Min(sheet.LastRowNum, sheet.FirstRowNum + searchRows - 1); + IRow? matched = null; + var maxHits = 0; + for (var index = sheet.FirstRowNum; index <= lastRow; index++) + { + var row = sheet.GetRow(index); + if (row == null) continue; + var hits = row.Cells.Count(cell => + headers.Contains(formatter.FormatCellValue(cell).Trim())); + if (hits > maxHits) + { + maxHits = hits; + matched = row; + } + } + return maxHits > 0 ? matched : null; + } + + /// + /// 单元格值按目标属性类型转换;空白单元格对引用/可空类型返回 null,对非空值类型返回默认值 + /// + private static object? ConvertCellValue(ICell? cell, Type targetType, DataFormatter formatter) + { + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + var text = cell == null ? null : formatter.FormatCellValue(cell).Trim(); + + if (string.IsNullOrWhiteSpace(text)) + return CreateDefaultValue(targetType); + + if (underlyingType == typeof(string)) return text; + + // 数值、日期型单元格优先取强类型值,避免格式化文本的千分位、小数位影响解析 + if (cell is { CellType: CellType.Numeric }) + { + if (DateUtil.IsCellDateFormatted(cell) && underlyingType == typeof(DateTime) + && cell.DateCellValue is { } cellDate) + return cellDate; + + var number = cell.NumericCellValue; + if (underlyingType == typeof(long)) return (long)Math.Round(number); + if (underlyingType == typeof(int)) return (int)Math.Round(number); + if (underlyingType == typeof(short)) return (short)Math.Round(number); + if (underlyingType == typeof(byte)) return (byte)Math.Round(number); + if (underlyingType == typeof(decimal)) return (decimal)number; + if (underlyingType == typeof(double)) return number; + if (underlyingType == typeof(float)) return (float)number; + } + + if (underlyingType == typeof(long) && long.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var longValue)) return longValue; + if (underlyingType == typeof(int) && int.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) return intValue; + if (underlyingType == typeof(decimal) && decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) return decimalValue; + if (underlyingType == typeof(double) && double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue)) return doubleValue; + if (underlyingType == typeof(DateTime) && DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.None, out var dateTimeValue)) return dateTimeValue; + if (underlyingType == typeof(bool) && bool.TryParse(text, out var boolValue)) return boolValue; + if (underlyingType.IsEnum && Enum.TryParse(underlyingType, text, out var enumValue)) return enumValue; + + // 无法转换时不抛异常中断整表导入:值类型给默认值,引用/可空类型给 null + return CreateDefaultValue(targetType); + } + + /// + /// 生成类型默认值:非空值类型返回 Activator 默认实例,其余返回 null + /// + private static object? CreateDefaultValue(Type targetType) + { + return targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null + ? Activator.CreateInstance(targetType) + : null; + } +} diff --git a/YD_Event.Application/Event/Dtos/ScoreDtos.cs b/YD_Event.Application/Event/Dtos/ScoreDtos.cs index 6eb8065..74b71fb 100644 --- a/YD_Event.Application/Event/Dtos/ScoreDtos.cs +++ b/YD_Event.Application/Event/Dtos/ScoreDtos.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using YD_Event.Core.Enum; -using MiniExcelLibs.Attributes; +using YD_Event.Application.Common.Excel; namespace YD_Event.Application.Event.Dtos; @@ -245,120 +245,60 @@ public class ScoreImportDto /// /// 报名ID(唯一标识符) /// - [ExcelColumnName("报名ID")] + [ExcelHeader("报名ID")] public long? RegistrationItemId { get; set; } /// /// 赛事地址 /// - [ExcelColumnName("赛事地址")] + [ExcelHeader("赛事地址")] public string EventAddress { get; set; } /// /// 项目名称 /// - [ExcelColumnName("项目")] + [ExcelHeader("项目")] public string ProjectName { get; set; } /// /// 组别名称 /// - [ExcelColumnName("组别")] + [ExcelHeader("组别")] public string GroupName { get; set; } /// /// 姓名 /// - [ExcelColumnName("姓名")] + [ExcelHeader("姓名")] public string Name { get; set; } /// /// 联系方式 /// - [ExcelColumnName("联系方式")] + [ExcelHeader("联系方式")] public string Contact { get; set; } /// /// 单位名称 /// - [ExcelColumnName("单位")] + [ExcelHeader("单位")] public string UnitName { get; set; } /// /// 成绩 /// - [ExcelColumnName("成绩")] + [ExcelHeader("成绩")] public string Score { get; set; } /// /// 名次(数字) /// - [ExcelColumnName("名次(数字)")] + [ExcelHeader("名次(数字)")] public int Rank { get; set; } /// /// 名次(汉字) /// - [ExcelColumnName("名次(汉字)")] + [ExcelHeader("名次(汉字)")] public string RankText { get; set; } } - -/// -/// 证书导出DTO -/// -public class CertificateExportDto -{ - /// - /// 证书编号 - /// - [ExcelColumnName("证书编号")] - public string CertificateNo { get; set; } - - /// - /// 姓名 - /// - [ExcelColumnName("姓名")] - public string ParticipantName { get; set; } - - /// - /// 项目名称 - /// - [ExcelColumnName("项目")] - public string ProjectName { get; set; } - - /// - /// 组别名称 - /// - [ExcelColumnName("组别")] - public string GroupName { get; set; } - - /// - /// 单位名称 - /// - [ExcelColumnName("单位")] - public string UnitName { get; set; } - - /// - /// 成绩 - /// - [ExcelColumnName("成绩")] - public string Score { get; set; } - - /// - /// 名次 - /// - [ExcelColumnName("名次")] - public int Rank { get; set; } - - /// - /// 证书类型 - /// - [ExcelColumnName("证书类型")] - public string CertificateType { get; set; } - - /// - /// 发放时间 - /// - [ExcelColumnName("发放时间")] - public DateTime? IssuedTime { get; set; } -} diff --git a/YD_Event.Application/Event/EventArrangementService.cs b/YD_Event.Application/Event/EventArrangementService.cs index 0e38711..9bcacc5 100644 --- a/YD_Event.Application/Event/EventArrangementService.cs +++ b/YD_Event.Application/Event/EventArrangementService.cs @@ -2,7 +2,6 @@ using YD_Event.Application.Event.Dtos; using YD_Event.Core.Domain.Export; using YD_Event.Core.Entities; using YD_Event.Core.Extensions; -using MiniExcelLibs; using SqlSugar; using OfficeOpenXml; using OfficeOpenXml.Style; diff --git a/YD_Event.Application/Event/EventScoreService.cs b/YD_Event.Application/Event/EventScoreService.cs index 2fdf453..11e34a8 100644 --- a/YD_Event.Application/Event/EventScoreService.cs +++ b/YD_Event.Application/Event/EventScoreService.cs @@ -6,7 +6,7 @@ using YD_Event.Application.Certificate; using YD_Event.Core.Entities; using YD_Event.Core.Enum; using YD_Event.Core.Domain.Export; -using MiniExcelLibs; +using YD_Event.Application.Common.Excel; using SqlSugar; using OfficeOpenXml; using OfficeOpenXml.Style; @@ -524,7 +524,9 @@ public class EventScoreService : BaseService, ITransient var startTime = DateTime.Now; using var stream = file.OpenReadStream(); - var allRows = stream.Query().ToList(); + // NPOI 统一读取,自动识别 .xls/.xlsx,按表头特性映射为导入行 + using var workbook = NpoiExcelReader.CreateWorkbook(stream); + var allRows = NpoiExcelReader.ReadSheet(workbook, 0); _logger.LogInformation("解析Excel完成,共 {RowCount} 条原始记录", allRows.Count); // 过滤掉空行(没有报名ID的行) @@ -1536,7 +1538,7 @@ public class EventScoreService : BaseService, ITransient SetHeaders(headers, 1, headerColor, 11); // 设置第1列为文本格式,防止科学计数法显示 - // 只设置前20行的格式,避免MiniExcel读取过多空行 + // 只设置前20行的格式,避免导入读取时遍历过多空行 for (int r = 2; r <= 20; r++) { Worksheet.Cells[r, 1].Style.Numberformat.Format = "@"; diff --git a/YD_Event.Application/Event/EventService.cs b/YD_Event.Application/Event/EventService.cs index fadef76..1fbc856 100644 --- a/YD_Event.Application/Event/EventService.cs +++ b/YD_Event.Application/Event/EventService.cs @@ -3,7 +3,6 @@ using YD_Event.Application.Project.Dtos; using YD_Event.Core.Entities; using YD_Event.Core.Enum; using Easy.Core; -using MiniExcelLibs; using SqlSugar; using Mapster; diff --git a/YD_Event.Application/Event/Scoring/EventScoreCalculationService.cs b/YD_Event.Application/Event/Scoring/EventScoreCalculationService.cs index 4f3b2cd..ee171e7 100644 --- a/YD_Event.Application/Event/Scoring/EventScoreCalculationService.cs +++ b/YD_Event.Application/Event/Scoring/EventScoreCalculationService.cs @@ -393,6 +393,21 @@ public class EventScoreCalculationService : ITransient result.UpdatedTime = now; } await _resultRepository.UpdateRangeAsync(results); + // 同步作废当前成绩版本(历史版本保留留痕),名次清零不再参与排名 + var resultIds = results.Select(x => x.Id).ToList(); + var versions = await _resultVersionRepository.GetListAsync(x => + !x.DeleteMark && resultIds.Contains(x.EventResultId)); + var currentVersions = versions.Where(version => results.Any(result => + result.Id == version.EventResultId && result.ResultVersion == version.ResultVersion)).ToList(); + foreach (var version in currentVersions) + { + version.ResultStatus = ResultStatusVoided; + version.Rank = 0; + version.UpdatedUserId = operatorUserId ?? 0; + version.UpdatedTime = now; + } + if (currentVersions.Count > 0) + await _resultVersionRepository.UpdateRangeAsync(currentVersions); } /// diff --git a/YD_Event.Application/OrderBook/Dtos/OrderBookDtos.cs b/YD_Event.Application/OrderBook/Dtos/OrderBookDtos.cs index 27689d7..c37f472 100644 --- a/YD_Event.Application/OrderBook/Dtos/OrderBookDtos.cs +++ b/YD_Event.Application/OrderBook/Dtos/OrderBookDtos.cs @@ -1,4 +1,4 @@ -using MiniExcelLibs.Attributes; +using YD_Event.Application.Common.Excel; namespace YD_Event.Application.OrderBook.Dtos; @@ -10,61 +10,61 @@ public class OrderBookEntryRow /// /// 序号(仅便于人工核对,导入时不参与落库) /// - [ExcelColumnName("序号")] + [ExcelHeader("序号")] public string? Seq { get; set; } /// /// 场地名称(必填) /// - [ExcelColumnName("场地名称")] + [ExcelHeader("场地名称")] public string? VenueName { get; set; } /// /// 项目名称(必填,需与赛事已配置项目一致,不存在将自动建档) /// - [ExcelColumnName("项目名称")] + [ExcelHeader("项目名称")] public string? ProjectName { get; set; } /// /// 组别名称(可空,空则归入"公开组") /// - [ExcelColumnName("组别名称")] + [ExcelHeader("组别名称")] public string? GroupName { get; set; } /// /// 出场编号(可空,空则按同场次顺序自动生成) /// - [ExcelColumnName("出场编号")] + [ExcelHeader("出场编号")] public string? EntryNo { get; set; } /// /// 姓名/队名(必填) /// - [ExcelColumnName("姓名/队名")] + [ExcelHeader("姓名/队名")] public string? ParticipantName { get; set; } /// /// 所属单位(可空,仅用于核对展示) /// - [ExcelColumnName("单位")] + [ExcelHeader("单位")] public string? UnitName { get; set; } /// /// 联系电话(可空) /// - [ExcelColumnName("联系电话")] + [ExcelHeader("联系电话")] public string? Mobile { get; set; } /// /// 证件号(可空) /// - [ExcelColumnName("证件号")] + [ExcelHeader("证件号")] public string? IdCard { get; set; } /// /// 备注(可空) /// - [ExcelColumnName("备注")] + [ExcelHeader("备注")] public string? Remark { get; set; } } @@ -76,43 +76,43 @@ public class OrderBookRefereeRow /// /// 序号(仅便于人工核对) /// - [ExcelColumnName("序号")] + [ExcelHeader("序号")] public string? Seq { get; set; } /// /// 裁判姓名(必填) /// - [ExcelColumnName("姓名")] + [ExcelHeader("姓名")] public string? Name { get; set; } /// /// 手机号(必填,用于匹配/创建裁判档案及打分账号) /// - [ExcelColumnName("手机号")] + [ExcelHeader("手机号")] public string? Mobile { get; set; } /// /// 裁判角色(可空,如:主裁/难度裁判/表现裁判) /// - [ExcelColumnName("裁判角色")] + [ExcelHeader("裁判角色")] public string? RoleText { get; set; } /// /// 裁判等级(可空,如:国际级/国家级/一级/二级/三级) /// - [ExcelColumnName("裁判等级")] + [ExcelHeader("裁判等级")] public string? LevelText { get; set; } /// /// 负责场地(可空,对应出场秩序中的场地名称) /// - [ExcelColumnName("负责场地")] + [ExcelHeader("负责场地")] public string? VenueName { get; set; } /// /// 负责项目(可空) /// - [ExcelColumnName("负责项目")] + [ExcelHeader("负责项目")] public string? ProjectName { get; set; } } diff --git a/YD_Event.Application/OrderBook/OrderBookService.cs b/YD_Event.Application/OrderBook/OrderBookService.cs index f945ec1..add67a5 100644 --- a/YD_Event.Application/OrderBook/OrderBookService.cs +++ b/YD_Event.Application/OrderBook/OrderBookService.cs @@ -1,6 +1,6 @@ -using MiniExcelLibs; using System.Drawing; using YD_Event.Application.Auth; +using YD_Event.Application.Common.Excel; using YD_Event.Application.OrderBook.Dtos; using YD_Event.Core.Domain.Export; @@ -99,8 +99,8 @@ public class OrderBookService : IDynamicApiController, ITransient throw Oops.Bah("请上传秩序册文件"); var ext = Path.GetExtension(file.FileName)?.ToLowerInvariant(); - if (ext != ".xlsx") - throw Oops.Bah("仅支持 .xlsx 格式的秩序册文件"); + if (ext is not ".xlsx" and not ".xls") + throw Oops.Bah("仅支持 .xls/.xlsx 格式的秩序册文件"); if (eventId.HasValue && eventId.Value > 0 && !await _eventRepository.IsAnyAsync(e => e.Id == eventId.Value && !e.DeleteMark)) @@ -115,8 +115,10 @@ public class OrderBookService : IDynamicApiController, ITransient try { using var stream = file.OpenReadStream(); - entryRows = stream.Query(sheetName: EntrySheetName).ToList(); - refereeRows = stream.Query(sheetName: RefereeSheetName).ToList(); + // NPOI 统一读取,WorkbookFactory 按文件头自动识别 .xls/.xlsx + using var workbook = NpoiExcelReader.CreateWorkbook(stream); + entryRows = NpoiExcelReader.ReadSheet(workbook, EntrySheetName); + refereeRows = NpoiExcelReader.ReadSheet(workbook, RefereeSheetName); } catch (Exception ex) { diff --git a/YD_Event.Application/Unit/UnitAccountService.cs b/YD_Event.Application/Unit/UnitAccountService.cs index c9c3a7b..024ca8b 100644 --- a/YD_Event.Application/Unit/UnitAccountService.cs +++ b/YD_Event.Application/Unit/UnitAccountService.cs @@ -88,6 +88,10 @@ public class UnitAccountService : BaseService, ITransient public async Task Add(AddUnitAccountInput input) { EnsureUnitPlatform(input.Platform); + // 规范化文本输入,避免首尾空格导致账号查重与登录不一致 + input.Account = input.Account.Trim(); + input.UnitName = input.UnitName.Trim(); + input.ContactName = input.ContactName.Trim(); // 账号是全局登录标识,需在整张 SysUser 表内查重 if (await _repository.IsAnyAsync(x => x.Account == input.Account)) { @@ -133,6 +137,10 @@ public class UnitAccountService : BaseService, ITransient public async Task Update(UpdateUnitAccountInput input) { var user = await GetUnitAccountAsync(input.Id); + // 规范化文本输入,避免首尾空格导致账号查重与登录不一致 + input.Account = input.Account.Trim(); + input.UnitName = input.UnitName.Trim(); + input.ContactName = input.ContactName.Trim(); if (await _repository.IsAnyAsync(x => x.Account == input.Account && x.Id != input.Id)) { diff --git a/YD_Event.Application/User/SysUserService.cs b/YD_Event.Application/User/SysUserService.cs index 6fd8ebc..c608a0a 100644 --- a/YD_Event.Application/User/SysUserService.cs +++ b/YD_Event.Application/User/SysUserService.cs @@ -73,6 +73,8 @@ public class SysUserService : BaseService, ITransient // 构造基本查询 var query = _repository.AsQueryable() .Where(x => x.Id > 1) // 排除超级管理员或特定ID + // 权限管理中的“报名平台”只管理后台账号;单位/打分账号由对应平台页签管理。 + .Where(x => x.UnitPlatform == UnitAccountPlatform.EventSystem) .WhereIF(!string.IsNullOrWhiteSpace(dto.Name), x => x.Name.Contains(dto.Name)) .WhereIF(!string.IsNullOrWhiteSpace(dto.Account), x => x.Account.Contains(dto.Account)) .WhereIF(dto.StartTime.HasValue, x => x.CreatedTime >= dto.StartTime.Value) @@ -141,6 +143,9 @@ public class SysUserService : BaseService, ITransient [DisplayName("添加系统用户")] public async Task AddUser(AddSysUserInput dto) { + // 规范化文本输入,避免首尾空格导致账号查重与登录不一致 + dto.Account = dto.Account.Trim(); + dto.Name = dto.Name.Trim(); // 校验账号是否已存在 if (await _repository.IsAnyAsync(x => x.Account == dto.Account)) throw new InvalidOperationException($"账号 {dto.Account} 已存在"); @@ -162,6 +167,8 @@ public class SysUserService : BaseService, ITransient user.Id = _idGenerator.NextId(); string encode = _idGenerator.Encode(user.Id); user.Password = MD5Encryption.Encrypt(encode + dto.Password); + // 权限管理-账号管理只创建报名平台后台账号 + user.UnitPlatform = UnitAccountPlatform.EventSystem; user.CreatedTime = DateTime.Now; // 确保设置创建时间 // 处理角色关联 @@ -191,8 +198,10 @@ public class SysUserService : BaseService, ITransient [UnitOfWork, HttpPut("user")] public async Task UpdateUser(UpdateSysUserInput dto) { - var user = await _repository.GetByIdAsync(dto.Id); - if (user == null) throw new InvalidOperationException("用户不存在"); + var user = await GetEventSystemUserAsync(dto.Id); + // 规范化文本输入,避免首尾空格导致账号查重与登录不一致 + dto.Account = dto.Account.Trim(); + dto.Name = dto.Name.Trim(); // 校验账号是否重复(排除自己) if (await _repository.IsAnyAsync(x => x.Account == dto.Account && x.Id != dto.Id)) @@ -253,6 +262,7 @@ public class SysUserService : BaseService, ITransient [HttpDelete] public override async Task Delete(KeyDto dto) { + await GetEventSystemUserAsync(dto.Id); await base.Delete(dto); await _easyCachingProvider.RemoveByPrefixAsync(CacheConst.PermissionKey); } @@ -286,6 +296,7 @@ public class SysUserService : BaseService, ITransient [HttpGet("detail")] public async Task Detail([FromQuery] long id) { + await GetEventSystemUserAsync(id); return await _repository.AsQueryable().Where(x => x.Id == id) .Select(x => new UpdateSysUserInput() { @@ -305,6 +316,7 @@ public class SysUserService : BaseService, ITransient [HttpPost("reset")] public async Task Reset(SysUserResetPasswordInput dto) { + await GetEventSystemUserAsync(dto.Id); string encrypt = MD5Encryption.Encrypt(_idGenerator.Encode(dto.Id) + dto.Password); await _repository.UpdateAsync(x => new SysUser() { @@ -312,6 +324,31 @@ public class SysUserService : BaseService, ITransient }, x => x.Id == dto.Id); } + /// + /// 修改报名平台后台账号状态。 + /// + [DisplayName("修改报名平台账号状态")] + [HttpPost("setStatus")] + public override async Task SetStatus(AvailabilityDto dto) + { + await GetEventSystemUserAsync(dto.Id); + await base.SetStatus(dto); + await _easyCachingProvider.RemoveByPrefixAsync(CacheConst.PermissionKey); + } + + /// + /// 校验目标账号为报名平台后台账号,拦截超级管理员及其他平台账号,防止跨平台误操作 + /// + private async Task GetEventSystemUserAsync(long id) + { + if (id <= 1) throw Oops.Bah("超级管理员账号不允许在此操作"); + var user = await _repository.GetByIdAsync(id); + if (user == null) throw Oops.Bah("报名平台账号不存在"); + if (user.UnitPlatform != UnitAccountPlatform.EventSystem) + throw Oops.Bah("该账号不属于报名平台,请切换到对应平台管理"); + return user; + } + /// /// 获取当前登录用户的信息 /// diff --git a/YD_Event.Application/Workbench/Dtos/WorkbenchDtos.cs b/YD_Event.Application/Workbench/Dtos/WorkbenchDtos.cs index 0c1621e..fb7ff58 100644 --- a/YD_Event.Application/Workbench/Dtos/WorkbenchDtos.cs +++ b/YD_Event.Application/Workbench/Dtos/WorkbenchDtos.cs @@ -1,5 +1,52 @@ namespace YD_Event.Application.Workbench.Dtos; +/// +/// 工作台赛事选择查询输入 +/// +public class WorkbenchEventQueryInput : Pagination +{ + /// + /// 赛事名称(可选,模糊匹配) + /// + public string? EventName { get; set; } + + /// + /// 数据来源:registration-报名平台(默认),orderbook-秩序册 + /// + public string SourceMode { get; set; } = "registration"; +} + +/// +/// 工作台赛事选择项 +/// +public class WorkbenchEventDto +{ + /// + /// 赛事ID + /// + public long Id { get; set; } + + /// + /// 赛事名称 + /// + public string EventName { get; set; } = string.Empty; + + /// + /// 赛事状态 + /// + public int EventStatus { get; set; } + + /// + /// 比赛开始时间 + /// + public DateTime? MatchStartTime { get; set; } + + /// + /// 比赛结束时间 + /// + public DateTime? MatchEndTime { get; set; } +} + /// /// 工作台场次列表查询输入 /// @@ -36,6 +83,31 @@ public class WorkbenchSessionQueryInput /// public class WorkbenchSessionDto { + /// + /// 赛事ID + /// + public long EventId { get; set; } + + /// + /// 场地ID + /// + public long VenueId { get; set; } + + /// + /// 赛事项目ID + /// + public long EventProjectId { get; set; } + + /// + /// 项目组别ID(多组别混合场次时为空) + /// + public long? EventProjectGroupId { get; set; } + + /// + /// 计分规则编码,用于前端按规则展示评分维度 + /// + public string ScoreRuleCode { get; set; } = string.Empty; + /// /// 场次ID /// @@ -168,6 +240,11 @@ public class WorkbenchEntryDetailDto /// public string? TeamName { get; set; } + /// + /// 参赛单位名称 + /// + public string? UnitName { get; set; } + /// /// 组别名称 /// @@ -359,6 +436,62 @@ public class WorkbenchJudgeScoreDto /// 是否存在待处理的修正申请 /// public bool HasPendingCorrection { get; set; } + + /// + /// 评分单的动态评分项明细快照 + /// + public List ScoreItems { get; set; } = new(); +} + +/// +/// 工作台评分项详情 +/// +public class WorkbenchScoreItemDto +{ + /// + /// 评分项编码 + /// + public string ItemCode { get; set; } = string.Empty; + + /// + /// 评分项名称 + /// + public string ItemName { get; set; } = string.Empty; + + /// + /// 所属科目编码(可选) + /// + public string? SubjectCode { get; set; } + + /// + /// 评分项类型 + /// + public int ItemType { get; set; } + + /// + /// 计分维度 + /// + public int ScoreDimension { get; set; } + + /// + /// 计数值(计数类评分项,如次数、人数) + /// + public decimal? CountValue { get; set; } + + /// + /// 裁判录入值 + /// + public decimal? InputValue { get; set; } + + /// + /// 小计得分 + /// + public decimal Subtotal { get; set; } + + /// + /// 排序号 + /// + public int Sort { get; set; } } /// @@ -490,6 +623,36 @@ public class CurrentScreenCastDto /// public DateTime CastTime { get; set; } + /// + /// 赛事名称(大屏标题) + /// + public string EventName { get; set; } = string.Empty; + + /// + /// 项目名称 + /// + public string ProjectName { get; set; } = string.Empty; + + /// + /// 计分规则编码 + /// + public string ScoreRuleCode { get; set; } = string.Empty; + + /// + /// 组别名称 + /// + public string GroupName { get; set; } = string.Empty; + + /// + /// 场地名称 + /// + public string VenueName { get; set; } = string.Empty; + + /// + /// 场次编号 + /// + public string SessionNo { get; set; } = string.Empty; + /// /// 投屏内容(JSON格式) /// @@ -698,6 +861,8 @@ public class CreateRematchInput /// /// 重赛原因 /// + [Required(ErrorMessage = "重赛原因不能为空")] + [StringLength(200, MinimumLength = 2, ErrorMessage = "重赛原因长度必须为2-200个字符")] public string RematchReason { get; set; } = string.Empty; } @@ -735,6 +900,8 @@ public class VoidResultInput /// /// 作废原因 /// + [Required(ErrorMessage = "作废原因不能为空")] + [StringLength(200, MinimumLength = 2, ErrorMessage = "作废原因长度必须为2-200个字符")] public string VoidReason { get; set; } = string.Empty; } @@ -760,7 +927,9 @@ public class BatchConfirmResultInput public long SessionId { get; set; } /// - /// 成绩ID列表(可选,不传则确认该场次所有待确认成绩) + /// 成绩ID列表(必须明确提交选中的成绩) /// - public List? ResultIds { get; set; } + [Required(ErrorMessage = "请选择需要确认的成绩")] + [MinLength(1, ErrorMessage = "请选择至少一条待确认成绩")] + public List ResultIds { get; set; } = new(); } diff --git a/YD_Event.Application/Workbench/Dtos/WorkbenchExportDtos.cs b/YD_Event.Application/Workbench/Dtos/WorkbenchExportDtos.cs index cac84dc..2d6e603 100644 --- a/YD_Event.Application/Workbench/Dtos/WorkbenchExportDtos.cs +++ b/YD_Event.Application/Workbench/Dtos/WorkbenchExportDtos.cs @@ -15,15 +15,25 @@ public class ResultExportQueryInput /// public long? EventProjectId { get; set; } + /// + /// 场次ID(可选) + /// + public long? SessionId { get; set; } + + /// + /// 关键词(可选):姓名、队名或出场编号模糊匹配 + /// + public string? Keyword { get; set; } + /// /// 组别名称(可选,模糊匹配) /// public string? GroupName { get; set; } /// - /// 成绩状态(可选,默认20-已确认;0-计算中,10-待复核,20-已确认,90-已作废) + /// 成绩状态(可选,不传则导出全部状态;0-计算中,10-待复核,20-已确认,30-已投屏,40-已更正,90-已作废) /// - public int? ResultStatus { get; set; } = 20; + public int? ResultStatus { get; set; } /// /// 是否包含被取消比赛资格(DQ)的记录,默认不包含 diff --git a/YD_Event.Application/Workbench/WorkbenchController.cs b/YD_Event.Application/Workbench/WorkbenchController.cs index cb2c791..5ba1079 100644 --- a/YD_Event.Application/Workbench/WorkbenchController.cs +++ b/YD_Event.Application/Workbench/WorkbenchController.cs @@ -29,9 +29,11 @@ public class WorkbenchController : IDynamicApiController private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _refereeRepository; private readonly ISqlSugarRepository _projectRepository; + private readonly ISqlSugarRepository _scoreRuleRepository; private readonly ISqlSugarRepository _scoreDetailRepository; private readonly ISqlSugarRepository _operationLogRepository; private readonly ISqlSugarRepository _sysUserRepository; + private readonly ISqlSugarRepository _merchantCertificationRepository; private readonly AuthManager _authManager; private readonly IIdGenerator _idGenerator; @@ -53,9 +55,11 @@ public class WorkbenchController : IDynamicApiController ISqlSugarRepository userRepository, ISqlSugarRepository refereeRepository, ISqlSugarRepository projectRepository, + ISqlSugarRepository scoreRuleRepository, ISqlSugarRepository scoreDetailRepository, ISqlSugarRepository operationLogRepository, ISqlSugarRepository sysUserRepository, + ISqlSugarRepository merchantCertificationRepository, AuthManager authManager, IIdGenerator idGenerator) { @@ -76,15 +80,45 @@ public class WorkbenchController : IDynamicApiController _userRepository = userRepository; _refereeRepository = refereeRepository; _projectRepository = projectRepository; + _scoreRuleRepository = scoreRuleRepository; _scoreDetailRepository = scoreDetailRepository; _operationLogRepository = operationLogRepository; _sysUserRepository = sysUserRepository; + _merchantCertificationRepository = merchantCertificationRepository; _authManager = authManager; _idGenerator = idGenerator; } #region 场次管理 + /// + /// 获取可进入打分工作台的赛事列表 + /// + [HttpGet("workbench/events")] + public async Task> GetEvents([FromQuery] WorkbenchEventQueryInput input) + { + var mode = input.SourceMode?.Trim().ToLowerInvariant(); + var query = _eventRepository.AsQueryable() + .Where(e => !e.DeleteMark) + .WhereIF(!string.IsNullOrWhiteSpace(input.EventName), e => e.EventName.Contains(input.EventName!.Trim())); + + // 报名平台入口只展示进行中/已结束的赛事;秩序册入口不限制赛事状态 + if (mode == "registration") + query = query.Where(e => e.EventStatus == Core.Enum.EventStatus.Ongoing || e.EventStatus == Core.Enum.EventStatus.Ended); + else if (mode != "orderbook") + throw Oops.Bah("数据来源只能为registration或orderbook"); + + return await query.OrderByDescending(e => e.MatchStartTime) + .Select(e => new WorkbenchEventDto + { + Id = e.Id, + EventName = e.EventName, + EventStatus = (int)e.EventStatus, + MatchStartTime = e.MatchStartTime, + MatchEndTime = e.MatchEndTime + }).ToPagedListAsync(input); + } + /// /// 获取工作台场次列表 /// @@ -121,6 +155,7 @@ public class WorkbenchController : IDynamicApiController s.VenueId, s.EventProjectId, s.EventProjectGroupId, + s.ScoreRuleId, s.StartTime, s.EndTime, s.SessionStatus, @@ -163,6 +198,13 @@ public class WorkbenchController : IDynamicApiController .Select(g => new { g.Id, g.GroupName }) .ToListAsync(); + // 批量查出场次绑定的计分规则编码,供前端按规则渲染评分维度 + var ruleIds = sessions.Select(s => s.ScoreRuleId).Distinct().ToList(); + var ruleDict = (await _scoreRuleRepository.AsQueryable() + .Where(r => !r.DeleteMark && ruleIds.Contains(r.Id)) + .Select(r => new { r.Id, r.RuleCode }) + .ToListAsync()).ToDictionary(r => r.Id, r => r.RuleCode); + var entryCounts = await _entryRepository.AsQueryable() .Where(e => !e.DeleteMark && sessionIds.Contains(e.SessionId)) .GroupBy(e => e.SessionId) @@ -176,7 +218,9 @@ public class WorkbenchController : IDynamicApiController var resultCounts = await _resultRepository.AsQueryable() .InnerJoin((r, e) => r.SessionEntryId == e.Id) - .Where((r, e) => !r.DeleteMark && !e.DeleteMark && sessionIds.Contains(e.SessionId) && r.ResultStatus == 20) + .Where((r, e) => !r.DeleteMark && !e.DeleteMark && sessionIds.Contains(e.SessionId) && + // 已确认(20)、已投屏(30)、已更正(40)均计入已完成成绩数 + (r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40)) .GroupBy((r, e) => e.SessionId) .Select((r, e) => new { @@ -195,6 +239,11 @@ public class WorkbenchController : IDynamicApiController return sessions.Select(s => new WorkbenchSessionDto { SessionId = s.Id, + EventId = s.EventId, + VenueId = s.VenueId, + EventProjectId = s.EventProjectId, + EventProjectGroupId = s.EventProjectGroupId, + ScoreRuleCode = ruleDict.GetValueOrDefault(s.ScoreRuleId, string.Empty), SessionNo = s.SessionNo, SessionName = s.SessionName, EventName = eventDict.GetValueOrDefault(s.EventId, ""), @@ -269,10 +318,19 @@ public class WorkbenchController : IDynamicApiController r.ResultVersion, r.ReviewUserId, r.ReviewTime, - r.Rank + r.Rank, + r.MerchantCertificationId }) .ToListAsync(); + // 批量查询成绩关联的参赛单位名称(单位信息存于商户认证表) + var merchantIds = results.Where(r => r.MerchantCertificationId.HasValue) + .Select(r => r.MerchantCertificationId!.Value).Distinct().ToList(); + var merchantNames = (await _merchantCertificationRepository.AsQueryable() + .Where(m => merchantIds.Contains(m.Id) && !m.DeleteMark) + .Select(m => new { m.Id, m.MerchantName }).ToListAsync()) + .ToDictionary(m => m.Id, m => m.MerchantName); + if (input.ResultStatus.HasValue) { var filteredEntryIds = results.Where(r => r.ResultStatus == input.ResultStatus.Value).Select(r => r.SessionEntryId).ToHashSet(); @@ -285,6 +343,25 @@ public class WorkbenchController : IDynamicApiController .OrderBy(s => s.JudgeRole) .ToListAsync(); + // 批量查询当前评分单的动态评分项明细,按评分单分组挂到各裁判评分下 + var sheetIds = sheets.Select(s => s.Id).ToList(); + var scoreItems = await _scoreDetailRepository.AsQueryable() + .Where(d => !d.DeleteMark && sheetIds.Contains(d.ScoreSheetId)) + .OrderBy(d => d.Sort) + .Select(d => new + { + d.ScoreSheetId, + d.ItemCode, + d.ItemName, + d.SubjectCode, + d.ItemType, + d.ScoreDimension, + d.CountValue, + d.InputValue, + d.Subtotal, + d.Sort + }).ToListAsync(); + var refereeIds = sheets.Select(s => s.RefereeId).Distinct().ToList(); var refereeMap = (await _refereeRepository.AsQueryable() .Where(r => refereeIds.Contains(r.Id)) @@ -312,6 +389,9 @@ public class WorkbenchController : IDynamicApiController PlayOrder = e.PlayOrder, ParticipantName = e.ParticipantName, TeamName = e.TeamName, + UnitName = result?.MerchantCertificationId is long merchantId + ? merchantNames.GetValueOrDefault(merchantId) + : null, GroupName = e.GroupName, EntryStatus = e.EntryStatus, EntryStatusName = GetEntryStatusName(e.EntryStatus), @@ -350,7 +430,19 @@ public class WorkbenchController : IDynamicApiController TotalScore = sheet.TotalScore, IsDisqualified = sheet.IsDisqualified, SubmittedTime = sheet.SubmittedTime, - HasPendingCorrection = pendingCorrectionSet.Contains(sheet.Id) + HasPendingCorrection = pendingCorrectionSet.Contains(sheet.Id), + ScoreItems = scoreItems.Where(d => d.ScoreSheetId == sheet.Id).Select(d => new WorkbenchScoreItemDto + { + ItemCode = d.ItemCode, + ItemName = d.ItemName, + SubjectCode = d.SubjectCode, + ItemType = d.ItemType, + ScoreDimension = d.ScoreDimension, + CountValue = d.CountValue, + InputValue = d.InputValue, + Subtotal = d.Subtotal, + Sort = d.Sort + }).ToList() }).ToList() }; }).ToList(); @@ -364,25 +456,55 @@ public class WorkbenchController : IDynamicApiController /// 投出成绩到大屏 /// [HttpPost("workbench/screencast")] + [UnitOfWork] public async Task CastResult([FromBody] ResultScreenCastInput input) { var currentUserId = _authManager.UserId; var now = DateTime.Now; + // 两种投屏类型都先校验赛事存在且未删除 + if (input.EventId <= 0 || !await _eventRepository.IsAnyAsync(e => e.Id == input.EventId && !e.DeleteMark)) + throw Oops.Bah("赛事不存在或已删除"); + if (input.CastType == 1) { - if (!input.SessionEntryId.HasValue || !input.EventResultId.HasValue) - throw Oops.Bah("投出单个成绩时,必须提供出场记录ID和成绩ID"); + if (!input.SessionId.HasValue || !input.SessionEntryId.HasValue || !input.EventResultId.HasValue) + throw Oops.Bah("投出单个成绩时,必须提供场次ID、出场记录ID和成绩ID"); var entry = await _entryRepository.GetFirstAsync(e => e.Id == input.SessionEntryId.Value && !e.DeleteMark) ?? throw Oops.Bah("出场记录不存在"); var result = await _resultRepository.GetFirstAsync(r => r.Id == input.EventResultId.Value && !r.DeleteMark) ?? throw Oops.Bah("成绩记录不存在"); + var previousResultStatus = result.ResultStatus; var session = await _sessionRepository.GetByIdAsync(entry.SessionId) ?? throw Oops.Bah("场次不存在"); + // 投屏前校验归属关系与成绩状态,防止跨赛事/跨场次或过期版本被投出 + if (session.DeleteMark || session.Id != input.SessionId.Value || session.EventId != input.EventId || + (input.VenueId.HasValue && session.VenueId != input.VenueId.Value)) + throw Oops.Bah("赛事、场地、场次和出场记录不匹配"); + if (result.EventId != input.EventId || result.SessionId != session.Id || result.SessionEntryId != entry.Id) + throw Oops.Bah("成绩记录与当前赛事出场记录不匹配"); + if (result.ResultStatus is not (20 or 30 or 40)) + throw Oops.Bah("只有已确认的有效成绩可以投屏"); + if (result.IsDisqualified) + throw Oops.Bah("DQ成绩不能投屏"); + if (input.ResultVersion.HasValue && input.ResultVersion.Value != result.ResultVersion) + throw Oops.Bah("成绩已产生新版本,请刷新后重新投屏"); + + // 该出场应到裁判的评分单必须全部提交,否则成绩未定稿,不允许投屏 + var expectedJudgeCount = await _assignmentRepository.AsQueryable() + .Where(a => !a.DeleteMark && a.SessionId == session.Id && a.Status == Core.Enum.AvailabilityStatus.Enable) + .CountAsync(); + var submittedJudgeCount = await _sheetRepository.AsQueryable() + .Where(s => !s.DeleteMark && s.IsCurrent && s.SessionEntryId == entry.Id && + s.SheetStatus >= 10 && s.SheetStatus < 90) + .Select(s => s.JudgeAssignmentId).Distinct().CountAsync(); + if (expectedJudgeCount <= 0 || submittedJudgeCount < expectedJudgeCount) + throw Oops.Bah("相关裁判评分单尚未全部提交,不能投屏"); + var payload = new { EntryNo = entry.EntryNo, @@ -399,6 +521,7 @@ public class WorkbenchController : IDynamicApiController Rank = result.Rank }; + // 同赛事同场地此前投出中的记录统一置为“已被新投屏替换(20)”,保证大屏只展示最新一条 await _screenCastRepository.AsUpdateable() .SetColumns(sc => new EventResultScreenCast { CastStatus = 20 }, appendColumnsByDataFilter: true) @@ -428,6 +551,14 @@ public class WorkbenchController : IDynamicApiController await _screenCastRepository.InsertAsync(screenCast); + // 投屏成功后成绩置为“已投屏(30)”,同步当前版本状态并记录审计 + result.ResultStatus = 30; + result.UpdatedUserId = currentUserId; + result.UpdatedTime = now; + await _resultRepository.UpdateAsync(result); + await SyncResultVersionStatusAsync(result, 30, currentUserId, now); + await WriteAuditAsync("投出单人成绩", input.EventId, result.Id, previousResultStatus, 30, sessionId: session.Id); + return new ResultScreenCastOutput { ScreenCastId = screenCast.Id, @@ -441,6 +572,15 @@ public class WorkbenchController : IDynamicApiController if (!input.EventProjectId.HasValue || !input.EventProjectGroupId.HasValue) throw Oops.Bah("投出排名时,必须提供项目ID和组别ID"); + // 投屏排名前校验项目归属当前赛事、组别归属该项目 + var eventProject = await _eventProjectRepository.GetFirstAsync(ep => + ep.Id == input.EventProjectId.Value && ep.EventId == input.EventId && !ep.DeleteMark) + ?? throw Oops.Bah("所选项目不属于当前赛事"); + _ = eventProject; + if (!await _groupRepository.IsAnyAsync(g => g.Id == input.EventProjectGroupId.Value && + g.EventProjectId == input.EventProjectId.Value && !g.DeleteMark)) + throw Oops.Bah("所选组别不属于当前赛事项目"); + var results = await _resultRepository.AsQueryable() .InnerJoin((r, e) => r.SessionEntryId == e.Id) .InnerJoin((r, e, s) => e.SessionId == s.Id) @@ -451,11 +591,14 @@ public class WorkbenchController : IDynamicApiController e.GroupName == SqlFunc.Subqueryable() .Where(g => g.Id == input.EventProjectGroupId.Value) .Select(g => g.GroupName)) && - r.ResultStatus >= 20) + // 只投屏有效成绩(已确认20/已投屏30/已更正40),排除DQ成绩 + (r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40) && + !r.IsDisqualified) .OrderBy((r, e, s) => r.Rank) .Select((r, e, s) => new { r.Id, + r.ResultVersion, e.EntryNo, e.ParticipantName, e.TeamName, @@ -467,6 +610,9 @@ public class WorkbenchController : IDynamicApiController }) .ToListAsync(); + if (!results.Any()) + throw Oops.Bah("当前项目组别没有可投屏的已确认成绩"); + var payload = results.Select(r => new { r.EntryNo, @@ -479,6 +625,7 @@ public class WorkbenchController : IDynamicApiController r.IsDisqualified }).ToList(); + // 同赛事同场地此前投出中的记录统一置为“已被新投屏替换(20)”,保证大屏只展示最新一条 await _screenCastRepository.AsUpdateable() .SetColumns(sc => new EventResultScreenCast { CastStatus = 20 }, appendColumnsByDataFilter: true) @@ -508,6 +655,34 @@ public class WorkbenchController : IDynamicApiController await _screenCastRepository.InsertAsync(screenCast); + // 组别排名投屏后,组内全部成绩置为“已投屏(30)” + var rankedResultIds = results.Select(r => r.Id).ToList(); + await _resultRepository.AsUpdateable() + .SetColumns(r => new EventResult + { + ResultStatus = 30, + UpdatedUserId = currentUserId, + UpdatedTime = now + }, appendColumnsByDataFilter: true) + .Where(r => rankedResultIds.Contains(r.Id)) + .ExecuteCommandAsync(); + var rankedVersions = await _resultVersionRepository.AsQueryable() + .Where(v => !v.DeleteMark && rankedResultIds.Contains(v.EventResultId)) + .ToListAsync(); + // 只同步各成绩的当前版本,历史版本保留原状态留痕 + var currentRankedVersions = rankedVersions + .Where(v => results.Any(r => r.Id == v.EventResultId && r.ResultVersion == v.ResultVersion)) + .ToList(); + foreach (var version in currentRankedVersions) + { + version.ResultStatus = 30; + version.UpdatedUserId = currentUserId; + version.UpdatedTime = now; + } + if (currentRankedVersions.Count > 0) + await _resultVersionRepository.UpdateRangeAsync(currentRankedVersions); + await WriteAuditAsync("投出组别排名", input.EventId, input.EventProjectGroupId.Value, null, 30); + return new ResultScreenCastOutput { ScreenCastId = screenCast.Id, @@ -539,12 +714,15 @@ public class WorkbenchController : IDynamicApiController screenCast.UpdatedTime = DateTime.Now; await _screenCastRepository.UpdateAsync(screenCast); + await WriteAuditAsync("撤回投屏", screenCast.EventId, screenCast.Id, 0, 10, sessionId: screenCast.SessionId); } /// /// 获取当前投屏内容 /// [HttpGet("workbench/screencast/current")] + // 大屏展示端无登录态,允许匿名拉取当前投屏内容 + [AllowAnonymous] public async Task GetCurrentScreenCast([FromQuery] GetCurrentScreenCastInput input) { var query = _screenCastRepository.AsQueryable() @@ -560,12 +738,39 @@ public class WorkbenchController : IDynamicApiController if (screenCast == null) return null; + // 补全赛事、项目、组别、场地、场次与计分规则信息,供大屏标题区展示 + var eventInfo = await _eventRepository.GetByIdAsync(screenCast.EventId); + var venue = screenCast.VenueId.HasValue + ? await _venueRepository.GetByIdAsync(screenCast.VenueId.Value) + : null; + var eventProject = screenCast.EventProjectId.HasValue + ? await _eventProjectRepository.GetByIdAsync(screenCast.EventProjectId.Value) + : null; + var project = eventProject != null + ? await _projectRepository.GetByIdAsync(eventProject.ProjectId) + : null; + var group = screenCast.EventProjectGroupId.HasValue + ? await _groupRepository.GetByIdAsync(screenCast.EventProjectGroupId.Value) + : null; + var session = screenCast.SessionId.HasValue + ? await _sessionRepository.GetByIdAsync(screenCast.SessionId.Value) + : null; + var rule = session != null + ? await _scoreRuleRepository.GetByIdAsync(session.ScoreRuleId) + : null; + return new CurrentScreenCastDto { ScreenCastId = screenCast.Id, CastType = screenCast.CastType, CastStatus = screenCast.CastStatus, CastTime = screenCast.CastTime, + EventName = eventInfo?.EventName ?? string.Empty, + ProjectName = project?.Name ?? string.Empty, + ScoreRuleCode = rule?.RuleCode ?? string.Empty, + GroupName = group?.GroupName ?? string.Empty, + VenueName = venue?.VenueName ?? string.Empty, + SessionNo = session?.SessionNo ?? string.Empty, PayloadJson = screenCast.PayloadJson }; } @@ -692,6 +897,10 @@ public class WorkbenchController : IDynamicApiController Session = new WorkbenchSessionDto { SessionId = session.Id, + EventId = session.EventId, + VenueId = session.VenueId, + EventProjectId = session.EventProjectId, + EventProjectGroupId = session.EventProjectGroupId, SessionNo = session.SessionNo, SessionName = session.SessionName, EventName = eventInfo?.EventName ?? "", @@ -780,6 +989,7 @@ public class WorkbenchController : IDynamicApiController /// 审批成绩修正申请 /// [HttpPost("workbench/corrections/approve")] + [UnitOfWork] public async Task ApproveCorrection([FromBody] ApproveScoreCorrectionInput input) { var currentUserId = _authManager.UserId; @@ -802,7 +1012,10 @@ public class WorkbenchController : IDynamicApiController if (input.IsApproved) { + // 批准修正需向原裁判下发修正任务,原裁判分配已删除时无法继续 var assignment = await _assignmentRepository.GetByIdAsync(correction.JudgeAssignmentId); + if (assignment == null || assignment.DeleteMark) + throw Oops.Bah("原裁判分配不存在,无法下发修正任务"); var task = new EventJudgeTask { @@ -811,11 +1024,11 @@ public class WorkbenchController : IDynamicApiController SessionId = correction.SessionId, SessionEntryId = correction.SessionEntryId, JudgeAssignmentId = correction.JudgeAssignmentId, - EventRefereeId = assignment?.EventRefereeId ?? 0, - RefereeId = assignment?.RefereeId ?? 0, - JudgeUserId = assignment?.JudgeUserId, - JudgeRole = assignment?.JudgeRole ?? "", - JudgeRoleType = assignment?.JudgeRoleType ?? "", + EventRefereeId = assignment.EventRefereeId, + RefereeId = assignment.RefereeId, + JudgeUserId = assignment.JudgeUserId, + JudgeRole = assignment.JudgeRole, + JudgeRoleType = assignment.JudgeRoleType, TaskSnapshotJson = "{}", TaskType = 1, TaskStatus = 0, @@ -828,7 +1041,7 @@ public class WorkbenchController : IDynamicApiController correction.CorrectionTaskId = task.Id; await _correctionRepository.UpdateAsync(correction); - if (assignment?.JudgeUserId != null) + if (assignment.JudgeUserId != null) { var message = new EventJudgeMessage { @@ -849,6 +1062,9 @@ public class WorkbenchController : IDynamicApiController await _messageRepository.InsertAsync(message); } } + await WriteAuditAsync(input.IsApproved ? "批准成绩修正" : "驳回成绩修正", + correction.EventId, correction.Id, 0, correction.CorrectionStatus, + input.IsApproved ? correction.ApplyReason : input.ApproveRemark, correction.SessionId); } #endregion @@ -859,6 +1075,7 @@ public class WorkbenchController : IDynamicApiController /// 确认成绩 /// [HttpPost("workbench/results/confirm")] + [UnitOfWork] public async Task ConfirmResult([FromBody] ConfirmResultInput input) { var currentUserId = _authManager.UserId; @@ -873,19 +1090,26 @@ public class WorkbenchController : IDynamicApiController if (result.ResultStatus != 10) throw Oops.Bah("只能确认待复核状态的成绩"); - result.ResultStatus = 20; + // 当前版本由成绩修正产生时确认为“已更正(40)”,常规成绩确认为“已确认(20)” + var currentVersion = await _resultVersionRepository.GetFirstAsync(v => + !v.DeleteMark && v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion); + var confirmedStatus = currentVersion?.ScoreCorrectionId.HasValue == true ? 40 : 20; + result.ResultStatus = confirmedStatus; result.ReviewUserId = currentUserId; result.ReviewTime = now; result.UpdatedUserId = currentUserId; result.UpdatedTime = now; await _resultRepository.UpdateAsync(result); + await SyncResultVersionStatusAsync(result, confirmedStatus, currentUserId, now, currentVersion); + await WriteAuditAsync("确认成绩", result.EventId, result.Id, 10, confirmedStatus, sessionId: result.SessionId); } /// /// 批量确认成绩 /// [HttpPost("workbench/results/batch-confirm")] + [UnitOfWork] public async Task BatchConfirmResults([FromBody] BatchConfirmResultInput input) { var currentUserId = _authManager.UserId; @@ -895,36 +1119,61 @@ public class WorkbenchController : IDynamicApiController .InnerJoin((r, e) => r.SessionEntryId == e.Id) .Where((r, e) => !r.DeleteMark && !e.DeleteMark && e.SessionId == input.SessionId && r.ResultStatus == 10); - if (input.ResultIds != null && input.ResultIds.Any()) - { - query = query.Where((r, e) => input.ResultIds.Contains(r.Id)); - } + // 必须显式提交选中的成绩,过滤非法Id并去重,防止误确认整场次 + var requestedIds = input.ResultIds.Where(id => id > 0).Distinct().ToList(); + if (!requestedIds.Any()) + throw Oops.Bah("请选择至少一条待确认成绩"); + query = query.Where((r, e) => requestedIds.Contains(r.Id)); var results = await query.Select((r, e) => r).ToListAsync(); if (!results.Any()) throw Oops.Bah("没有可确认的成绩"); + if (results.Count != requestedIds.Count) + throw Oops.Bah("选中成绩中包含非当前场次或非待复核数据,请刷新后重试"); + // 批量取出当前版本,逐条同步确认状态(含修正产生的已更正40状态) + var resultIds = results.Select(r => r.Id).ToList(); + var resultVersions = await _resultVersionRepository.AsQueryable() + .Where(v => !v.DeleteMark && resultIds.Contains(v.EventResultId)) + .ToListAsync(); + var currentResultVersions = new List(); foreach (var result in results) { - result.ResultStatus = 20; + var version = resultVersions.FirstOrDefault(v => + v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion); + result.ResultStatus = version?.ScoreCorrectionId.HasValue == true ? 40 : 20; result.ReviewUserId = currentUserId; result.ReviewTime = now; result.UpdatedUserId = currentUserId; result.UpdatedTime = now; + if (version != null) + { + version.ResultStatus = result.ResultStatus; + version.UpdatedUserId = currentUserId; + version.UpdatedTime = now; + currentResultVersions.Add(version); + } } await _resultRepository.UpdateRangeAsync(results); + if (currentResultVersions.Count > 0) + await _resultVersionRepository.UpdateRangeAsync(currentResultVersions); + await WriteAuditAsync("批量确认成绩", results[0].EventId, input.SessionId, 10, + results.Any(r => r.ResultStatus == 40) ? "20/40" : "20", + $"成绩数量:{results.Count}", input.SessionId); } /// /// 创建重赛 /// [HttpPost("workbench/entries/rematch")] + [UnitOfWork] public async Task CreateRematch([FromBody] CreateRematchInput input) { var currentUserId = _authManager.UserId; var now = DateTime.Now; + var reason = NormalizeReason(input.RematchReason, "重赛原因"); var originalEntry = await _entryRepository.GetFirstAsync(e => e.Id == input.OriginalSessionEntryId && !e.DeleteMark) ?? throw Oops.Bah("原出场记录不存在"); @@ -932,14 +1181,31 @@ public class WorkbenchController : IDynamicApiController var session = await _sessionRepository.GetByIdAsync(originalEntry.SessionId) ?? throw Oops.Bah("场次不存在"); - var maxPlayOrder = await _entryRepository.AsQueryable() - .Where(e => !e.DeleteMark && e.SessionId == originalEntry.SessionId) + // 已作废的出场不能重复发起重赛,避免生成多条重赛记录 + if (originalEntry.IsVoided) + throw Oops.Bah("该出场记录已作废,不能重复创建重赛"); + + // 出场序号只在同组别内连续,重赛出场追加到本组别末尾 + var groupLastPlayOrder = await _entryRepository.AsQueryable() + .Where(e => !e.DeleteMark && e.SessionId == originalEntry.SessionId && e.GroupName == originalEntry.GroupName) .MaxAsync(e => e.PlayOrder); - var newPlayOrder = maxPlayOrder + 1; + var newPlayOrder = groupLastPlayOrder + 1; + // 占用该序号及之后的同组别出场倒序后移一位,避免出场序号冲突 + var laterEntries = await _entryRepository.AsQueryable() + .Where(e => !e.DeleteMark && e.SessionId == originalEntry.SessionId && e.PlayOrder >= newPlayOrder) + .OrderByDescending(e => e.PlayOrder) + .ToListAsync(); + foreach (var laterEntry in laterEntries) + { + laterEntry.PlayOrder++; + laterEntry.UpdatedUserId = currentUserId; + laterEntry.UpdatedTime = now; + await _entryRepository.UpdateAsync(laterEntry); + } originalEntry.IsVoided = true; - originalEntry.VoidReason = $"重赛原因:{input.RematchReason}"; + originalEntry.VoidReason = $"重赛原因:{reason}"; originalEntry.UpdatedUserId = currentUserId; originalEntry.UpdatedTime = now; await _entryRepository.UpdateAsync(originalEntry); @@ -951,6 +1217,7 @@ public class WorkbenchController : IDynamicApiController result.UpdatedUserId = currentUserId; result.UpdatedTime = now; await _resultRepository.UpdateAsync(result); + await SyncResultVersionStatusAsync(result, 90, currentUserId, now); } var newEntry = new EventSessionEntry @@ -977,6 +1244,7 @@ public class WorkbenchController : IDynamicApiController }; await _entryRepository.InsertAsync(newEntry); + await WriteAuditAsync("创建重赛", session.EventId, originalEntry.Id, "有效", "已作废并创建重赛", reason, session.Id); return new CreateRematchOutput { @@ -990,16 +1258,22 @@ public class WorkbenchController : IDynamicApiController /// 作废成绩 /// [HttpPost("workbench/results/void")] + [UnitOfWork] public async Task VoidResult([FromBody] VoidResultInput input) { var currentUserId = _authManager.UserId; var now = DateTime.Now; + var reason = NormalizeReason(input.VoidReason, "作废原因"); var entry = await _entryRepository.GetFirstAsync(e => e.Id == input.SessionEntryId && !e.DeleteMark) ?? throw Oops.Bah("出场记录不存在"); + // 已作废的出场记录直接拦截,避免重复写入作废状态与原因 + if (entry.IsVoided) + throw Oops.Bah("该出场记录已作废"); + entry.IsVoided = true; - entry.VoidReason = input.VoidReason; + entry.VoidReason = reason; entry.UpdatedUserId = currentUserId; entry.UpdatedTime = now; @@ -1012,7 +1286,10 @@ public class WorkbenchController : IDynamicApiController result.UpdatedUserId = currentUserId; result.UpdatedTime = now; await _resultRepository.UpdateAsync(result); + await SyncResultVersionStatusAsync(result, 90, currentUserId, now); } + var voidSession = await _sessionRepository.GetByIdAsync(entry.SessionId); + await WriteAuditAsync("作废成绩", voidSession?.EventId ?? 0, entry.Id, "有效", "已作废", reason, entry.SessionId); } #endregion @@ -1048,6 +1325,19 @@ public class WorkbenchController : IDynamicApiController var eventProjectId = input.EventProjectId.Value; query = query.Where((r, e, s) => s.EventProjectId == eventProjectId); } + // 支持按单个场次缩小导出范围 + if (input.SessionId.HasValue) + { + var sessionId = input.SessionId.Value; + query = query.Where((r, e, s) => e.SessionId == sessionId); + } + // 关键词匹配出场编号、参赛者姓名或队名 + if (!string.IsNullOrWhiteSpace(input.Keyword)) + { + var keyword = input.Keyword.Trim(); + query = query.Where((r, e, s) => e.EntryNo.Contains(keyword) || e.ParticipantName.Contains(keyword) || + (e.TeamName != null && e.TeamName.Contains(keyword))); + } var flat = await query .OrderBy((r, e, s) => r.Rank) @@ -1378,6 +1668,72 @@ public class WorkbenchController : IDynamicApiController }; } + /// + /// 规范化重赛/作废原因:去除首尾空格并校验长度为2-200个字符 + /// + private static string NormalizeReason(string? value, string fieldName) + { + var reason = value?.Trim() ?? string.Empty; + if (reason.Length < 2 || reason.Length > 200) + throw Oops.Bah($"{fieldName}长度必须为2-200个字符"); + return reason; + } + + /// + /// 同步成绩当前版本的状态,保证 EventResult 与 EventResultVersion 状态一致 + /// + private async Task SyncResultVersionStatusAsync(EventResult result, int status, long userId, + DateTime now, EventResultVersion? version = null) + { + version ??= await _resultVersionRepository.GetFirstAsync(v => + !v.DeleteMark && v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion); + if (version == null) return; + version.ResultStatus = status; + version.UpdatedUserId = userId; + version.UpdatedTime = now; + await _resultVersionRepository.UpdateAsync(version); + } + + /// + /// 写入工作台业务操作审计日志,记录投屏、确认、修正、重赛、作废等关键动作的状态流转 + /// + private async Task WriteAuditAsync(string action, long eventId, long objectId, + object? beforeStatus, object? afterStatus, string? reason = null, long? sessionId = null) + { + var parameter = System.Text.Json.JsonSerializer.Serialize(new + { + EventId = eventId, + SessionId = sessionId, + ObjectId = objectId, + BeforeStatus = beforeStatus, + AfterStatus = afterStatus, + Reason = reason + }); + await _operationLogRepository.InsertAsync(new SysOperationLog + { + Id = _idGenerator.NextId(), + UserId = _authManager.UserId, + LogLevel = Microsoft.Extensions.Logging.LogLevel.Information, + Description = action, + ActionName = action, + ControllerName = nameof(WorkbenchController), + HttpMethod = "BUSINESS", + HttpStatusCode = 200, + Message = action, + RequestUrl = "/workbench/audit", + Parameter = parameter, + Response = null, + TraceId = string.Empty, + ThreadId = Environment.CurrentManagedThreadId, + UserAgent = string.Empty, + RemoteIp = string.Empty, + Location = string.Empty, + OsDescription = null, + Elapsed = 0, + CreatedTime = DateTime.Now + }); + } + private static string GetSheetStatusName(int status) { return status switch diff --git a/YD_Event.Application/YD_Event.Application.csproj b/YD_Event.Application/YD_Event.Application.csproj index 6995eaf..8a1c677 100644 --- a/YD_Event.Application/YD_Event.Application.csproj +++ b/YD_Event.Application/YD_Event.Application.csproj @@ -36,8 +36,8 @@ - + - \ No newline at end of file + diff --git a/YD_Event.Core/InitData/sysmenu.txt b/YD_Event.Core/InitData/sysmenu.txt index 17af1a1..77bee94 100644 --- a/YD_Event.Core/InitData/sysmenu.txt +++ b/YD_Event.Core/InitData/sysmenu.txt @@ -1890,35 +1890,12 @@ "CreatedUserId": 1000, "CreatedTime": "2023-06-01" }, - { - "Id": 1080, - "Name": "单位管理", - "Type": 1, - "Code": "system:unit", - "ParentId": 1070, - "RouteName": "systemunit", - "Path": "/system/unit", - "Component": "system/unit/index", - "Redirect": "", - "Icon": "ele-OfficeBuilding", - "IsIframe": "0", - "Link": "", - "IsVisible": "1", - "IsKeepAlive": "1", - "IsFixed": "0", - "Status": 0, - "Sort": 6, - "Remark": "报名平台/打分系统账号管理", - "DeleteMark": false, - "CreatedUserId": 1000, - "CreatedTime": "2023-06-01" - }, { "Id": 1081, - "Name": "新增", + "Name": "打分账号-新增", "Type": 2, "Code": "system:unit:add", - "ParentId": 1080, + "ParentId": 1047, "RouteName": "btn_system_unit_add", "Path": "", "Component": "", @@ -1930,18 +1907,18 @@ "IsKeepAlive": "1", "IsFixed": "0", "Status": 0, - "Sort": 1, - "Remark": "", + "Sort": 11, + "Remark": "权限管理-账号管理:打分系统账号新增", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01" }, { "Id": 1082, - "Name": "编辑", + "Name": "打分账号-编辑", "Type": 2, "Code": "system:unit:edit", - "ParentId": 1080, + "ParentId": 1047, "RouteName": "btn_system_unit_edit", "Path": "", "Component": "", @@ -1953,18 +1930,18 @@ "IsKeepAlive": "1", "IsFixed": "0", "Status": 0, - "Sort": 2, - "Remark": "", + "Sort": 12, + "Remark": "权限管理-账号管理:打分系统账号编辑", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01" }, { "Id": 1083, - "Name": "删除", + "Name": "打分账号-删除", "Type": 2, "Code": "system:unit:delete", - "ParentId": 1080, + "ParentId": 1047, "RouteName": "btn_system_unit_delete", "Path": "", "Component": "", @@ -1976,18 +1953,18 @@ "IsKeepAlive": "1", "IsFixed": "0", "Status": 0, - "Sort": 3, - "Remark": "", + "Sort": 13, + "Remark": "权限管理-账号管理:打分系统账号删除", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01" }, { "Id": 1084, - "Name": "启用禁用", + "Name": "打分账号-启用禁用", "Type": 2, "Code": "system:unit:status", - "ParentId": 1080, + "ParentId": 1047, "RouteName": "btn_system_unit_status", "Path": "", "Component": "", @@ -1999,18 +1976,18 @@ "IsKeepAlive": "1", "IsFixed": "0", "Status": 0, - "Sort": 4, - "Remark": "", + "Sort": 14, + "Remark": "权限管理-账号管理:打分系统账号状态", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01" }, { "Id": 1085, - "Name": "重置密码", + "Name": "打分账号-重置密码", "Type": 2, "Code": "system:unit:resetpwd", - "ParentId": 1080, + "ParentId": 1047, "RouteName": "btn_system_unit_resetpwd", "Path": "", "Component": "", @@ -2022,8 +1999,8 @@ "IsKeepAlive": "1", "IsFixed": "0", "Status": 0, - "Sort": 5, - "Remark": "", + "Sort": 15, + "Remark": "权限管理-账号管理:打分系统账号重置密码", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01" diff --git a/YD_Event.Core/InitData/sysrolemenu.txt b/YD_Event.Core/InitData/sysrolemenu.txt index 98fa6f6..1105abe 100644 --- a/YD_Event.Core/InitData/sysrolemenu.txt +++ b/YD_Event.Core/InitData/sysrolemenu.txt @@ -679,14 +679,6 @@ "CreatedTime": "2023-06-01", "DeleteMark": false }, - { - "Id": 1101, - "RoleId": 1000, - "MenuId": 1080, - "CreatedUserId": 1000, - "CreatedTime": "2023-06-01", - "DeleteMark": false - }, { "Id": 1102, "RoleId": 1000, @@ -727,4 +719,4 @@ "CreatedTime": "2023-06-01", "DeleteMark": false } -] \ No newline at end of file +] diff --git a/二期需求/dbo.sql b/二期需求/dbo.sql index 7939861..ea22f6f 100644 --- a/二期需求/dbo.sql +++ b/二期需求/dbo.sql @@ -1,7 +1,7 @@ /* ======================================================================================== YD_Event 二期生产环境部署脚本 - 版本:3.0 | 基线提交:25830581d2add95f2d659e663f3d78b766ce0946 + 版本:3.1 | 基线提交:25830581d2add95f2d659e663f3d78b766ce0946 数据库:Microsoft SQL Server 2016+(兼容级别 130+) 执行示例: @@ -31,7 +31,7 @@ SET QUOTED_IDENTIFIER ON; BEGIN TRY BEGIN TRANSACTION; DECLARE @LockResult INT; - EXEC @LockResult=sys.sp_getapplock @Resource=N'YD_Event_Phase2_Production_Deployment_v3',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=10000; + EXEC @LockResult=sys.sp_getapplock @Resource=N'YD_Event_Phase2_Production_Deployment',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=10000; IF @LockResult<0 THROW 51002,N'无法取得二期部署排他锁,可能有另一个部署正在执行。',1; /* ==================================================================================== @@ -688,10 +688,18 @@ BEGIN TRY IF NOT EXISTS(SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID(N'dbo.[EventResultVersion]') AND name=N'UX_EventResultVersion_Result_Version') CREATE UNIQUE NONCLUSTERED INDEX [UX_EventResultVersion_Result_Version] ON dbo.[EventResultVersion]([EventResultId],[ResultVersion]) WHERE [DeleteMark]=0; IF NOT EXISTS(SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID(N'dbo.[EventJudgeMessage]') AND name=N'IX_EventJudgeMessage_Receiver_Unread') CREATE NONCLUSTERED INDEX [IX_EventJudgeMessage_Receiver_Unread] ON dbo.[EventJudgeMessage]([ReceiverUserId],[IsRead],[CreatedTime]) WHERE [DeleteMark]=0; IF NOT EXISTS(SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID(N'dbo.[EventResultScreenCast]') AND name=N'IX_EventResultScreenCast_Event_Time') CREATE NONCLUSTERED INDEX [IX_EventResultScreenCast_Event_Time] ON dbo.[EventResultScreenCast]([EventId],[CastTime]) WHERE [DeleteMark]=0; + /* 同一赛事同一场地只允许保留一条投出中的投屏:先把重复的历史投屏置为“已替换(20)”,再创建唯一筛选索引兜底 */ + ;WITH ActiveCast AS ( + SELECT Id,ROW_NUMBER() OVER(PARTITION BY EventId,VenueId ORDER BY CastTime DESC,Id DESC) AS rn + FROM dbo.EventResultScreenCast WHERE CastStatus=0 AND DeleteMark=0 + ) + UPDATE sc SET CastStatus=20,UpdatedUserId=1000,UpdatedTime=SYSUTCDATETIME() + FROM dbo.EventResultScreenCast sc JOIN ActiveCast a ON a.Id=sc.Id WHERE a.rn>1; + IF NOT EXISTS(SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID(N'dbo.[EventResultScreenCast]') AND name=N'UX_EventResultScreenCast_Current') CREATE UNIQUE NONCLUSTERED INDEX [UX_EventResultScreenCast_Current] ON dbo.[EventResultScreenCast]([EventId],[VenueId]) WHERE [CastStatus]=0 AND [DeleteMark]=0; /* ==================================================================================== [4/9] 插入数据 · 初始化种子(统一“存在则更新、缺失才插入”的幂等写法,可重复执行) - 顺序:4.1 计分规则(4条) -> 规则Id映射 -> 4.2 规则明细(230条) -> 4.3 角色 -> 4.4 菜单 -> 4.5 角色菜单授权 + 顺序:4.1 计分规则(4条) -> 规则Id映射 -> 4.2 规则明细(230条) -> 4.3 角色 -> 4.4 账号管理按钮 -> 4.5 角色菜单授权 ==================================================================================== */ PRINT N'[4/9] 插入数据:初始化计分规则、规则明细、角色与菜单'; /* 4.1 四条内置计分规则(固定Id:3000规定赛/3100车轮跳花样/3200交互绳花样/3300表演赛):先按 RuleCode 更新,再插入缺失,Id冲突则报错 */ @@ -972,15 +980,23 @@ BEGIN TRY INSERT dbo.SysRole(Id,Name,Code,Status,Sort,Remark,DeleteMark,CreatedUserId,CreatedTime) VALUES(1003,N'打分系统单位',N'scoringunit',0,4,N'打分系统单位账号',0,1000,'2023-06-01'); ELSE UPDATE dbo.SysRole SET Name=N'打分系统单位',Code=N'scoringunit',Status=0,Sort=4,Remark=N'打分系统单位账号',DeleteMark=0 WHERE Id=1003; - /* 4.4 菜单/按钮:1080 单位管理及其5个按钮(1081-1085)、2010 裁判管理;Id被其他编码占用则报错 */ + /* 4.4 菜单/按钮:账号管理下的打分账号按钮(1081-1085)、2010 裁判管理;不再创建“系统管理-单位管理” */ + IF NOT EXISTS(SELECT 1 FROM dbo.SysMenu WHERE Id=1047 AND Code=N'permission:account' AND DeleteMark=0) + THROW 51006,N'未找到权限管理-账号管理菜单(Id=1047,Code=permission:account)。',1; + IF EXISTS(SELECT 1 FROM dbo.SysMenu WHERE Id=1080 AND ISNULL(Code,N'')<>N'system:unit') + THROW 51006,N'SysMenu.Id=1080 已被其他菜单占用。',1; + /* 兼容曾执行过旧版脚本的环境:停用旧菜单及其角色授权,业务统一归入账号管理。 */ + UPDATE dbo.SysRoleMenu SET DeleteMark=1,UpdatedUserId=1000,UpdatedTime=SYSUTCDATETIME() + WHERE MenuId=1080 AND DeleteMark=0; + UPDATE dbo.SysMenu SET IsVisible=0,Status=1,DeleteMark=1,Remark=N'功能已迁移至权限管理-账号管理',UpdatedUserId=1000,UpdatedTime=SYSUTCDATETIME() + WHERE Id=1080 AND Code=N'system:unit' AND DeleteMark=0; CREATE TABLE #MenuSeed(Id BIGINT PRIMARY KEY,Name NVARCHAR(32),Type INT,Code NVARCHAR(128),ParentId BIGINT,RouteName NVARCHAR(32),Path NVARCHAR(128),Component NVARCHAR(128),Redirect NVARCHAR(128),Icon NVARCHAR(64),IsIframe BIT,Link NVARCHAR(256),IsVisible BIT,IsKeepAlive BIT,IsFixed BIT,Status INT,Sort INT,Remark NVARCHAR(256),DeleteMark BIT,CreatedUserId BIGINT,CreatedTime DATETIME2(3)); INSERT #MenuSeed VALUES - (1080,N'单位管理',1,N'system:unit',1070,N'systemunit',N'/system/unit',N'system/unit/index',N'',N'ele-OfficeBuilding',N'0',N'',N'1',N'1',N'0',0,6,N'报名平台/打分系统账号管理',0,1000,N'2023-06-01'), - (1081,N'新增',2,N'system:unit:add',1080,N'btn_system_unit_add',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,1,N'',0,1000,N'2023-06-01'), - (1082,N'编辑',2,N'system:unit:edit',1080,N'btn_system_unit_edit',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,2,N'',0,1000,N'2023-06-01'), - (1083,N'删除',2,N'system:unit:delete',1080,N'btn_system_unit_delete',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,3,N'',0,1000,N'2023-06-01'), - (1084,N'启用禁用',2,N'system:unit:status',1080,N'btn_system_unit_status',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,4,N'',0,1000,N'2023-06-01'), - (1085,N'重置密码',2,N'system:unit:resetpwd',1080,N'btn_system_unit_resetpwd',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,5,N'',0,1000,N'2023-06-01'), + (1081,N'打分账号-新增',2,N'system:unit:add',1047,N'btn_system_unit_add',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,11,N'权限管理-账号管理:打分系统账号新增',0,1000,N'2023-06-01'), + (1082,N'打分账号-编辑',2,N'system:unit:edit',1047,N'btn_system_unit_edit',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,12,N'权限管理-账号管理:打分系统账号编辑',0,1000,N'2023-06-01'), + (1083,N'打分账号-删除',2,N'system:unit:delete',1047,N'btn_system_unit_delete',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,13,N'权限管理-账号管理:打分系统账号删除',0,1000,N'2023-06-01'), + (1084,N'打分账号-启用禁用',2,N'system:unit:status',1047,N'btn_system_unit_status',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,14,N'权限管理-账号管理:打分系统账号状态',0,1000,N'2023-06-01'), + (1085,N'打分账号-重置密码',2,N'system:unit:resetpwd',1047,N'btn_system_unit_resetpwd',N'',N'',N'',N'',N'0',N'',N'0',N'1',N'0',0,15,N'权限管理-账号管理:打分系统账号重置密码',0,1000,N'2023-06-01'), (2010,N'裁判管理',1,N'referee',NULL,N'referee',N'/referee',N'match/referee/index',N'',N'ele-UserFilled',N'0',N'',N'1',N'1',N'0',0,245,N'裁判库管理',0,1000,N'2023-06-01'); IF EXISTS(SELECT 1 FROM #MenuSeed s JOIN dbo.SysMenu m ON m.Id=s.Id WHERE ISNULL(m.Code,N'')<>ISNULL(s.Code,N'')) THROW 51006,N'二期菜单ID已被其他菜单占用。',1; UPDATE m SET Name=s.Name,Type=s.Type,Code=s.Code,ParentId=s.ParentId,RouteName=s.RouteName,Path=s.Path,Component=s.Component,Redirect=s.Redirect,Icon=s.Icon,IsIframe=s.IsIframe,Link=s.Link,IsVisible=s.IsVisible,IsKeepAlive=s.IsKeepAlive,IsFixed=s.IsFixed,Status=s.Status,Sort=s.Sort,Remark=s.Remark,DeleteMark=0,UpdatedUserId=1000,UpdatedTime=SYSUTCDATETIME() @@ -990,9 +1006,9 @@ BEGIN TRY INSERT dbo.SysMenu(Id,Name,Type,Code,ParentId,RouteName,Path,Component,Redirect,Icon,IsIframe,Link,IsVisible,IsKeepAlive,IsFixed,Status,Sort,Remark,DeleteMark,CreatedUserId,CreatedTime) SELECT Id,Name,Type,Code,ParentId,RouteName,Path,Component,Redirect,Icon,IsIframe,Link,IsVisible,IsKeepAlive,IsFixed,Status,Sort,Remark,DeleteMark,CreatedUserId,CreatedTime FROM #MenuSeed s WHERE NOT EXISTS(SELECT 1 FROM dbo.SysMenu m WHERE m.Id=s.Id); - /* 4.5 角色-菜单授权:把上述菜单授予超管角色 RoleId=1000(关系Id 1101-1110,幂等) */ + /* 4.5 角色-菜单授权:账号管理入口为一期既有授权,仅补充打分账号按钮与裁判菜单 */ DECLARE @RoleMenuSeed TABLE(Id BIGINT PRIMARY KEY,RoleId BIGINT,MenuId BIGINT); - INSERT @RoleMenuSeed VALUES(1101,1000,1080),(1102,1000,1081),(1103,1000,1082),(1104,1000,1083),(1105,1000,1084),(1106,1000,1085),(1110,1000,2010); + INSERT @RoleMenuSeed VALUES(1102,1000,1081),(1103,1000,1082),(1104,1000,1083),(1105,1000,1084),(1106,1000,1085),(1110,1000,2010); IF EXISTS(SELECT 1 FROM @RoleMenuSeed s JOIN dbo.SysRoleMenu rm ON rm.Id=s.Id WHERE rm.RoleId<>s.RoleId OR rm.MenuId<>s.MenuId) THROW 51007,N'二期角色菜单关系ID已被占用。',1; UPDATE rm SET DeleteMark=0,UpdatedUserId=1000,UpdatedTime=SYSUTCDATETIME() FROM dbo.SysRoleMenu rm JOIN @RoleMenuSeed s ON s.RoleId=rm.RoleId AND s.MenuId=rm.MenuId WHERE rm.DeleteMark<>0; INSERT dbo.SysRoleMenu(Id,RoleId,MenuId,CreatedUserId,CreatedTime,DeleteMark) @@ -1003,9 +1019,9 @@ BEGIN TRY 1个标量函数 + 3个视图 + 2个存储过程 ==================================================================================== */ PRINT N'[5/9] 可编程对象:创建函数、视图与存储过程'; - /* 标量函数:成绩状态码 -> 中文名(0草稿/10待确认/20已确认/30已发布/40已投屏/90作废) */ + /* 标量函数:成绩状态码 -> 中文名(0计算中/10待复核/20已确认/30已投屏/40已更正/90作废) */ EXEC(N'CREATE OR ALTER FUNCTION dbo.fn_Phase2_ResultStatusName(@ResultStatus INT) -RETURNS NVARCHAR(32) AS BEGIN RETURN CASE @ResultStatus WHEN 0 THEN N''草稿'' WHEN 10 THEN N''待确认'' WHEN 20 THEN N''已确认'' WHEN 30 THEN N''已发布'' WHEN 40 THEN N''已投屏'' WHEN 90 THEN N''已作废'' ELSE N''未知'' END END'); +RETURNS NVARCHAR(32) AS BEGIN RETURN CASE @ResultStatus WHEN 0 THEN N''计算中'' WHEN 10 THEN N''待复核'' WHEN 20 THEN N''已确认'' WHEN 30 THEN N''已投屏'' WHEN 40 THEN N''已更正'' WHEN 90 THEN N''已作废'' ELSE N''未知'' END END'); /* 视图:裁判席位分配明细(关联场次/场地/裁判档案) */ EXEC(N'CREATE OR ALTER VIEW dbo.vw_Phase2_JudgeAssignment AS @@ -1130,7 +1146,11 @@ SELECT r.RuleCode,COUNT(i.Id) AS ItemCount FROM dbo.ScoreRule r LEFT JOIN dbo.Sc (N'CONSTRAINT',N'二期外键',CASE WHEN (SELECT COUNT(*) FROM sys.foreign_keys WHERE name LIKE 'FK[_]%' AND parent_object_id IN(SELECT OBJECT_ID(N'dbo.'+QUOTENAME(Name)) FROM @ExpectedTables))>=20 THEN 1 ELSE 0 END,CONVERT(NVARCHAR(20),(SELECT COUNT(*) FROM sys.foreign_keys WHERE name LIKE 'FK[_]%' AND parent_object_id IN(SELECT OBJECT_ID(N'dbo.'+QUOTENAME(Name)) FROM @ExpectedTables)))), (N'SEED',N'计分规则',CASE WHEN (SELECT COUNT(DISTINCT r.RuleCode) FROM dbo.ScoreRule r JOIN #RuleSeed s ON s.RuleCode=r.RuleCode WHERE r.DeleteMark=0)=4 THEN 1 ELSE 0 END,N'期望4条'), (N'SEED',N'计分规则明细',CASE WHEN (SELECT COUNT(*) FROM #ItemSeed s JOIN #RuleMap m ON m.SourceId=s.SourceRuleId JOIN dbo.ScoreRuleItem i ON i.ScoreRuleId=m.TargetId AND i.JudgeRoleType=s.JudgeRoleType AND i.ItemCode=s.ItemCode AND i.DeleteMark=0)=230 THEN 1 ELSE 0 END,N'期望230条'), - (N'SEED',N'角色与菜单',CASE WHEN EXISTS(SELECT 1 FROM dbo.SysRole WHERE Id=1003 AND Code='scoringunit' AND DeleteMark=0) AND (SELECT COUNT(*) FROM dbo.SysMenu WHERE Id IN(1080,1081,1082,1083,1084,1085,2010) AND DeleteMark=0)=7 THEN 1 ELSE 0 END,N'角色1条、菜单7条'), + (N'SEED',N'角色与菜单',CASE WHEN EXISTS(SELECT 1 FROM dbo.SysRole WHERE Id=1003 AND Code='scoringunit' AND DeleteMark=0) + AND (SELECT COUNT(*) FROM dbo.SysMenu WHERE Id IN(1081,1082,1083,1084,1085,2010) AND DeleteMark=0)=6 + AND (SELECT COUNT(*) FROM dbo.SysMenu WHERE Id IN(1081,1082,1083,1084,1085) AND ParentId=1047 AND DeleteMark=0)=5 + AND NOT EXISTS(SELECT 1 FROM dbo.SysMenu WHERE Id=1080 AND DeleteMark=0) + THEN 1 ELSE 0 END,N'角色1条、账号管理按钮5条、裁判菜单1条、无单位管理菜单'), (N'PERMISSION',N'最小权限角色',CASE WHEN (SELECT COUNT(*) FROM sys.database_principals WHERE name IN('yd_phase2_app_rw','yd_phase2_app_ro','yd_phase2_executor') AND type='R')=3 THEN 1 ELSE 0 END,N'期望3个数据库角色'); SELECT N'POSTCHECK' AS [Stage],[Category],[Item],CASE WHEN Passed=1 THEN N'PASS' ELSE N'FAIL' END AS [Result],[Actual] FROM @Validation ORDER BY Passed,[Category],[Item];