using YD_Event.Application.Auth; using YD_Event.Application.Common; using YD_Event.Application.Event; using YD_Event.Application.Scoring.Dtos; using System.Text.RegularExpressions; namespace YD_Event.Application.Scoring; /// /// 打分系统裁判端评分接口(独立于微信小程序,供裁判端页面调用)。 /// 规定赛计分(totalMode=linear):创意编排裁判创意分取平均记作 T1,完成质量裁判完成分取平均记作 T2, /// 主裁每项违例从 100 分中扣 5 分记作 T3,总分 = T1 + T2 - T3。 /// 花样赛计分(totalMode=factor):两人车轮跳花样 R = D × (1 + P) × Q × M, /// 其中 D = (dA + dB) / 2、P = 表现裁判均值 × 0.05、Q = max(0, 1 - 0.05 × 规定元素缺失类别数)、 /// M = max(0, 1 - 最终扣分合计);失误次数由表现裁判与主裁按人数平均,其他扣分仍取主裁记录; /// 交互绳花样 R = D × (1 + P) × M,其中 D = dJ × 1.4 + dT、P = 表现裁判均值 × 0.033,不计 Q。 /// 表演赛 R = D × (1 + P) × Q × M,其中 D = (A + B) / 2(A/B 组难度裁判各自取平均)、P = 表现裁判均值 × 0.05、 /// Q = max(0, 1 - 0.05 × 5 类规定元素缺失数)、M = max(0, 1 - (失误 + 违例))。 /// 裁判提交的分值一律在服务端按计分规则重算,客户端上传的合计值不参与落库。 /// [ApiDescriptionSettings("打分系统")] [Route("api/judgescoring")] [Authorize] public class JudgeScoringController : IDynamicApiController { private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _refereeRepository; private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _venueRepository; private readonly ISqlSugarRepository _eventProjectRepository; private readonly ISqlSugarRepository _projectRepository; private readonly ISqlSugarRepository _ruleRepository; private readonly ISqlSugarRepository _sessionRepository; private readonly ISqlSugarRepository _entryRepository; private readonly ISqlSugarRepository _assignmentRepository; private readonly ISqlSugarRepository _taskRepository; private readonly ISqlSugarRepository _sheetRepository; private readonly ISqlSugarRepository _detailRepository; private readonly ISqlSugarRepository _correctionRepository; private readonly ISqlSugarRepository _messageRepository; private readonly ISqlSugarRepository _attachmentRepository; private readonly ISqlSugarRepository _resultRepository; private readonly ISqlSugarRepository _personRepository; private readonly ISqlSugarRepository _participationRepository; private readonly EventScoreCalculationService _calculationService; private readonly AuthManager _authManager; private readonly IIdGenerator _idGenerator; private readonly FileService _fileService; public JudgeScoringController( ISqlSugarRepository userRepository, ISqlSugarRepository refereeRepository, ISqlSugarRepository eventRepository, ISqlSugarRepository venueRepository, ISqlSugarRepository eventProjectRepository, ISqlSugarRepository projectRepository, ISqlSugarRepository ruleRepository, ISqlSugarRepository sessionRepository, ISqlSugarRepository entryRepository, ISqlSugarRepository assignmentRepository, ISqlSugarRepository taskRepository, ISqlSugarRepository sheetRepository, ISqlSugarRepository detailRepository, ISqlSugarRepository correctionRepository, ISqlSugarRepository messageRepository, ISqlSugarRepository attachmentRepository, ISqlSugarRepository resultRepository, ISqlSugarRepository personRepository, ISqlSugarRepository participationRepository, EventScoreCalculationService calculationService, AuthManager authManager, IIdGenerator idGenerator, FileService fileService) { _userRepository = userRepository; _refereeRepository = refereeRepository; _eventRepository = eventRepository; _venueRepository = venueRepository; _eventProjectRepository = eventProjectRepository; _projectRepository = projectRepository; _ruleRepository = ruleRepository; _sessionRepository = sessionRepository; _entryRepository = entryRepository; _assignmentRepository = assignmentRepository; _taskRepository = taskRepository; _sheetRepository = sheetRepository; _detailRepository = detailRepository; _correctionRepository = correctionRepository; _messageRepository = messageRepository; _attachmentRepository = attachmentRepository; _resultRepository = resultRepository; _personRepository = personRepository; _participationRepository = participationRepository; _calculationService = calculationService; _authManager = authManager; _idGenerator = idGenerator; _fileService = fileService; } /// /// 获取我的执裁场次 /// [HttpGet("sessions")] [DisplayName("获取我的执裁场次")] public async Task> GetSessions([FromQuery] long? eventId = null) { var referee = await EnsureRefereeAsync(); var assignments = await _assignmentRepository.AsQueryable() .Where(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .WhereIF(eventId.HasValue, x => x.EventId == eventId!.Value) .ToListAsync(); if (assignments.Count == 0) return new List(); var sessionIds = assignments.Select(x => x.SessionId).Distinct().ToList(); var sessions = await _sessionRepository.AsQueryable() .Where(x => sessionIds.Contains(x.Id) && x.SessionStatus != 90 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); if (sessions.Count == 0) return new List(); var eventIds = sessions.Select(x => x.EventId).Distinct().ToList(); var events = (await _eventRepository.AsQueryable() .Where(x => eventIds.Contains(x.Id) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync()) .ToDictionary(x => x.Id); var now = DateTime.Now; EventInfo targetEvent; if (eventId.HasValue) { targetEvent = events.GetValueOrDefault(eventId.Value); } else { targetEvent = events.Values .Where(x => GetEffectiveEventStatus(x, now) == EventStatus.Ongoing) .OrderBy(x => x.MatchStartTime) .ThenBy(x => x.Id) .FirstOrDefault() ?? events.Values .Where(x => GetEffectiveEventStatus(x, now) != EventStatus.Ended && x.MatchStartTime > now) .OrderBy(x => x.MatchStartTime) .ThenBy(x => x.Id) .FirstOrDefault(); } if (targetEvent == null) return new List(); sessions = sessions.Where(x => x.EventId == targetEvent.Id).ToList(); assignments = assignments.Where(x => x.EventId == targetEvent.Id).ToList(); sessionIds = sessions.Select(x => x.Id).ToList(); var venues = (await _venueRepository.AsQueryable() .Where(x => sessions.Select(s => s.VenueId).Contains(x.Id) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync()).ToDictionary(x => x.Id); var eventProjects = (await _eventProjectRepository.AsQueryable() .Where(x => sessions.Select(s => s.EventProjectId).Contains(x.Id) && !x.DeleteMark) .ToListAsync()).ToDictionary(x => x.Id); var projects = (await _projectRepository.AsQueryable() .Where(x => eventProjects.Values.Select(e => e.ProjectId).Contains(x.Id) && !x.DeleteMark) .ToListAsync()).ToDictionary(x => x.Id); var rules = (await _ruleRepository.AsQueryable() .Where(x => sessions.Select(s => s.ScoreRuleId).Contains(x.Id) && !x.DeleteMark) .ToListAsync()).ToDictionary(x => x.Id); sessions = sessions .OrderBy(x => GetArrangementSequence(x.SessionNo)) .ThenBy(x => eventProjects.TryGetValue(x.EventProjectId, out var eventProject) && projects.TryGetValue(eventProject.ProjectId, out var project) ? project.SortIndex : int.MaxValue) .ThenBy(x => eventProjects.TryGetValue(x.EventProjectId, out var eventProject) ? eventProject.ProjectId : long.MaxValue) .ThenBy(x => venues.TryGetValue(x.VenueId, out var venue) ? venue.Sort : int.MaxValue) .ThenBy(x => x.SessionNo) .ThenBy(x => x.Id) .ToList(); var entries = await _entryRepository.AsQueryable() .Where(x => sessionIds.Contains(x.SessionId) && !x.IsVoided && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); var mySheets = await _sheetRepository.AsQueryable() .Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id && x.IsCurrent && !x.DeleteMark && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync(); var rows = new List(); foreach (var session in sessions) { var assignment = assignments.FirstOrDefault(x => x.SessionId == session.Id); if (assignment == null) continue; var roleType = JudgeRoleSchemaService.NormalizeRoleType( string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType); var project = eventProjects.TryGetValue(session.EventProjectId, out var ep) && projects.TryGetValue(ep.ProjectId, out var p) ? p : null; var rule = rules.GetValueOrDefault(session.ScoreRuleId); var slot = JudgeRoleSchemaService.ParseSlots(rule?.JudgeRoleSchemaJson) .FirstOrDefault(x => string.Equals(x.Code, roleType, StringComparison.OrdinalIgnoreCase)); rows.Add(new JudgeSessionDto { SessionId = session.Id, EventId = session.EventId, EventName = events.GetValueOrDefault(session.EventId)?.EventName ?? string.Empty, ProjectName = project?.Name ?? string.Empty, RuleName = rule?.RuleName ?? string.Empty, SessionName = session.SessionName ?? session.SessionNo, VenueName = venues.GetValueOrDefault(session.VenueId)?.VenueName ?? "-", GroupName = entries.Where(x => x.SessionId == session.Id).Select(x => x.GroupName) .Distinct().Count() == 1 ? entries.First(x => x.SessionId == session.Id).GroupName : null, StartTime = session.StartTime, SessionStatus = session.SessionStatus, SessionStatusName = SessionStatusName(session.SessionStatus), JudgeRole = assignment.JudgeRole, JudgeRoleType = roleType, JudgeRoleName = slot?.Name ?? JudgeRoleSchemaService.RoleName(roleType), EntryCount = entries.Count(x => x.SessionId == session.Id), ScoredCount = mySheets.Count(x => x.SessionId == session.Id), ArrangementOrder = GetArrangementSequence(session.SessionNo) is var order && order < int.MaxValue ? order : 0 }); } return rows; } /// /// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。 /// /// 场次ID。 /// 当前场次的赛事状态和操作权限。 [HttpGet("status/{sessionId}")] [DisplayName("查询当前赛事状态")] public async Task GetCurrentEventStatus(long sessionId) { var referee = await EnsureRefereeAsync(); await GetMyAssignmentAsync(sessionId, referee.Id); var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId); if (session == null) throw Oops.Bah("比赛场次不存在"); var eventInfo = await GetEventAsync(session.EventId); var canScore = CompetitionStagePolicy.CanScore(session.SessionStatus, eventInfo.EventStatus); return new JudgeEventStatusDto { EventId = session.EventId, SessionId = session.Id, SessionStatus = session.SessionStatus, SessionStatusName = SessionStatusName(session.SessionStatus), Phase = CompetitionStagePolicy.PhaseOf(session.SessionStatus, eventInfo.EventStatus), PhaseName = CompetitionStagePolicy.PhaseName(session.SessionStatus, eventInfo.EventStatus), CanScore = canScore, CanModifyAssignment = CompetitionStagePolicy.CanModifyAssignment(session.SessionStatus, eventInfo.EventStatus), Message = canScore ? null : CompetitionStagePolicy.ScoreLockReason(session.SessionStatus, eventInfo.EventStatus) }; } /// /// 获取场次出场名单与我的评分状态 /// [HttpGet("entries/{sessionId}")] [DisplayName("获取场次出场名单")] public async Task> GetEntries(long sessionId) { var referee = await EnsureRefereeAsync(); var assignment = await GetMyAssignmentAsync(sessionId, referee.Id); var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId); if (session == null) throw Oops.Bah("比赛场次不存在"); var ctx = await _calculationService.LoadContextAsync(sessionId); var config = ctx.Config; var entries = (await _entryRepository.AsQueryable() .Where(x => x.SessionId == sessionId && !x.IsVoided && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync()) .OrderBy(x => x.PlayOrder).ThenBy(x => x.Id).ToList(); if (entries.Count == 0) return new List(); var eventProjects = await _eventProjectRepository.AsQueryable() .Where(x => x.EventId == session.EventId && !x.DeleteMark) .ToListAsync(); var projectIds = eventProjects.Select(x => x.ProjectId).Distinct().ToList(); var orderedProjectIds = (await _projectRepository.AsQueryable() .Where(x => projectIds.Contains(x.Id) && !x.DeleteMark) .ToListAsync()) .OrderBy(x => x.SortIndex) .ThenBy(x => x.Id) .Select(x => x.Id) .ToList(); var currentProjectId = eventProjects .FirstOrDefault(x => x.Id == session.EventProjectId)?.ProjectId ?? 0; var projectOrderIndex = orderedProjectIds.IndexOf(currentProjectId); var projectOrder = projectOrderIndex >= 0 ? projectOrderIndex + 1 : orderedProjectIds.Count + 1; var venue = await _venueRepository.GetFirstAsync(x => x.Id == session.VenueId && !x.DeleteMark); var venueOrder = venue?.Sort > 0 ? venue.Sort : 1; var sessionArrangementOrder = GetArrangementSequence(session.SessionNo); var entryIds = entries.Select(x => x.Id).ToList(); var sheets = await _sheetRepository.AsQueryable() .Where(x => entryIds.Contains(x.SessionEntryId) && x.JudgeAssignmentId == assignment.Id).ToListAsync(); var corrections = await _correctionRepository.AsQueryable() .Where(x => x.SessionId == sessionId && x.JudgeAssignmentId == assignment.Id && (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing)) .ToListAsync(); return entries.Select(entry => { var submitted = sheets.FirstOrDefault(x => x.SessionEntryId == entry.Id && x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted); var draft = sheets.FirstOrDefault(x => x.SessionEntryId == entry.Id && x.SheetStatus == SheetStatusDraft); var inCorrection = submitted != null && corrections.Any(x => x.OriginalScoreSheetId == submitted.Id); var status = inCorrection ? 3 : submitted != null ? 2 : draft != null ? 1 : 0; return new JudgeEntryDto { SessionEntryId = entry.Id, EntryNo = entry.EntryNo, PlayOrder = entry.PlayOrder, ScheduleOrder = entry.SourceType == 1 ? entry.Id : BuildArrangementScheduleOrder( projectOrder, sessionArrangementOrder, venueOrder, entry.PlayOrder), ParticipantName = DisplayParticipantName(entry, ctx.Scope), GroupName = entry.GroupName, EntryStatus = entry.EntryStatus, IsRematch = entry.IsRematch, ScoreStatus = status, ScoreStatusName = ScoreStatusName(status), MyScore = submitted == null ? null : config.Format(submitted.TotalScore), IsDisqualified = submitted?.IsDisqualified ?? false, ScoreSheetId = submitted?.Id ?? draft?.Id ?? 0 }; }).ToList(); } /// /// 获取评分表:表头、评分项、我已录入的值与合计预览 /// [HttpGet("sheet/{sessionEntryId}")] [DisplayName("获取评分表")] public async Task GetSheet(long sessionEntryId) { var referee = await EnsureRefereeAsync(); var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(sessionEntryId, referee.Id); var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType); if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项"); var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted); var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft); var correction = submitted == null ? null : await _correctionRepository.GetFirstAsync( x => x.OriginalScoreSheetId == submitted.Id && (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing)); var source = draft ?? submitted; var details = source == null ? new List() : await _detailRepository.GetListAsync(x => x.ScoreSheetId == source.Id); var inputs = details.Select(x => new ScoreItemInputValue { ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue }).ToList(); // 草稿允许留空,因此按非必填口径试算,只汇总已录入的部分。 var preview = ScoreSheetCalculationService.Calculate(definitions, inputs, ctx.Config, false); var stageAllowsScoring = CompetitionStagePolicy.CanScore(ctx.Session.SessionStatus, eventInfo.EventStatus); var scoreSheetAllowsEditing = submitted == null || correction != null; var canEdit = stageAllowsScoring && scoreSheetAllowsEditing; var members = await LoadMemberNamesAsync(entry, ctx.Scope); // 个人项目以参赛记录解析出的当前运动员为准,兼容历史出场快照误存单位名的情况。 var participantName = ctx.Scope == ScoreScope.Personal && members.Count == 1 ? members[0] : DisplayParticipantName(entry, ctx.Scope); var venue = await _venueRepository.GetFirstAsync(x => x.Id == ctx.Session.VenueId); var nextVersion = (submitted?.SheetVersion ?? 0) + 1; return new JudgeScoreSheetOutput { SessionEntryId = entry.Id, SessionId = ctx.Session.Id, JudgeTaskId = draft?.JudgeTaskId ?? submitted?.JudgeTaskId ?? 0, ScoreSheetId = source?.Id ?? 0, SheetVersion = source?.SheetVersion ?? nextVersion, SheetStatus = source?.SheetStatus ?? SheetStatusDraft, CanEdit = canEdit, LockReason = canEdit ? null : !stageAllowsScoring ? CompetitionStagePolicy.ScoreLockReason(ctx.Session.SessionStatus, eventInfo.EventStatus) : "已提交评分,如需修改请先申请成绩修正", IsCorrection = correction != null, // 幂等键随评分单版本递增,弱网重复提交同一版本只会落一张评分单。 IdempotencyKey = $"{assignment.Id}-{entry.Id}-v{nextVersion}", Header = new JudgeSheetHeaderDto { EventName = eventInfo?.EventName ?? string.Empty, ProjectName = ctx.Project.Name, RuleName = ctx.Rule.RuleName, GroupName = entry.GroupName, SessionVenue = BuildSessionVenue( ctx.Session, ctx.Project.Name, entry.GroupName, venue?.VenueName ?? "-"), EntryNo = entry.EntryNo, ParticipantName = participantName, MemberNames = members, JudgeRole = assignment.JudgeRole, JudgeRoleType = roleType, JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType), RuleType = ctx.Config.RuleType, TotalMode = ctx.Config.TotalMode, Formula = EntryResultCalculationService.BuildFormula(ctx.Config), Scope = ctx.Scope, BaseScore = ctx.Config.BaseScore, ScoreDecimals = ctx.Config.ScoreDecimals }, Items = definitions.Select(definition => { var line = preview.Lines.FirstOrDefault(x => string.Equals(x.ItemCode, definition.ItemCode, StringComparison.OrdinalIgnoreCase)); return new JudgeScoreItemDto { ScoreRuleItemId = definition.Item.Id, ItemCode = definition.ItemCode, ItemName = definition.Item.ItemName, ItemType = definition.Item.ItemType, ScoreDimension = definition.Item.ScoreDimension, InputMode = definition.InputMode, MinValue = definition.MinValue, MaxValue = definition.MaxValue, StepValue = definition.StepValue, UnitValue = definition.Item.UnitValue, DefaultValue = definition.Item.DefaultValue, UnitLabel = definition.UnitLabel, Remark = definition.Remark, IsRequired = definition.Item.IsRequired, IsDisqualifyReason = definition.IsDisqualifyReason, IsAutoGenerated = definition.IsAuto, SubjectCode = definition.SubjectCode, SubjectName = definition.SubjectName, DimensionCode = definition.DimensionCode, DimensionWeight = definition.DimensionWeight, BaseValue = definition.BaseValue, AdjustSign = definition.AdjustSign, IsFailureCount = definition.IsFailureCount, StepDeductions = definition.StepDeductions, RequiredTarget = definition.RequiredTarget, RequiredAsCategory = definition.RequiredAsCategory, Sort = definition.Item.Sort, InputValue = line?.InputValue, CountValue = line?.CountValue, Subtotal = line?.Subtotal ?? 0m }; }).ToList(), Preview = ToPreviewDto(preview, ctx.Config) }; } /// /// 保存评分草稿。草稿允许留空,不参与成绩计算。 /// [HttpPost("save")] [DisplayName("保存评分草稿")] [UnitOfWork] public async Task SaveDraft([FromBody] SaveJudgeScoreInput input) { var referee = await EnsureRefereeAsync(); var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id); EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus); var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType); if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项"); var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted); if (submitted != null && !await HasOpenCorrectionAsync(submitted.Id)) throw Oops.Bah("已提交评分,如需修改请先申请成绩修正"); var calculation = ScoreSheetCalculationService.Calculate(definitions, ToInputValues(input.Items), ctx.Config, false); ValidateDeductionCoefficient(input.DeductionCoefficient, calculation, ctx.Config, roleType); var task = await EnsureTaskAsync(entry, ctx, assignment, roleType, submitted == null ? 0 : TaskTypeCorrection); var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft); var nextVersion = (submitted?.SheetVersion ?? 0) + 1; await UpsertSheetAsync(draft, entry, ctx, assignment, roleType, task, calculation, SheetStatusDraft, nextVersion, $"{assignment.Id}-{entry.Id}-v{nextVersion}-draft", null, null, null); if (submitted != null) { var correction = await _correctionRepository.GetFirstAsync(x => x.OriginalScoreSheetId == submitted.Id && x.CorrectionStatus == CorrectionStatusApproved); if (correction != null) { correction.CorrectionTaskId = task.Id; correction.CorrectionStatus = CorrectionStatusEditing; correction.UpdatedUserId = _authManager.UserId; correction.UpdatedTime = DateTime.Now; await _correctionRepository.UpdateAsync(correction); } } return ToPreviewDto(calculation, ctx.Config); } /// /// 提交评分。提交后自动聚合本出场成绩(T1、T2、T3 与总分)并重排名次。 /// [HttpPost("submit")] [DisplayName("提交评分")] [UnitOfWork] public async Task Submit([FromBody] SubmitJudgeScoreInput input) { var referee = await EnsureRefereeAsync(); var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id); EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus); if (entry.IsVoided) throw Oops.Bah("该出场成绩已作废,无法提交评分"); if (entry.EntryStatus == EntryStatusWithdrawn) throw Oops.Bah("该运动员已退赛,无需评分"); var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType); if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项"); // 弱网重试时同一幂等键只落一张评分单,直接回放上次的提交结果。 var idempotencyKey = string.IsNullOrWhiteSpace(input.IdempotencyKey) ? null : input.IdempotencyKey!.Trim(); if (idempotencyKey != null) { var replay = await _sheetRepository.GetFirstAsync(x => x.IdempotencyKey == idempotencyKey && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted); if (replay != null) return await BuildSubmitOutputAsync(entry, ctx, replay, true); } var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted); var correction = submitted == null ? null : await _correctionRepository.GetFirstAsync( x => x.OriginalScoreSheetId == submitted.Id && (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing)); if (submitted != null && correction == null) throw Oops.Bah("已提交评分,如需修改请先申请成绩修正"); // 正式提交才校验必填项与取值范围。 var calculation = ScoreSheetCalculationService.Calculate(definitions, ToInputValues(input.Items), ctx.Config, true); ValidateDeductionCoefficient(input.DeductionCoefficient, calculation, ctx.Config, roleType); var task = await EnsureTaskAsync(entry, ctx, assignment, roleType, correction == null ? 0 : TaskTypeCorrection); var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft); var nextVersion = (submitted?.SheetVersion ?? 0) + 1; var now = DateTime.Now; if (submitted != null) { // 当前版本唯一索引要求先让原评分单退出当前态;UnitOfWork 会在后续失败时整体回滚。 submitted.IsCurrent = false; submitted.SheetStatus = SheetStatusCorrected; submitted.UpdatedUserId = _authManager.UserId; submitted.UpdatedTime = now; await _sheetRepository.UpdateAsync(submitted); } var sheet = await UpsertSheetAsync(draft, entry, ctx, assignment, roleType, task, calculation, EventScoreCalculationService.SheetStatusSubmitted, nextVersion, idempotencyKey ?? $"{assignment.Id}-{entry.Id}-v{nextVersion}", input.DisqualifyRemark, submitted?.Id, correction?.ApplyReason); if (correction != null) { correction.CorrectionTaskId = task.Id; correction.NewScoreSheetId = sheet.Id; correction.CorrectionStatus = CorrectionStatusCompleted; correction.CompletedTime = now; correction.UpdatedUserId = _authManager.UserId; correction.UpdatedTime = now; await _correctionRepository.UpdateAsync(correction); } task.TaskStatus = TaskStatusSubmitted; task.SubmittedTime = now; task.UpdatedUserId = _authManager.UserId; task.UpdatedTime = now; await _taskRepository.UpdateAsync(task); // 所有应到裁判都提交后即写入成绩并重排名次,未齐时只返回提交进度。 await _calculationService.RecalculateEntryAsync(entry.Id, correction == null ? EventScoreCalculationService.SourceTypeNormal : EventScoreCalculationService.SourceTypeCorrection, correction?.Id, _authManager.UserId, ctx); return await BuildSubmitOutputAsync(entry, ctx, sheet, false); } /// /// 查询场次成绩:各出场的 T1、T2、T3、总分与名次 /// [HttpGet("results/{sessionId}")] [DisplayName("查询场次成绩")] public async Task> GetResults(long sessionId) { var referee = await EnsureRefereeAsync(); var assignment = await GetMyAssignmentAsync(sessionId, referee.Id); var roleType = JudgeRoleSchemaService.NormalizeRoleType( string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType); if (!string.Equals(roleType, JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase)) throw Oops.Bah("仅主裁判可查看场次最终成绩"); var ctx = await _calculationService.LoadContextAsync(sessionId); var outcomes = await _calculationService.EvaluateSessionAsync(sessionId, ctx); var entryIds = outcomes.Select(x => (long?)x.SessionEntryId).ToList(); var results = entryIds.Count == 0 ? new List() : await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId)); var scoreSheetIds = outcomes.SelectMany(x => x.Aggregate?.Symbols ?? new List()) .SelectMany(x => x.Judges).Select(x => x.ScoreSheetId).Distinct().ToList(); var annotationUrls = scoreSheetIds.Count == 0 ? new Dictionary() : (await _attachmentRepository.GetListAsync(x => scoreSheetIds.Contains(x.ScoreSheetId) && x.IsCurrent && x.AttachmentType == AnnotationAttachmentType)) .GroupBy(x => x.ScoreSheetId).ToDictionary(x => x.Key, x => x.OrderByDescending(y => y.CreatedTime).First().FileUrl); return outcomes.Select(outcome => { var result = results.FirstOrDefault(x => x.SessionEntryId == outcome.SessionEntryId); return new JudgeEntryResultDto { SessionEntryId = outcome.SessionEntryId, EntryNo = outcome.EntryNo, ParticipantName = outcome.ParticipantName, GroupName = outcome.GroupName, Rank = result?.Rank ?? 0, TotalScore = outcome.IsDisqualified ? "取消资格" : outcome.IsCompleted ? ctx.Config.Format(outcome.TotalScore) : "-", Formula = outcome.Aggregate?.Formula ?? EntryResultCalculationService.BuildFormula(ctx.Config), IsCompleted = outcome.IsCompleted, IsDisqualified = outcome.IsDisqualified, Symbols = (outcome.Aggregate?.Symbols ?? new List()).Select(symbol => new JudgeSymbolScoreDto { Symbol = symbol.Symbol, Label = symbol.Label, Sign = symbol.Sign, Value = ctx.Config.Format(symbol.Value), Judges = symbol.Judges.Select(judge => new JudgeSymbolJudgeDto { ScoreSheetId = judge.ScoreSheetId, JudgeRole = judge.JudgeRole, JudgeName = judge.JudgeName ?? string.Empty, Value = ctx.Config.Format(judge.Value), AnnotationUrl = annotationUrls.GetValueOrDefault(judge.ScoreSheetId) }).ToList() }).ToList(), Factors = !ctx.Config.IsFactorTotal || outcome.Aggregate == null ? null : new JudgeScoreFactorDto { Difficulty = ctx.Config.Format(outcome.Aggregate.DifficultyFactor), Performance = ctx.Config.Format(outcome.Aggregate.PerformanceFactor), Required = ctx.Config.Format(outcome.Aggregate.RequiredFactor), Deduction = ctx.Config.Format(outcome.Aggregate.DeductionFactor) }, Progress = outcome.Roles.Select(ToProgressDto).ToList() }; }).ToList(); } /// /// 获取可申请成绩修正的评分单 /// [HttpGet("corrections/candidates/{sessionId}")] [DisplayName("获取可修正的评分单")] public async Task> GetCorrectionCandidates(long sessionId) { var referee = await EnsureRefereeAsync(); var assignment = await GetMyAssignmentAsync(sessionId, referee.Id); var ctx = await _calculationService.LoadContextAsync(sessionId); var sheets = (await _sheetRepository.GetListAsync(x => x.SessionId == sessionId && x.JudgeAssignmentId == assignment.Id && x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted)) .OrderByDescending(x => x.SubmittedTime).ToList(); if (sheets.Count == 0) return new List(); var sheetIds = sheets.Select(x => x.Id).ToList(); var corrections = await _correctionRepository.GetListAsync(x => sheetIds.Contains(x.OriginalScoreSheetId) || (x.NewScoreSheetId.HasValue && sheetIds.Contains(x.NewScoreSheetId.Value))); var entryIds = sheets.Select(x => x.SessionEntryId).Distinct().ToList(); var entries = (await _entryRepository.GetListAsync(x => entryIds.Contains(x.Id))).ToDictionary(x => x.Id); return sheets.Select(sheet => { var entry = entries.GetValueOrDefault(sheet.SessionEntryId); // 修正完成后当前评分单已经变成 NewScoreSheetId,仍需返回最近一次流程状态, // 否则 App 会把刚完成的修正误显示成“可首次申请”。 var correction = corrections.Where(x => x.OriginalScoreSheetId == sheet.Id || x.NewScoreSheetId == sheet.Id) .OrderByDescending(x => x.CreatedTime).FirstOrDefault(); var status = correction?.CorrectionStatus ?? -1; return new JudgeCorrectionCandidateDto { CorrectionId = correction?.Id, ScoreSheetId = sheet.Id, SessionEntryId = sheet.SessionEntryId, EntryNo = entry?.EntryNo ?? string.Empty, ParticipantName = entry == null ? string.Empty : DisplayParticipantName(entry, ctx.Scope), MyScore = ctx.Config.Format(sheet.TotalScore), SubmittedTime = sheet.SubmittedTime, CorrectionStatus = status, CorrectionStatusName = CorrectionStatusName(status), ApplyReason = correction?.ApplyReason, ApproveRemark = correction?.ApproveRemark, // 已在流程中的评分单不能重复申请,被拒绝或已完成的可以再次申请。 CanApply = status is -1 or CorrectionStatusRejected or CorrectionStatusCompleted or CorrectionStatusCancelled }; }).ToList(); } /// /// 申请成绩修正。规则未要求审批时申请即生效,裁判可直接重新提交评分。 /// [HttpPost("corrections/apply")] [DisplayName("申请成绩修正")] [UnitOfWork] public async Task ApplyCorrection([FromBody] ApplyJudgeCorrectionInput input) { var referee = await EnsureRefereeAsync(); if (string.IsNullOrWhiteSpace(input.ApplyReason)) throw Oops.Bah("请填写申请修正原因"); var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == input.ScoreSheetId && x.RefereeId == referee.Id); if (sheet == null) throw Oops.Bah("评分单不存在"); var session = await _sessionRepository.GetFirstAsync(x => x.Id == sheet.SessionId); if (session == null) throw Oops.Bah("比赛场次不存在"); var eventInfo = await GetEventAsync(session.EventId); EnsureCanScore(session.SessionStatus, eventInfo.EventStatus); if (!sheet.IsCurrent || sheet.SheetStatus < EventScoreCalculationService.SheetStatusSubmitted) throw Oops.Bah("该评分单尚未提交,可直接修改后提交"); if (sheet.SheetStatus == SheetStatusConfirmed) throw Oops.Bah("成绩已确认,请联系赛事管理员处理"); if (await HasOpenCorrectionAsync(sheet.Id)) throw Oops.Bah("该评分单已有修正申请在处理中"); var ctx = await _calculationService.LoadContextAsync(sheet.SessionId); var now = DateTime.Now; var correction = new EventScoreCorrection { Id = _idGenerator.NextId(), EventId = sheet.EventId, SessionId = sheet.SessionId, SessionEntryId = sheet.SessionEntryId, OriginalScoreSheetId = sheet.Id, JudgeAssignmentId = sheet.JudgeAssignmentId, JudgeRole = sheet.JudgeRole, ApplyRefereeId = referee.Id, ApplyReason = input.ApplyReason.Trim(), CorrectionStatus = ctx.Config.CorrectionNeedApproval ? CorrectionStatusApplied : CorrectionStatusApproved, CreatedUserId = _authManager.UserId, CreatedTime = now }; if (!ctx.Config.CorrectionNeedApproval) { // 规则自动放行不属于人工审批,不伪造审批人和审批时间。 correction.ApproveRemark = "规则配置为无需审批,申请后即可修正"; } await _correctionRepository.InsertAsync(correction); return correction.Id; } /// /// 获取我的消息 /// [HttpGet("messages")] [DisplayName("获取裁判端消息")] public async Task> GetMessages([FromQuery] bool? onlyUnread = null) { var referee = await EnsureRefereeAsync(); var userId = _authManager.UserId; var messages = (await _messageRepository.AsQueryable() .Where(x => x.RefereeId == referee.Id || x.ReceiverUserId == userId) .WhereIF(onlyUnread == true, x => !x.IsRead) .ToListAsync()) .OrderByDescending(x => x.CreatedTime).ToList(); // 成绩修正只服务于进行中的赛事。赛事状态同步可能有延迟,因此同时结合 // 赛事时间计算实时状态;赛事结束后不再向裁判端返回对应通知。 var correctionEventIds = messages .Where(IsScoreCorrectionMessage) .Select(x => x.EventId) .Distinct() .ToList(); if (correctionEventIds.Count > 0) { var now = DateTime.Now; var endedEventIds = (await _eventRepository.AsQueryable() .Where(x => correctionEventIds.Contains(x.Id)) .ToListAsync()) .Where(x => GetEffectiveEventStatus(x, now) == EventStatus.Ended) .Select(x => x.Id) .ToHashSet(); messages = messages .Where(x => !IsScoreCorrectionMessage(x) || !endedEventIds.Contains(x.EventId)) .ToList(); } var correctionIds = messages .Where(x => IsScoreCorrectionMessage(x) && x.RelationId.HasValue) .Select(x => x.RelationId!.Value).Distinct().ToList(); var corrections = correctionIds.Count == 0 ? new Dictionary() : (await _correctionRepository.GetListAsync(x => correctionIds.Contains(x.Id))).ToDictionary(x => x.Id); return messages.Select(x => new JudgeMessageDto { Id = x.Id, EventId = x.EventId, MessageType = x.MessageType, Title = x.Title, Content = x.Content, RelationType = x.RelationType, RelationId = x.RelationId, SessionId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.SessionId : null, SessionEntryId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.SessionEntryId : null, ScoreSheetId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.OriginalScoreSheetId : null, IsRead = x.IsRead, CreatedTime = x.CreatedTime }).ToList(); } /// /// 标记消息已读,不传消息ID表示全部标记已读 /// [HttpPost("messages/read")] [DisplayName("标记裁判端消息已读")] public async Task MarkMessagesRead([FromBody] ReadJudgeMessageInput input) { var referee = await EnsureRefereeAsync(); var userId = _authManager.UserId; var messages = await _messageRepository.AsQueryable() .Where(x => (x.RefereeId == referee.Id || x.ReceiverUserId == userId) && !x.IsRead) .WhereIF(input.MessageIds.Count > 0, x => input.MessageIds.Contains(x.Id)) .ToListAsync(); if (messages.Count == 0) return; var now = DateTime.Now; messages.ForEach(x => { x.IsRead = true; x.ReadTime = now; x.UpdatedUserId = userId; x.UpdatedTime = now; }); await _messageRepository.UpdateRangeAsync(messages); } /// 上传当前裁判评分单的记录画板 PNG。 [HttpPost("judge-scoring/annotations/upload")] [DisplayName("上传裁判记录画板")] [UnitOfWork] public async Task UploadAnnotation([FromForm] UploadJudgeScoreAnnotationInput input) { var referee = await EnsureRefereeAsync(); var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == input.ScoreSheetId && x.RefereeId == referee.Id); if (sheet == null) throw Oops.Bah("评分单不存在或不属于当前裁判"); if (sheet.SheetStatus < EventScoreCalculationService.SheetStatusSubmitted) throw Oops.Bah("请先提交成绩,再上传记录画板"); var key = input.IdempotencyKey?.Trim() ?? string.Empty; if (key.Length is < 8 or > 64 || !Regex.IsMatch(key, "^[A-Za-z0-9_-]+$")) throw Oops.Bah("画板上传幂等键无效"); var replay = await _attachmentRepository.GetFirstAsync(x => x.ScoreSheetId == sheet.Id && x.IdempotencyKey == key); if (replay != null) return ToAttachmentDto(replay); var file = input.File; if (file == null || file.Length == 0) throw Oops.Bah("请选择画板 PNG 文件"); if (file.Length > MaxAnnotationBytes) throw Oops.Bah("画板图片不能超过5MB"); if (!string.Equals(Path.GetExtension(file.FileName), ".png", StringComparison.OrdinalIgnoreCase) || !string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase)) throw Oops.Bah("画板仅支持PNG格式"); await using var stream = file.OpenReadStream(); var signature = new byte[8]; if (await stream.ReadAsync(signature.AsMemory(0, signature.Length)) != signature.Length || !signature.SequenceEqual(PngSignature)) throw Oops.Bah("上传文件不是有效的PNG图片"); stream.Position = 0; // 文件头可以伪造;在上传 OSS 前完整解码,拒绝截断或损坏的 PNG。 // 限制解码后的像素数,避免小体积压缩文件造成过量内存分配。 // 解码器会释放其输入;保留请求流供后续 OSS 上传使用。 using (var decoderStream = new SkiaSharp.SKManagedStream(stream, false)) using (var codec = SkiaSharp.SKCodec.Create(decoderStream)) { if (codec == null || codec.EncodedFormat != SkiaSharp.SKEncodedImageFormat.Png || codec.Info.Width <= 0 || codec.Info.Height <= 0 || (long)codec.Info.Width * codec.Info.Height > 16_777_216) throw Oops.Bah("画板PNG图片损坏或像素尺寸过大"); using var bitmap = new SkiaSharp.SKBitmap(codec.Info); if (codec.GetPixels(bitmap.Info, bitmap.GetPixels()) != SkiaSharp.SKCodecResult.Success) throw Oops.Bah("画板PNG图片不完整或无法解码"); } stream.Position = 0; var objectName = $"judge-score-annotations/{sheet.EventId}/{sheet.Id}/{key}.png"; var url = await _fileService.UploadFileStreamAsync(objectName, stream); var now = DateTime.Now; var current = await _attachmentRepository.GetListAsync(x => x.ScoreSheetId == sheet.Id && x.AttachmentType == AnnotationAttachmentType && x.IsCurrent); foreach (var old in current) { old.IsCurrent = false; old.UpdatedUserId = _authManager.UserId; old.UpdatedTime = now; } if (current.Count > 0) await _attachmentRepository.UpdateRangeAsync(current); var attachment = new EventJudgeScoreAttachment { Id = _idGenerator.NextId(), EventId = sheet.EventId, SessionId = sheet.SessionId, SessionEntryId = sheet.SessionEntryId, ScoreSheetId = sheet.Id, JudgeAssignmentId = sheet.JudgeAssignmentId, RefereeId = referee.Id, AttachmentType = AnnotationAttachmentType, FileName = Path.GetFileName(file.FileName), FileUrl = url, FileSize = file.Length, ContentType = "image/png", IdempotencyKey = key, IsCurrent = true, CreatedUserId = _authManager.UserId, CreatedTime = now }; await _attachmentRepository.InsertAsync(attachment); return ToAttachmentDto(attachment); } /// 查询评分单当前记录画板。 [HttpGet("judge-scoring/annotations/{scoreSheetId}")] [DisplayName("查询裁判记录画板")] public async Task GetAnnotation(long scoreSheetId) { var referee = await EnsureRefereeAsync(); var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == scoreSheetId); if (sheet == null) throw Oops.Bah("评分单不存在"); if (sheet.RefereeId != referee.Id) { var assignment = await GetMyAssignmentAsync(sheet.SessionId, referee.Id); var roleType = JudgeRoleSchemaService.NormalizeRoleType( string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType); if (!string.Equals(roleType, JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase)) throw Oops.Bah("无权查看该评分单画板"); } var attachment = await _attachmentRepository.GetFirstAsync(x => x.ScoreSheetId == scoreSheetId && x.AttachmentType == AnnotationAttachmentType && x.IsCurrent); return attachment == null ? null : ToAttachmentDto(attachment); } /// /// 校验当前登录用户的裁判身份并取出裁判档案 /// private async Task EnsureRefereeAsync() { var userId = _authManager.UserId; var user = await _userRepository.GetFirstAsync(x => x.Id == userId); if (user == null) throw Oops.Bah("用户不存在"); if (user.MiniProgramRole != MiniProgramRole.Referee) throw Oops.Bah("当前账号不是裁判身份"); var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId); if (referee == null) throw Oops.Bah("裁判档案不存在,请先完善裁判资料"); if (!await _assignmentRepository.IsAnyAsync(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1)) throw Oops.Bah("裁判分配尚未提交,暂不能进入裁判端"); return referee; } /// /// 校验当前场次状态是否允许评分。 /// /// 场次状态。 /// 赛事状态。 private static void EnsureCanScore(int sessionStatus, EventStatus eventStatus) { if (!CompetitionStagePolicy.CanScore(sessionStatus, eventStatus)) throw Oops.Bah(CompetitionStagePolicy.ScoreLockReason(sessionStatus, eventStatus)); } /// /// 获取场次所属赛事,确保评分权限校验与后台展示使用同一赛事状态。 /// private async Task GetEventAsync(long eventId) { var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == eventId); if (eventInfo == null) throw Oops.Bah("赛事不存在"); return eventInfo; } /// /// 取我在该场次的裁判席位,未被分配到该场次时直接拒绝 /// private async Task GetMyAssignmentAsync(long sessionId, long refereeId) { var assignment = await _assignmentRepository.GetFirstAsync(x => x.SessionId == sessionId && x.RefereeId == refereeId && x.AssignmentStatus == 1); if (assignment == null) throw Oops.Bah("您未被分配到该场次执裁"); return assignment; } /// /// 加载评分表所需的出场、计分上下文与我的裁判席位 /// private async Task<(EventSessionEntry Entry, ScoringContext Context, EventInfo Event, EventJudgeAssignment Assignment, string RoleType)> LoadSheetContextAsync(long sessionEntryId, long refereeId) { var entry = await _entryRepository.GetFirstAsync(x => x.Id == sessionEntryId); if (entry == null) throw Oops.Bah("出场记录不存在"); var assignment = await GetMyAssignmentAsync(entry.SessionId, refereeId); var ctx = await _calculationService.LoadContextAsync(entry.SessionId); var eventInfo = await GetEventAsync(ctx.Session.EventId); var roleType = JudgeRoleSchemaService.NormalizeRoleType( string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType); return (entry, ctx, eventInfo, assignment, roleType); } /// /// 取出场对应的运动员姓名,用于评分表表头展示 /// private async Task> LoadMemberNamesAsync(EventSessionEntry entry, string scope) { // 单位可以在同一报名项下填报多名个人项运动员。 // 个人项必须通过出场记录关联的参赛记录精确取一人,不能按 RegistrationItemId 整体取人。 if (scope == ScoreScope.Personal) { if (entry.ParticipationRecordId.HasValue) { var record = await _participationRepository.GetFirstAsync(x => x.Id == entry.ParticipationRecordId.Value); if (record != null) { var person = await _personRepository.GetFirstAsync(x => x.Id == record.RegistrationPersonId); if (person != null) return new List { person.Name }; } } // 历史异常关联不扩大到同报名项的其他人,使用出场快照保证不串人。 return string.IsNullOrWhiteSpace(entry.ParticipantName) ? new List() : new List { entry.ParticipantName }; } if (!entry.RegistrationItemId.HasValue) return new List(); var persons = await _personRepository.GetListAsync(x => x.RegistrationItemId == entry.RegistrationItemId.Value); var athletes = persons.Where(x => x.PersonType == PersonType.Athlete).ToList(); if (athletes.Count == 0) athletes = persons; return athletes.OrderBy(x => x.Name).Select(x => x.Name).ToList(); } /// /// 个人项始终显示出场人快照;只有集体项才优先显示队名。 /// 兼容修复前已经编排、TeamName 被误写为单位名的历史出场记录。 /// private static string DisplayParticipantName(EventSessionEntry entry, string scope) => scope == ScoreScope.Team && !string.IsNullOrWhiteSpace(entry.TeamName) ? entry.TeamName : entry.ParticipantName; /// /// 该评分单是否有正在处理中的修正申请 /// private async Task HasOpenCorrectionAsync(long scoreSheetId) => await _correctionRepository.IsAnyAsync(x => x.OriginalScoreSheetId == scoreSheetId && (x.CorrectionStatus == CorrectionStatusApplied || x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing)); private static JudgeScoreAttachmentDto ToAttachmentDto(EventJudgeScoreAttachment attachment) => new() { Id = attachment.Id, ScoreSheetId = attachment.ScoreSheetId, AttachmentType = attachment.AttachmentType, FileName = attachment.FileName, FileUrl = attachment.FileUrl, FileSize = attachment.FileSize, UploadedTime = attachment.CreatedTime }; /// /// 取(或按需创建)本裁判在该出场上的评分任务 /// private async Task EnsureTaskAsync(EventSessionEntry entry, ScoringContext ctx, EventJudgeAssignment assignment, string roleType, int taskType) { var task = await _taskRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && x.TaskType == taskType && x.TaskStatus != TaskStatusVoided && (taskType != TaskTypeCorrection || x.TaskStatus != TaskStatusSubmitted)); var now = DateTime.Now; if (task != null) { if (task.AcceptedTime == null) { task.TaskStatus = TaskStatusAccepted; task.AcceptedTime = now; task.UpdatedUserId = _authManager.UserId; task.UpdatedTime = now; await _taskRepository.UpdateAsync(task); } return task; } var taskHistory = await _taskRepository.GetListAsync(x => x.SessionEntryId == entry.Id && x.JudgeAssignmentId == assignment.Id && !x.DeleteMark); var previousTask = taskHistory.OrderByDescending(x => x.TaskVersion).FirstOrDefault(); task = new EventJudgeTask { Id = _idGenerator.NextId(), EventId = ctx.Session.EventId, SessionId = ctx.Session.Id, SessionEntryId = entry.Id, JudgeAssignmentId = assignment.Id, EventRefereeId = assignment.EventRefereeId, RefereeId = assignment.RefereeId, JudgeUserId = assignment.JudgeUserId ?? _authManager.UserId, JudgeRole = assignment.JudgeRole, JudgeRoleType = roleType, ScoreRuleId = ctx.Rule.Id, RuleVersion = ctx.Rule.RuleVersion, // 任务快照记录评分时看到的赛事信息,后续赛程调整不影响已产生的评分留痕。 TaskSnapshotJson = JsonConvert.SerializeObject(new { projectName = ctx.Project.Name, ruleName = ctx.Rule.RuleName, sessionName = ctx.Session.SessionName ?? ctx.Session.SessionNo, entryNo = entry.EntryNo, groupName = entry.GroupName, participantName = DisplayParticipantName(entry, ctx.Scope), judgeRole = assignment.JudgeRole, judgeRoleType = roleType, scope = ctx.Scope }), TaskType = taskType, TaskVersion = (previousTask?.TaskVersion ?? 0) + 1, OriginalTaskId = taskType == TaskTypeCorrection ? previousTask?.Id : null, TaskStatus = TaskStatusAccepted, DispatchedTime = now, AcceptedTime = now, CreatedUserId = _authManager.UserId, CreatedTime = now }; await _taskRepository.InsertAsync(task); return task; } /// /// 写入(或更新)评分单及其明细。分值一律取服务端计算结果。 /// private async Task UpsertSheetAsync(EventJudgeScoreSheet? sheet, EventSessionEntry entry, ScoringContext ctx, EventJudgeAssignment assignment, string roleType, EventJudgeTask task, ScoreSheetCalculationResult calculation, int sheetStatus, int sheetVersion, string idempotencyKey, string? disqualifyRemark, long? originalSheetId, string? correctionReason) { var now = DateTime.Now; var isNew = sheet == null; sheet ??= new EventJudgeScoreSheet { Id = _idGenerator.NextId(), EventId = ctx.Session.EventId, SessionId = ctx.Session.Id, SessionEntryId = entry.Id, CreatedUserId = _authManager.UserId, CreatedTime = now }; sheet.JudgeTaskId = task.Id; sheet.JudgeAssignmentId = assignment.Id; sheet.RefereeId = assignment.RefereeId; sheet.JudgeUserId = assignment.JudgeUserId ?? _authManager.UserId; sheet.JudgeRole = assignment.JudgeRole; sheet.JudgeRoleType = roleType; sheet.ScoreRuleId = ctx.Rule.Id; sheet.RuleVersion = ctx.Rule.RuleVersion; sheet.SheetVersion = sheetVersion; sheet.SheetStatus = sheetStatus; // 只有已提交的评分单参与成绩聚合,草稿不置为当前版本。 sheet.IsCurrent = sheetStatus >= EventScoreCalculationService.SheetStatusSubmitted; sheet.DifficultyScore = calculation.DifficultyScore; sheet.PerformanceScore = calculation.PerformanceScore; sheet.RequiredScore = calculation.RequiredScore; sheet.DeductionScore = calculation.DeductionScore; sheet.TotalScore = calculation.TotalScore; // 规定元素完成量、缺失量由服务端按折算系数与目标数算出,供成绩聚合换算 Q 因子。 sheet.RequiredCompletedCount = calculation.RequiredCompletedCount; sheet.RequiredMissingCount = calculation.RequiredMissingCount; sheet.IsDisqualified = calculation.IsDisqualified; sheet.DisqualifyReasonCode = calculation.DisqualifyReasonCode; sheet.DisqualifyRemark = calculation.IsDisqualified ? disqualifyRemark : null; sheet.CalculationJson = ScoreSheetCalculationService.BuildCalculationJson(calculation, assignment.JudgeRole, roleType, ctx.Scope); sheet.IdempotencyKey = idempotencyKey; sheet.OriginalSheetId = originalSheetId; sheet.CorrectionReason = correctionReason; if (sheetStatus >= EventScoreCalculationService.SheetStatusSubmitted) sheet.SubmittedTime = now; if (isNew) await _sheetRepository.InsertAsync(sheet); else { sheet.UpdatedUserId = _authManager.UserId; sheet.UpdatedTime = now; await _sheetRepository.UpdateAsync(sheet); } // 明细整单覆盖:重算后的行数可能变化,逐行比对不如整单重写清晰。 await _detailRepository.SoftDeleteAsync(x => x.ScoreSheetId == sheet.Id); if (calculation.Lines.Count > 0) { await _detailRepository.InsertRangeAsync(calculation.Lines.Select(line => new EventJudgeScoreDetail { Id = _idGenerator.NextId(), ScoreSheetId = sheet.Id, ScoreRuleItemId = line.ScoreRuleItemId, ItemCode = line.ItemCode, ItemName = line.ItemName, ItemType = line.ItemType, ScoreDimension = line.ScoreDimension, SubjectCode = line.SubjectCode, IsAutoGenerated = line.IsAutoGenerated, ParentItemCode = line.ParentItemCode, CountValue = line.CountValue, InputValue = line.InputValue, UnitValue = line.UnitValue, Subtotal = line.Subtotal, Sort = line.Sort, CreatedUserId = _authManager.UserId, CreatedTime = now }).ToList()); } return sheet; } /// /// 组装提交结果:我的得分 + 本出场的聚合进度 /// private async Task BuildSubmitOutputAsync(EventSessionEntry entry, ScoringContext ctx, EventJudgeScoreSheet sheet, bool isDuplicate) { var outcomes = await _calculationService.EvaluateSessionAsync(ctx.Session.Id, ctx); var outcome = outcomes.FirstOrDefault(x => x.SessionEntryId == entry.Id); return new SubmitJudgeScoreOutput { ScoreSheetId = sheet.Id, IsDuplicate = isDuplicate, MyScore = ctx.Config.Format(sheet.TotalScore), ResultCompleted = outcome?.IsCompleted ?? false, TotalScore = !string.Equals(JudgeRoleSchemaService.NormalizeRoleType(sheet.JudgeRoleType), JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase) || outcome == null || !outcome.IsCompleted ? null : outcome.IsDisqualified ? "取消资格" : ctx.Config.Format(outcome.TotalScore), Progress = (outcome?.Roles ?? new List()).Select(ToProgressDto).ToList() }; } /// /// 把录入项转成计算器的入参 /// private static List ToInputValues(List items) => items.Select(x => new ScoreItemInputValue { ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue }).ToList(); /// /// 校验主裁端上传的扣分系数。最终得分系数 M 仍由平台按 1-扣分系数计算, /// 这里不信任客户端结果,只用于确认页面明细与上传系数属于同一评分快照。 /// private static void ValidateDeductionCoefficient(decimal? uploaded, ScoreSheetCalculationResult calculation, ScoreRuleConfig config, string roleType) { if (uploaded == null) return; // 兼容尚未升级的裁判端。 if (!config.IsFactorTotal || !string.Equals( JudgeRoleSchemaService.NormalizeRoleType(roleType), JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase)) throw Oops.Bah("当前评分表不支持上传扣分系数"); if (uploaded < 0m || uploaded > 1m) throw Oops.Bah("扣分系数必须在0至1之间"); // 明细行保留了递进扣分的未舍入小计,用它校验 0.125 等三位小数系数。 var expected = calculation.Lines .Where(x => x.ScoreDimension == ScoreDimensionCode.Deduction) .Sum(x => x.Subtotal); if (Math.Abs(uploaded.Value - expected) > 0.0005m) throw Oops.Bah($"扣分系数与扣分明细不一致,应为{expected:0.###}"); } /// /// 组装合计预览 /// private static JudgeScorePreviewDto ToPreviewDto(ScoreSheetCalculationResult calculation, ScoreRuleConfig config) => new() { // 裁判端的难度小计按规则精度展示,内部仍保留原始值参与最终汇总。 DifficultyScore = config.Round(calculation.DifficultyScore), PerformanceScore = calculation.PerformanceScore, RequiredScore = calculation.RequiredScore, DeductionScore = calculation.DeductionScore, TotalScore = calculation.TotalScore, TotalScoreText = config.Format(calculation.TotalScore), IsDisqualified = calculation.IsDisqualified, DisqualifyReasonName = calculation.DisqualifyReasonName, RequiredCompletedCount = calculation.RequiredCompletedCount, RequiredMissingCount = calculation.RequiredMissingCount, Steps = calculation.Steps.Select(x => new JudgeScoreStepDto { Code = x.Code, Label = x.Label, Formula = x.Formula, Value = x.Value }).ToList() }; /// /// 组装裁判类型的提交进度 /// private static JudgeRoleProgressDto ToProgressDto(EntryRoleProgress progress) => new() { RoleType = progress.RoleType, RoleName = progress.RoleName, Symbol = progress.Symbol, ExpectedCount = progress.ExpectedCount, SubmittedCount = progress.SubmittedCount, PendingJudges = progress.PendingJudges }; /// /// 评分单状态:草稿 /// private const int SheetStatusDraft = 0; /// /// 评分单状态:已确认 /// private const int SheetStatusConfirmed = 20; /// /// 评分单状态:已被修正 /// private const int SheetStatusCorrected = 30; /// /// 评分任务状态:已接收 /// private const int TaskStatusAccepted = 20; /// /// 评分任务状态:已提交 /// private const int TaskStatusSubmitted = 40; /// /// 评分任务状态:已作废 /// private const int TaskStatusVoided = 90; /// /// 消息类型:成绩修正提醒 /// private const int MessageTypeCorrection = 1; /// /// 评分任务类型:成绩修正 /// private const int TaskTypeCorrection = 1; /// /// 修正状态:已申请 /// private const int CorrectionStatusApplied = 0; /// /// 修正状态:已批准 /// private const int CorrectionStatusApproved = 10; /// /// 修正状态:已拒绝 /// private const int CorrectionStatusRejected = 20; /// /// 修正状态:裁判修改中 /// private const int CorrectionStatusEditing = 30; /// /// 修正状态:已完成 /// private const int CorrectionStatusCompleted = 40; /// /// 修正状态:已取消 /// private const int CorrectionStatusCancelled = 90; private const string AnnotationAttachmentType = "score_annotation_png"; private const long MaxAnnotationBytes = 5L * 1024 * 1024; private static readonly byte[] PngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; /// /// 出场状态:退赛 /// private const int EntryStatusWithdrawn = 80; /// /// 获取场次在赛事编排中的顺序。自动编排和秩序册导入生成的场次号末段均保存场次顺序; /// 速度项目只有项目和场地段,同一项目内按场地顺序排列。 /// private static int GetArrangementSequence(string? sessionNo) { if (string.IsNullOrWhiteSpace(sessionNo)) return int.MaxValue; var match = Regex.Match(sessionNo, @"^EP\d+-(?:V)?\d+(?:-(?\d+))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); if (!match.Success) return int.MaxValue; return match.Groups["sequence"].Success && int.TryParse(match.Groups["sequence"].Value, out var sequence) ? sequence : 0; } /// /// 将自动编排任务还原为“项目、场次、场地”的全局顺序。 /// 同组同场地的多条记录在数据库中属于同一场次,因此需结合出场顺序恢复原始场次号。 /// private static long BuildArrangementScheduleOrder( int projectOrder, int sessionArrangementOrder, int venueOrder, int playOrder) { var safePlayOrder = Math.Max(playOrder, 1); var taskSessionOrder = sessionArrangementOrder > 0 && sessionArrangementOrder < int.MaxValue ? sessionArrangementOrder + safePlayOrder - 1 : safePlayOrder; return (long)Math.Max(projectOrder, 1) * 1_000_000_000_000L + (long)taskSessionOrder * 1_000_000L + Math.Max(venueOrder, 1); } /// /// 构建裁判端展示用的“场次-场地”,去除秩序册导入时写入场次名的项目和组别信息。 /// private static string BuildSessionVenue( EventSession session, string projectName, string groupName, string venueName) { var sessionName = session.SessionName ?? string.Empty; foreach (var redundant in new[] { projectName, groupName, venueName }) { if (!string.IsNullOrWhiteSpace(redundant)) sessionName = sessionName.Replace( redundant, string.Empty, StringComparison.OrdinalIgnoreCase); } sessionName = Regex.Replace( sessionName, @"^[\s·|/_—–-]+|[\s·|/_—–-]+$", string.Empty).Trim(); if (string.IsNullOrWhiteSpace(sessionName)) { var arrangementOrder = GetArrangementSequence(session.SessionNo); sessionName = arrangementOrder > 0 && arrangementOrder < int.MaxValue ? $"第{arrangementOrder}场" : session.SessionNo; } return string.IsNullOrWhiteSpace(venueName) || sessionName.Contains(venueName, StringComparison.OrdinalIgnoreCase) ? sessionName : $"{sessionName}-{venueName}"; } /// /// 获取用于裁判场次选择的赛事实时状态,避免后台状态同步延迟导致比赛中场次不可见。 /// private static EventStatus GetEffectiveEventStatus(EventInfo eventInfo, DateTime now) { var calculatedStatus = EventStatusUpdateService.CalculateEventStatus(eventInfo, now); if (calculatedStatus == EventStatus.Ended || eventInfo.EventStatus == EventStatus.Ended) return EventStatus.Ended; if (calculatedStatus == EventStatus.Ongoing || eventInfo.EventStatus == EventStatus.Ongoing) return EventStatus.Ongoing; return calculatedStatus; } /// /// 兼容历史实体名和当前业务关系名两种成绩修正消息标识。 /// private static bool IsScoreCorrectionMessage(EventJudgeMessage message) => string.Equals(message.RelationType, "ScoreCorrection", StringComparison.OrdinalIgnoreCase) || string.Equals(message.RelationType, nameof(EventScoreCorrection), StringComparison.OrdinalIgnoreCase); /// /// 场次状态名称 /// private static string SessionStatusName(int status) => status switch { 10 => "就绪", 20 => "检录中", 30 => "进行中", 40 => "暂停", 50 => "待确认", 60 => "已结束", 90 => "已取消", _ => "草稿" }; /// /// 我的评分状态名称 /// private static string ScoreStatusName(int status) => status switch { 1 => "草稿", 2 => "已提交", 3 => "待修正", _ => "未评分" }; /// /// 修正申请状态名称 /// private static string CorrectionStatusName(int status) => status switch { CorrectionStatusApplied => "已申请", CorrectionStatusApproved => "已批准", CorrectionStatusRejected => "已拒绝", CorrectionStatusEditing => "裁判修改中", CorrectionStatusCompleted => "已完成", CorrectionStatusCancelled => "已取消", _ => "未申请" }; }