YD_Event.Admin/YD_Event.Application/Judge/JudgeScoringController.cs

1534 lines
77 KiB
C#
Raw Normal View History

using YD_Event.Application.Auth;
using YD_Event.Application.Common;
2026-09-20 09:40:18 +08:00
using YD_Event.Application.Event;
2026-08-22 20:34:05 +08:00
using YD_Event.Application.Scoring.Dtos;
using System.Text.RegularExpressions;
2026-08-22 20:34:05 +08:00
namespace YD_Event.Application.Scoring;
/// <summary>
/// 打分系统裁判端评分接口(独立于微信小程序,供裁判端页面调用)。
2026-08-23 16:21:49 +08:00
/// 规定赛计分(totalMode=linear):创意编排裁判创意分取平均记作 T1,完成质量裁判完成分取平均记作 T2,
2026-08-22 20:34:05 +08:00
/// 主裁每项违例从 100 分中扣 5 分记作 T3,总分 = T1 + T2 - T3。
2026-08-23 16:21:49 +08:00
/// 花样赛计分(totalMode=factor):两人车轮跳花样 R = D × (1 + P) × Q × M,
2026-10-10 13:56:49 +08:00
/// 其中 D = (dA + dB) / 2、P = 表现裁判均值 × 0.05、Q = max(0, 1 - 0.025 × 规定元素缺失数)、
/// M = max(0, 1 - 最终扣分合计);所有表现裁判与主裁的失误次数按实际人数求平均,四舍五入取整后计算阶梯扣分,其他扣分仍取主裁记录;
2026-08-23 16:21:49 +08:00
/// 交互绳花样 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 - (失误 + 违例))。
2026-08-22 20:34:05 +08:00
/// 裁判提交的分值一律在服务端按计分规则重算,客户端上传的合计值不参与落库。
/// </summary>
[ApiDescriptionSettings("打分系统")]
2026-09-08 16:27:27 +08:00
[Route("api/judgescoring")]
2026-08-22 20:34:05 +08:00
[Authorize]
public class JudgeScoringController : IDynamicApiController
{
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Referee> _refereeRepository;
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<EventVenue> _venueRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Project> _projectRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.ScoreRule> _ruleRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
private readonly ISqlSugarRepository<EventJudgeAssignment> _assignmentRepository;
private readonly ISqlSugarRepository<EventJudgeTask> _taskRepository;
private readonly ISqlSugarRepository<EventJudgeScoreSheet> _sheetRepository;
private readonly ISqlSugarRepository<EventJudgeScoreDetail> _detailRepository;
private readonly ISqlSugarRepository<EventScoreCorrection> _correctionRepository;
private readonly ISqlSugarRepository<EventJudgeMessage> _messageRepository;
private readonly ISqlSugarRepository<EventJudgeScoreAttachment> _attachmentRepository;
2026-08-22 20:34:05 +08:00
private readonly ISqlSugarRepository<EventResult> _resultRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventParticipationRecord> _participationRepository;
2026-08-22 20:34:05 +08:00
private readonly EventScoreCalculationService _calculationService;
private readonly AuthManager _authManager;
private readonly IIdGenerator _idGenerator;
private readonly FileService _fileService;
2026-08-22 20:34:05 +08:00
public JudgeScoringController(
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Referee> refereeRepository,
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<EventVenue> venueRepository,
ISqlSugarRepository<EventProject> eventProjectRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Project> projectRepository,
ISqlSugarRepository<YD_Event.Core.Entities.ScoreRule> ruleRepository,
ISqlSugarRepository<EventSession> sessionRepository,
ISqlSugarRepository<EventSessionEntry> entryRepository,
ISqlSugarRepository<EventJudgeAssignment> assignmentRepository,
ISqlSugarRepository<EventJudgeTask> taskRepository,
ISqlSugarRepository<EventJudgeScoreSheet> sheetRepository,
ISqlSugarRepository<EventJudgeScoreDetail> detailRepository,
ISqlSugarRepository<EventScoreCorrection> correctionRepository,
ISqlSugarRepository<EventJudgeMessage> messageRepository,
ISqlSugarRepository<EventJudgeScoreAttachment> attachmentRepository,
2026-08-22 20:34:05 +08:00
ISqlSugarRepository<EventResult> resultRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventParticipationRecord> participationRepository,
2026-08-22 20:34:05 +08:00
EventScoreCalculationService calculationService,
AuthManager authManager,
IIdGenerator idGenerator,
FileService fileService)
2026-08-22 20:34:05 +08:00
{
_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;
2026-08-22 20:34:05 +08:00
_resultRepository = resultRepository;
_personRepository = personRepository;
_participationRepository = participationRepository;
2026-08-22 20:34:05 +08:00
_calculationService = calculationService;
_authManager = authManager;
_idGenerator = idGenerator;
_fileService = fileService;
2026-08-22 20:34:05 +08:00
}
/// <summary>
/// 获取我的执裁场次
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("sessions")]
2026-08-22 20:34:05 +08:00
[DisplayName("获取我的执裁场次")]
public async Task<List<JudgeSessionDto>> GetSessions([FromQuery] long? eventId = null)
{
var referee = await EnsureRefereeAsync();
var assignments = await _assignmentRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1 &&
x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
2026-08-22 20:34:05 +08:00
.WhereIF(eventId.HasValue, x => x.EventId == eventId!.Value)
.ToListAsync();
if (assignments.Count == 0) return new List<JudgeSessionDto>();
var sessionIds = assignments.Select(x => x.SessionId).Distinct().ToList();
2026-09-17 16:27:30 +08:00
var sessions = await _sessionRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => sessionIds.Contains(x.Id) && x.SessionStatus != 90 &&
x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync();
2026-08-22 20:34:05 +08:00
if (sessions.Count == 0) return new List<JudgeSessionDto>();
var eventIds = sessions.Select(x => x.EventId).Distinct().ToList();
2026-09-20 09:40:18 +08:00
var events = (await _eventRepository.AsQueryable()
.Where(x => eventIds.Contains(x.Id) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync())
2026-08-22 20:34:05 +08:00
.ToDictionary(x => x.Id);
2026-09-20 09:40:18 +08:00
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<JudgeSessionDto>();
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();
2026-08-22 20:34:05 +08:00
var venues = (await _venueRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => sessions.Select(s => s.VenueId).Contains(x.Id) &&
x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync()).ToDictionary(x => x.Id);
2026-08-22 20:34:05 +08:00
var eventProjects = (await _eventProjectRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => sessions.Select(s => s.EventProjectId).Contains(x.Id) && !x.DeleteMark)
.ToListAsync()).ToDictionary(x => x.Id);
2026-08-22 20:34:05 +08:00
var projects = (await _projectRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => eventProjects.Values.Select(e => e.ProjectId).Contains(x.Id) && !x.DeleteMark)
.ToListAsync()).ToDictionary(x => x.Id);
2026-08-22 20:34:05 +08:00
var rules = (await _ruleRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => sessions.Select(s => s.ScoreRuleId).Contains(x.Id) && !x.DeleteMark)
.ToListAsync()).ToDictionary(x => x.Id);
2026-08-22 20:34:05 +08:00
2026-09-17 16:27:30 +08:00
sessions = sessions
2026-09-21 17:52:13 +08:00
.OrderBy(x => GetArrangementSequence(x.SessionNo))
.ThenBy(x => eventProjects.TryGetValue(x.EventProjectId, out var eventProject)
2026-09-17 16:27:30 +08:00
&& projects.TryGetValue(eventProject.ProjectId, out var project)
? project.SortIndex
: int.MaxValue)
.ThenBy(x => eventProjects.TryGetValue(x.EventProjectId, out var eventProject)
? eventProject.ProjectId
: long.MaxValue)
2026-09-20 09:40:18 +08:00
.ThenBy(x => venues.TryGetValue(x.VenueId, out var venue) ? venue.Sort : int.MaxValue)
2026-09-17 16:27:30 +08:00
.ThenBy(x => x.SessionNo)
2026-09-20 09:40:18 +08:00
.ThenBy(x => x.Id)
2026-09-17 16:27:30 +08:00
.ToList();
2026-08-22 20:34:05 +08:00
var entries = await _entryRepository.AsQueryable()
2026-09-20 09:40:18 +08:00
.Where(x => sessionIds.Contains(x.SessionId) && !x.IsVoided &&
x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync();
2026-08-22 20:34:05 +08:00
var mySheets = await _sheetRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id
2026-09-20 09:40:18 +08:00
&& x.IsCurrent && !x.DeleteMark
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync();
2026-08-22 20:34:05 +08:00
var rows = new List<JudgeSessionDto>();
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),
2026-09-21 17:52:13 +08:00
ScoredCount = mySheets.Count(x => x.SessionId == session.Id),
ArrangementOrder = GetArrangementSequence(session.SessionNo) is var order
&& order < int.MaxValue
? order
: 0
2026-08-22 20:34:05 +08:00
});
}
return rows;
}
2026-09-02 14:07:36 +08:00
/// <summary>
/// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。
/// </summary>
/// <param name="sessionId">场次ID。</param>
/// <returns>当前场次的赛事状态和操作权限。</returns>
2026-09-08 16:27:27 +08:00
[HttpGet("status/{sessionId}")]
2026-09-02 14:07:36 +08:00
[DisplayName("查询当前赛事状态")]
public async Task<JudgeEventStatusDto> 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("比赛场次不存在");
2026-09-17 14:48:11 +08:00
var eventInfo = await GetEventAsync(session.EventId);
2026-09-02 14:07:36 +08:00
2026-09-17 14:48:11 +08:00
var canScore = CompetitionStagePolicy.CanScore(session.SessionStatus, eventInfo.EventStatus);
2026-09-02 14:07:36 +08:00
return new JudgeEventStatusDto
{
EventId = session.EventId,
SessionId = session.Id,
SessionStatus = session.SessionStatus,
SessionStatusName = SessionStatusName(session.SessionStatus),
2026-09-17 14:48:11 +08:00
Phase = CompetitionStagePolicy.PhaseOf(session.SessionStatus, eventInfo.EventStatus),
PhaseName = CompetitionStagePolicy.PhaseName(session.SessionStatus, eventInfo.EventStatus),
2026-09-02 14:07:36 +08:00
CanScore = canScore,
2026-09-17 14:48:11 +08:00
CanModifyAssignment = CompetitionStagePolicy.CanModifyAssignment(session.SessionStatus, eventInfo.EventStatus),
Message = canScore ? null : CompetitionStagePolicy.ScoreLockReason(session.SessionStatus, eventInfo.EventStatus)
2026-09-02 14:07:36 +08:00
};
}
2026-08-22 20:34:05 +08:00
/// <summary>
/// 获取场次出场名单与我的评分状态
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("entries/{sessionId}")]
2026-08-22 20:34:05 +08:00
[DisplayName("获取场次出场名单")]
public async Task<List<JudgeEntryDto>> 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;
2026-08-22 20:34:05 +08:00
2026-09-21 17:52:13 +08:00
var entries = (await _entryRepository.AsQueryable()
.Where(x => x.SessionId == sessionId && !x.IsVoided &&
x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync())
2026-08-22 20:34:05 +08:00
.OrderBy(x => x.PlayOrder).ThenBy(x => x.Id).ToList();
if (entries.Count == 0) return new List<JudgeEntryDto>();
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);
2026-08-22 20:34:05 +08:00
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),
2026-08-22 20:34:05 +08:00
GroupName = entry.GroupName,
EntryStatus = entry.EntryStatus,
IsRematch = entry.IsRematch,
ScoreStatus = status,
ScoreStatusName = ScoreStatusName(status),
MyScore = submitted == null ? null : ScoreDisplayService.JudgeText(submitted, config.IsFactorTotal),
2026-08-22 20:34:05 +08:00
IsDisqualified = submitted?.IsDisqualified ?? false,
ScoreSheetId = submitted?.Id ?? draft?.Id ?? 0
};
}).ToList();
}
/// <summary>
/// 获取评分表:表头、评分项、我已录入的值与合计预览
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("sheet/{sessionEntryId}")]
2026-08-22 20:34:05 +08:00
[DisplayName("获取评分表")]
public async Task<JudgeScoreSheetOutput> GetSheet(long sessionEntryId)
{
var referee = await EnsureRefereeAsync();
2026-09-17 14:48:11 +08:00
var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(sessionEntryId, referee.Id);
2026-08-22 20:34:05 +08:00
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<EventJudgeScoreDetail>()
: await _detailRepository.GetListAsync(x => x.ScoreSheetId == source.Id && !x.DeleteMark);
var legacyInteraction = roleType == "T"
&& (ctx.Config.RuleType.ToUpperInvariant() is "FREESTYLE_DOUBLE_SYNC" or "FREESTYLE_QUAD_SYNC")
? details.FirstOrDefault(x => x.ItemCode == "REQ_INTERACTION") : null;
// 仅排除已退役的互动配合录入项,历史明细仍保留在原评分单中供复核。
var inputs = details.Where(x => legacyInteraction == null || x.ItemCode != "REQ_INTERACTION")
.Select(x => new ScoreItemInputValue
2026-08-22 20:34:05 +08:00
{
2026-09-20 09:40:18 +08:00
ItemCode = x.ItemCode,
InputValue = x.InputValue,
CountValue = x.CountValue
2026-08-22 20:34:05 +08:00
}).ToList();
// 草稿允许留空,因此按非必填口径试算,只汇总已录入的部分。
var preview = ScoreSheetCalculationService.Calculate(definitions, inputs, ctx.Config, false);
2026-09-17 14:48:11 +08:00
var stageAllowsScoring = CompetitionStagePolicy.CanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
2026-09-02 14:07:36 +08:00
var scoreSheetAllowsEditing = submitted == null || correction != null;
var canEdit = stageAllowsScoring && scoreSheetAllowsEditing;
var members = await LoadMemberNamesAsync(entry, ctx.Scope);
2026-09-23 10:36:07 +08:00
// 个人项目以参赛记录解析出的当前运动员为准,兼容历史出场快照误存单位名的情况。
var participantName = ctx.Scope == ScoreScope.Personal && members.Count == 1
? members[0]
: DisplayParticipantName(entry, ctx.Scope);
2026-08-22 20:34:05 +08:00
var venue = await _venueRepository.GetFirstAsync(x => x.Id == ctx.Session.VenueId);
var nextVersion = (submitted?.SheetVersion ?? 0) + 1;
return new JudgeScoreSheetOutput
{
RuleMigrationNotice = legacyInteraction == null ? null
: $"旧互动配合记录为 {legacyInteraction.CountValue ?? 0m} 个,未区分等级。原记录已保留,请重新核对两档数量(含0)后保存或提交。",
2026-08-22 20:34:05 +08:00
SessionEntryId = entry.Id,
SessionId = ctx.Session.Id,
JudgeTaskId = draft?.JudgeTaskId ?? submitted?.JudgeTaskId ?? 0,
ScoreSheetId = source?.Id ?? 0,
SheetVersion = source?.SheetVersion ?? nextVersion,
2026-08-22 20:34:05 +08:00
SheetStatus = source?.SheetStatus ?? SheetStatusDraft,
CanEdit = canEdit,
2026-09-02 14:07:36 +08:00
LockReason = canEdit ? null : !stageAllowsScoring
2026-09-17 14:48:11 +08:00
? CompetitionStagePolicy.ScoreLockReason(ctx.Session.SessionStatus, eventInfo.EventStatus)
2026-09-02 14:07:36 +08:00
: "已提交评分,如需修改请先申请成绩修正",
2026-08-22 20:34:05 +08:00
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,
2026-09-21 17:52:13 +08:00
SessionVenue = BuildSessionVenue(
ctx.Session,
ctx.Project.Name,
entry.GroupName,
venue?.VenueName ?? "-"),
2026-08-22 20:34:05 +08:00
EntryNo = entry.EntryNo,
2026-09-23 10:36:07 +08:00
ParticipantName = participantName,
2026-08-22 20:34:05 +08:00
MemberNames = members,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType),
RuleType = ctx.Config.RuleType,
2026-08-23 16:21:49 +08:00
TotalMode = ctx.Config.TotalMode,
Formula = EntryResultCalculationService.BuildFormula(ctx.Config),
2026-08-22 20:34:05 +08:00
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));
// 修正时必须回填原评分单的手工录入值。预览用于重算自动项,不能作为历史录入的唯一来源。
var savedInput = definition.IsAuto ? null : details.FirstOrDefault(x =>
!x.IsAutoGenerated && string.Equals(x.ItemCode, definition.ItemCode, StringComparison.OrdinalIgnoreCase));
2026-08-22 20:34:05 +08:00
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,
2026-08-23 16:21:49 +08:00
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,
2026-08-22 20:34:05 +08:00
Sort = definition.Item.Sort,
InputValue = savedInput?.InputValue ?? line?.InputValue,
CountValue = savedInput?.CountValue ?? line?.CountValue,
2026-08-22 20:34:05 +08:00
Subtotal = line?.Subtotal ?? 0m
};
}).ToList(),
Preview = ToPreviewDto(preview, ctx.Config)
};
}
/// <summary>
/// 保存评分草稿。草稿允许留空,不参与成绩计算。
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpPost("save")]
2026-08-22 20:34:05 +08:00
[DisplayName("保存评分草稿")]
[UnitOfWork]
public async Task<JudgeScorePreviewDto> SaveDraft([FromBody] SaveJudgeScoreInput input)
{
var referee = await EnsureRefereeAsync();
2026-09-17 14:48:11 +08:00
var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
2026-08-22 20:34:05 +08:00
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);
2026-09-21 17:52:13 +08:00
ValidateDeductionCoefficient(input.DeductionCoefficient, calculation, ctx.Config, roleType);
2026-08-22 20:34:05 +08:00
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);
}
}
2026-08-22 20:34:05 +08:00
return ToPreviewDto(calculation, ctx.Config);
}
/// <summary>
/// 提交评分。提交后自动聚合本出场成绩(T1、T2、T3 与总分)并重排名次。
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpPost("submit")]
2026-08-22 20:34:05 +08:00
[DisplayName("提交评分")]
[UnitOfWork]
public async Task<SubmitJudgeScoreOutput> Submit([FromBody] SubmitJudgeScoreInput input)
{
var referee = await EnsureRefereeAsync();
2026-09-17 14:48:11 +08:00
var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
2026-08-22 20:34:05 +08:00
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);
2026-09-21 17:52:13 +08:00
ValidateDeductionCoefficient(input.DeductionCoefficient, calculation, ctx.Config, roleType);
2026-08-22 20:34:05 +08:00
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 会在后续失败时整体回滚。
2026-08-22 20:34:05 +08:00
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);
2026-08-22 20:34:05 +08:00
if (correction != null)
{
correction.CorrectionTaskId = task.Id;
2026-08-22 20:34:05 +08:00
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);
}
/// <summary>
/// 查询场次成绩:各出场的 T1、T2、T3、总分与名次
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("results/{sessionId}")]
2026-08-22 20:34:05 +08:00
[DisplayName("查询场次成绩")]
public async Task<List<JudgeEntryResultDto>> 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("仅主裁判可查看场次最终成绩");
2026-08-22 20:34:05 +08:00
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<EventResult>()
: await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId));
var scoreSheetIds = outcomes.SelectMany(x => x.Aggregate?.Symbols ?? new List<EntrySymbolScore>())
.SelectMany(x => x.Judges).Select(x => x.ScoreSheetId).Distinct().ToList();
var annotationUrls = scoreSheetIds.Count == 0
? new Dictionary<long, string>()
: (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);
2026-08-22 20:34:05 +08:00
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<EntrySymbolScore>()).Select(symbol => new JudgeSymbolScoreDto
{
2026-09-20 09:40:18 +08:00
Symbol = symbol.Symbol,
Label = symbol.Label,
Sign = symbol.Sign,
2026-08-22 20:34:05 +08:00
Value = ctx.Config.Format(symbol.Value),
Judges = symbol.Judges.Select(judge => new JudgeSymbolJudgeDto
{
ScoreSheetId = judge.ScoreSheetId,
2026-08-22 20:34:05 +08:00
JudgeRole = judge.JudgeRole,
JudgeName = judge.JudgeName ?? string.Empty,
Value = ctx.Config.Format(judge.Value),
AnnotationUrl = annotationUrls.GetValueOrDefault(judge.ScoreSheetId)
2026-08-22 20:34:05 +08:00
}).ToList()
}).ToList(),
2026-08-23 16:21:49 +08:00
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 = outcome.Aggregate.DeductionFactor.ToString("F3", System.Globalization.CultureInfo.InvariantCulture)
2026-08-23 16:21:49 +08:00
},
2026-08-22 20:34:05 +08:00
Progress = outcome.Roles.Select(ToProgressDto).ToList()
};
}).ToList();
}
/// <summary>
/// 获取可申请成绩修正的评分单
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("corrections/candidates/{sessionId}")]
2026-08-22 20:34:05 +08:00
[DisplayName("获取可修正的评分单")]
public async Task<List<JudgeCorrectionCandidateDto>> 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<JudgeCorrectionCandidateDto>();
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)));
2026-08-22 20:34:05 +08:00
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)
2026-08-22 20:34:05 +08:00
.OrderByDescending(x => x.CreatedTime).FirstOrDefault();
var status = correction?.CorrectionStatus ?? -1;
return new JudgeCorrectionCandidateDto
{
CorrectionId = correction?.Id,
2026-08-22 20:34:05 +08:00
ScoreSheetId = sheet.Id,
SessionEntryId = sheet.SessionEntryId,
EntryNo = entry?.EntryNo ?? string.Empty,
ParticipantName = entry == null ? string.Empty : DisplayParticipantName(entry, ctx.Scope),
MyScore = ScoreDisplayService.JudgeText(sheet, ctx.Config.IsFactorTotal),
2026-08-22 20:34:05 +08:00
SubmittedTime = sheet.SubmittedTime,
CorrectionStatus = status,
CorrectionStatusName = CorrectionStatusName(status),
ApplyReason = correction?.ApplyReason,
ApproveRemark = correction?.ApproveRemark,
2026-08-22 20:34:05 +08:00
// 已在流程中的评分单不能重复申请,被拒绝或已完成的可以再次申请。
CanApply = status is -1 or CorrectionStatusRejected or CorrectionStatusCompleted or CorrectionStatusCancelled
};
}).ToList();
}
/// <summary>
/// 申请成绩修正。规则未要求审批时申请即生效,裁判可直接重新提交评分。
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpPost("corrections/apply")]
2026-08-22 20:34:05 +08:00
[DisplayName("申请成绩修正")]
[UnitOfWork]
public async Task<long> 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("评分单不存在");
2026-09-02 14:07:36 +08:00
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sheet.SessionId);
if (session == null) throw Oops.Bah("比赛场次不存在");
2026-09-17 14:48:11 +08:00
var eventInfo = await GetEventAsync(session.EventId);
EnsureCanScore(session.SessionStatus, eventInfo.EventStatus);
2026-08-22 20:34:05 +08:00
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,
2026-08-22 20:34:05 +08:00
ApplyReason = input.ApplyReason.Trim(),
CorrectionStatus = ctx.Config.CorrectionNeedApproval ? CorrectionStatusApplied : CorrectionStatusApproved,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
if (!ctx.Config.CorrectionNeedApproval)
{
// 规则自动放行不属于人工审批,不伪造审批人和审批时间。
2026-08-22 20:34:05 +08:00
correction.ApproveRemark = "规则配置为无需审批,申请后即可修正";
}
await _correctionRepository.InsertAsync(correction);
return correction.Id;
}
/// <summary>
/// 获取我的消息
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpGet("messages")]
2026-08-22 20:34:05 +08:00
[DisplayName("获取裁判端消息")]
public async Task<List<JudgeMessageDto>> GetMessages([FromQuery] bool? onlyUnread = null)
{
var referee = await EnsureRefereeAsync();
var userId = _authManager.UserId;
var messages = (await _messageRepository.AsQueryable()
2026-08-22 20:34:05 +08:00
.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<long, EventScoreCorrection>()
: (await _correctionRepository.GetListAsync(x => correctionIds.Contains(x.Id))).ToDictionary(x => x.Id);
2026-08-22 20:34:05 +08:00
return messages.Select(x => new JudgeMessageDto
{
2026-09-20 09:40:18 +08:00
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,
2026-09-20 09:40:18 +08:00
IsRead = x.IsRead,
CreatedTime = x.CreatedTime
2026-08-22 20:34:05 +08:00
}).ToList();
}
/// <summary>
/// 标记消息已读,不传消息ID表示全部标记已读
/// </summary>
2026-09-08 16:27:27 +08:00
[HttpPost("messages/read")]
2026-08-22 20:34:05 +08:00
[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);
}
/// <summary>上传当前裁判评分单的记录画板 PNG。</summary>
[HttpPost("judge-scoring/annotations/upload")]
[DisplayName("上传裁判记录画板")]
[UnitOfWork]
public async Task<JudgeScoreAttachmentDto> 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;
2026-09-16 15:20:14 +08:00
// 文件头可以伪造;在上传 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
{
2026-09-20 09:40:18 +08:00
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);
}
/// <summary>查询评分单当前记录画板。</summary>
[HttpGet("judge-scoring/annotations/{scoreSheetId}")]
[DisplayName("查询裁判记录画板")]
public async Task<JudgeScoreAttachmentDto?> 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);
}
2026-08-22 20:34:05 +08:00
/// <summary>
/// 校验当前登录用户的裁判身份并取出裁判档案
/// </summary>
private async Task<YD_Event.Core.Entities.Referee> 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("裁判档案不存在,请先完善裁判资料");
2026-09-02 14:07:36 +08:00
if (!await _assignmentRepository.IsAnyAsync(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1))
throw Oops.Bah("裁判分配尚未提交,暂不能进入裁判端");
2026-08-22 20:34:05 +08:00
return referee;
}
2026-09-02 14:07:36 +08:00
/// <summary>
/// 校验当前场次状态是否允许评分。
/// </summary>
/// <param name="sessionStatus">场次状态。</param>
2026-09-17 14:48:11 +08:00
/// <param name="eventStatus">赛事状态。</param>
private static void EnsureCanScore(int sessionStatus, EventStatus eventStatus)
2026-09-02 14:07:36 +08:00
{
2026-09-17 14:48:11 +08:00
if (!CompetitionStagePolicy.CanScore(sessionStatus, eventStatus))
throw Oops.Bah(CompetitionStagePolicy.ScoreLockReason(sessionStatus, eventStatus));
}
/// <summary>
/// 获取场次所属赛事,确保评分权限校验与后台展示使用同一赛事状态。
/// </summary>
private async Task<EventInfo> GetEventAsync(long eventId)
{
var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == eventId);
if (eventInfo == null) throw Oops.Bah("赛事不存在");
return eventInfo;
2026-09-02 14:07:36 +08:00
}
2026-08-22 20:34:05 +08:00
/// <summary>
/// 取我在该场次的裁判席位,未被分配到该场次时直接拒绝
/// </summary>
private async Task<EventJudgeAssignment> 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;
}
/// <summary>
/// 加载评分表所需的出场、计分上下文与我的裁判席位
/// </summary>
2026-09-17 14:48:11 +08:00
private async Task<(EventSessionEntry Entry, ScoringContext Context, EventInfo Event,
EventJudgeAssignment Assignment, string RoleType)>
2026-08-22 20:34:05 +08:00
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);
2026-09-17 14:48:11 +08:00
var eventInfo = await GetEventAsync(ctx.Session.EventId);
2026-08-22 20:34:05 +08:00
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
2026-09-17 14:48:11 +08:00
return (entry, ctx, eventInfo, assignment, roleType);
2026-08-22 20:34:05 +08:00
}
/// <summary>
/// 取出场对应的运动员姓名,用于评分表表头展示
/// </summary>
private async Task<List<string>> LoadMemberNamesAsync(EventSessionEntry entry, string scope)
2026-08-22 20:34:05 +08:00
{
// 单位可以在同一报名项下填报多名个人项运动员。
// 个人项必须通过出场记录关联的参赛记录精确取一人,不能按 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<string> { person.Name };
}
}
// 历史异常关联不扩大到同报名项的其他人,使用出场快照保证不串人。
return string.IsNullOrWhiteSpace(entry.ParticipantName)
? new List<string>()
: new List<string> { entry.ParticipantName };
}
2026-08-22 20:34:05 +08:00
if (!entry.RegistrationItemId.HasValue) return new List<string>();
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();
}
/// <summary>
/// 个人项始终显示出场人快照;只有集体项才优先显示队名。
/// 兼容历史出场记录中的单位名称字段。
/// </summary>
private static string DisplayParticipantName(EventSessionEntry entry, string scope) =>
scope == ScoreScope.Team && !string.IsNullOrWhiteSpace(entry.TeamName)
? entry.TeamName
: entry.ParticipantName;
2026-08-22 20:34:05 +08:00
/// <summary>
/// 该评分单是否有正在处理中的修正申请
/// </summary>
private async Task<bool> 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
};
2026-08-22 20:34:05 +08:00
/// <summary>
/// 取(或按需创建)本裁判在该出场上的评分任务
/// </summary>
private async Task<EventJudgeTask> 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));
2026-08-22 20:34:05 +08:00
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();
2026-08-22 20:34:05 +08:00
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),
2026-08-22 20:34:05 +08:00
judgeRole = assignment.JudgeRole,
judgeRoleType = roleType,
scope = ctx.Scope
}),
TaskType = taskType,
TaskVersion = (previousTask?.TaskVersion ?? 0) + 1,
OriginalTaskId = taskType == TaskTypeCorrection ? previousTask?.Id : null,
2026-08-22 20:34:05 +08:00
TaskStatus = TaskStatusAccepted,
DispatchedTime = now,
AcceptedTime = now,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
await _taskRepository.InsertAsync(task);
return task;
}
/// <summary>
/// 写入(或更新)评分单及其明细。分值一律取服务端计算结果。
/// </summary>
private async Task<EventJudgeScoreSheet> 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;
2026-08-23 16:21:49 +08:00
// 规定元素完成量、缺失量由服务端按折算系数与目标数算出,供成绩聚合换算 Q 因子。
sheet.RequiredCompletedCount = calculation.RequiredCompletedCount;
sheet.RequiredMissingCount = calculation.RequiredMissingCount;
2026-08-22 20:34:05 +08:00
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,
2026-08-23 16:21:49 +08:00
SubjectCode = line.SubjectCode,
IsAutoGenerated = line.IsAutoGenerated,
ParentItemCode = line.ParentItemCode,
2026-08-22 20:34:05 +08:00
CountValue = line.CountValue,
InputValue = line.InputValue,
UnitValue = line.UnitValue,
Subtotal = line.Subtotal,
Sort = line.Sort,
CreatedUserId = _authManager.UserId,
CreatedTime = now
}).ToList());
}
return sheet;
}
/// <summary>
/// 组装提交结果:我的得分 + 本出场的聚合进度
/// </summary>
private async Task<SubmitJudgeScoreOutput> 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 = ScoreDisplayService.JudgeText(sheet, ctx.Config.IsFactorTotal),
2026-08-22 20:34:05 +08:00
ResultCompleted = outcome?.IsCompleted ?? false,
TotalScore = !string.Equals(JudgeRoleSchemaService.NormalizeRoleType(sheet.JudgeRoleType),
JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase)
|| outcome == null || !outcome.IsCompleted ? null
2026-08-22 20:34:05 +08:00
: outcome.IsDisqualified ? "取消资格" : ctx.Config.Format(outcome.TotalScore),
Progress = (outcome?.Roles ?? new List<EntryRoleProgress>()).Select(ToProgressDto).ToList()
};
}
/// <summary>
/// 把录入项转成计算器的入参
/// </summary>
private static List<ScoreItemInputValue> ToInputValues(List<JudgeScoreItemInput> items) =>
items.Select(x => new ScoreItemInputValue
{
2026-09-20 09:40:18 +08:00
ItemCode = x.ItemCode,
InputValue = x.InputValue,
CountValue = x.CountValue
2026-08-22 20:34:05 +08:00
}).ToList();
2026-09-21 17:52:13 +08:00
/// <summary>
/// 校验主裁端上传的扣分系数。最终得分系数 M 仍由平台按 1-扣分系数计算,
/// 这里不信任客户端结果,只用于确认页面明细与上传系数属于同一评分快照。
/// </summary>
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.###}");
}
2026-08-22 20:34:05 +08:00
/// <summary>
/// 组装合计预览
/// </summary>
private static JudgeScorePreviewDto ToPreviewDto(ScoreSheetCalculationResult calculation, ScoreRuleConfig config) =>
new()
{
// 裁判端的难度小计按规则精度展示,内部仍保留原始值参与最终汇总。
DifficultyScore = config.Round(calculation.DifficultyScore),
2026-08-22 20:34:05 +08:00
PerformanceScore = calculation.PerformanceScore,
RequiredScore = calculation.RequiredScore,
DeductionScore = calculation.DeductionScore,
TotalScore = calculation.TotalScore,
TotalScoreText = config.Format(calculation.TotalScore),
IsDisqualified = calculation.IsDisqualified,
2026-08-23 16:21:49 +08:00
DisqualifyReasonName = calculation.DisqualifyReasonName,
RequiredCompletedCount = calculation.RequiredCompletedCount,
RequiredMissingCount = calculation.RequiredMissingCount,
Steps = calculation.Steps.Select(x => new JudgeScoreStepDto
{
2026-09-20 09:40:18 +08:00
Code = x.Code,
Label = x.Label,
Formula = x.Formula,
Value = x.Value
2026-08-23 16:21:49 +08:00
}).ToList()
2026-08-22 20:34:05 +08:00
};
/// <summary>
/// 组装裁判类型的提交进度
/// </summary>
private static JudgeRoleProgressDto ToProgressDto(EntryRoleProgress progress) => new()
{
2026-09-20 09:40:18 +08:00
RoleType = progress.RoleType,
RoleName = progress.RoleName,
Symbol = progress.Symbol,
ExpectedCount = progress.ExpectedCount,
SubmittedCount = progress.SubmittedCount,
2026-08-22 20:34:05 +08:00
PendingJudges = progress.PendingJudges
};
/// <summary>
/// 评分单状态:草稿
/// </summary>
private const int SheetStatusDraft = 0;
/// <summary>
/// 评分单状态:已确认
/// </summary>
private const int SheetStatusConfirmed = 20;
/// <summary>
/// 评分单状态:已被修正
/// </summary>
private const int SheetStatusCorrected = 30;
/// <summary>
/// 评分任务状态:已接收
/// </summary>
private const int TaskStatusAccepted = 20;
/// <summary>
/// 评分任务状态:已提交
/// </summary>
private const int TaskStatusSubmitted = 40;
/// <summary>
/// 评分任务状态:已作废
/// </summary>
private const int TaskStatusVoided = 90;
/// <summary>
/// 消息类型:成绩修正提醒
/// </summary>
private const int MessageTypeCorrection = 1;
2026-08-22 20:34:05 +08:00
/// <summary>
/// 评分任务类型:成绩修正
/// </summary>
private const int TaskTypeCorrection = 1;
/// <summary>
/// 修正状态:已申请
/// </summary>
private const int CorrectionStatusApplied = 0;
/// <summary>
/// 修正状态:已批准
/// </summary>
private const int CorrectionStatusApproved = 10;
/// <summary>
/// 修正状态:已拒绝
/// </summary>
private const int CorrectionStatusRejected = 20;
/// <summary>
/// 修正状态:裁判修改中
/// </summary>
private const int CorrectionStatusEditing = 30;
/// <summary>
/// 修正状态:已完成
/// </summary>
private const int CorrectionStatusCompleted = 40;
/// <summary>
/// 修正状态:已取消
/// </summary>
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];
2026-08-22 20:34:05 +08:00
/// <summary>
/// 出场状态:退赛
/// </summary>
private const int EntryStatusWithdrawn = 80;
2026-09-20 09:40:18 +08:00
/// <summary>
/// 获取场次在赛事编排中的顺序。自动编排和秩序册导入生成的场次号末段均保存场次顺序;
/// 速度项目只有项目和场地段,同一项目内按场地顺序排列。
/// </summary>
private static int GetArrangementSequence(string? sessionNo)
{
if (string.IsNullOrWhiteSpace(sessionNo)) return int.MaxValue;
var match = Regex.Match(sessionNo, @"^EP\d+-(?:V)?\d+(?:-(?<sequence>\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;
}
/// <summary>
/// 将自动编排任务还原为“项目、场次、场地”的全局顺序。
/// 同组同场地的多条记录在数据库中属于同一场次,因此需结合出场顺序恢复原始场次号。
/// </summary>
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);
}
2026-09-21 17:52:13 +08:00
/// <summary>
/// 构建裁判端展示用的“场次-场地”,去除秩序册导入时写入场次名的项目和组别信息。
/// </summary>
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}";
}
2026-09-20 09:40:18 +08:00
/// <summary>
/// 获取用于裁判场次选择的赛事实时状态,避免后台状态同步延迟导致比赛中场次不可见。
/// </summary>
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;
}
/// <summary>
/// 兼容历史实体名和当前业务关系名两种成绩修正消息标识。
/// </summary>
private static bool IsScoreCorrectionMessage(EventJudgeMessage message) =>
string.Equals(message.RelationType, "ScoreCorrection", StringComparison.OrdinalIgnoreCase) ||
string.Equals(message.RelationType, nameof(EventScoreCorrection), StringComparison.OrdinalIgnoreCase);
2026-08-22 20:34:05 +08:00
/// <summary>
/// 场次状态名称
/// </summary>
private static string SessionStatusName(int status) => status switch
{
10 => "就绪",
20 => "检录中",
30 => "进行中",
40 => "暂停",
50 => "待确认",
60 => "已结束",
90 => "已取消",
_ => "草稿"
};
/// <summary>
/// 我的评分状态名称
/// </summary>
private static string ScoreStatusName(int status) => status switch
{
1 => "草稿",
2 => "已提交",
3 => "待修正",
_ => "未评分"
};
/// <summary>
/// 修正申请状态名称
/// </summary>
private static string CorrectionStatusName(int status) => status switch
{
CorrectionStatusApplied => "已申请",
CorrectionStatusApproved => "已批准",
CorrectionStatusRejected => "已拒绝",
CorrectionStatusEditing => "裁判修改中",
CorrectionStatusCompleted => "已完成",
CorrectionStatusCancelled => "已取消",
_ => "未申请"
};
}