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];