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

1218 lines
63 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using YD_Event.Application.Auth;
using YD_Event.Application.Common;
using YD_Event.Application.Scoring.Dtos;
using System.Text.RegularExpressions;
namespace YD_Event.Application.Scoring;
/// <summary>
/// 打分系统裁判端评分接口(独立于微信小程序,供裁判端页面调用)。
/// 规定赛计分(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 - (失误 + 违例))。
/// 裁判提交的分值一律在服务端按计分规则重算,客户端上传的合计值不参与落库。
/// </summary>
[ApiDescriptionSettings("打分系统")]
[Route("api/judgescoring")]
[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;
private readonly ISqlSugarRepository<EventResult> _resultRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly EventScoreCalculationService _calculationService;
private readonly AuthManager _authManager;
private readonly IIdGenerator _idGenerator;
private readonly FileService _fileService;
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,
ISqlSugarRepository<EventResult> resultRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
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;
_calculationService = calculationService;
_authManager = authManager;
_idGenerator = idGenerator;
_fileService = fileService;
}
/// <summary>
/// 获取我的执裁场次
/// </summary>
[HttpGet("sessions")]
[DisplayName("获取我的执裁场次")]
public async Task<List<JudgeSessionDto>> GetSessions([FromQuery] long? eventId = null)
{
var referee = await EnsureRefereeAsync();
var assignments = await _assignmentRepository.AsQueryable()
.Where(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1)
.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();
var sessions = (await _sessionRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.Id) && x.SessionStatus != 90).ToListAsync())
.OrderByDescending(x => x.StartTime ?? DateTime.MinValue).ThenBy(x => x.SessionNo).ToList();
if (sessions.Count == 0) return new List<JudgeSessionDto>();
var eventIds = sessions.Select(x => x.EventId).Distinct().ToList();
var events = (await _eventRepository.AsQueryable().Where(x => eventIds.Contains(x.Id)).ToListAsync())
.ToDictionary(x => x.Id);
var venues = (await _venueRepository.AsQueryable()
.Where(x => sessions.Select(s => s.VenueId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var eventProjects = (await _eventProjectRepository.AsQueryable()
.Where(x => sessions.Select(s => s.EventProjectId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var projects = (await _projectRepository.AsQueryable()
.Where(x => eventProjects.Values.Select(e => e.ProjectId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var rules = (await _ruleRepository.AsQueryable()
.Where(x => sessions.Select(s => s.ScoreRuleId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var entries = await _entryRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && !x.IsVoided).ToListAsync();
var mySheets = await _sheetRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id
&& x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync();
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),
ScoredCount = mySheets.Count(x => x.SessionId == session.Id)
});
}
return rows;
}
/// <summary>
/// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。
/// </summary>
/// <param name="sessionId">场次ID。</param>
/// <returns>当前场次的赛事状态和操作权限。</returns>
[HttpGet("status/{sessionId}")]
[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("比赛场次不存在");
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)
};
}
/// <summary>
/// 获取场次出场名单与我的评分状态
/// </summary>
[HttpGet("entries/{sessionId}")]
[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 config = ScoreRuleConfigService.Parse(
(await _ruleRepository.GetFirstAsync(x => x.Id == session.ScoreRuleId))?.RuleConfigJson);
var entries = (await _entryRepository.AsQueryable().Where(x => x.SessionId == sessionId).ToListAsync())
.OrderBy(x => x.PlayOrder).ThenBy(x => x.Id).ToList();
if (entries.Count == 0) return new List<JudgeEntryDto>();
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,
ParticipantName = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
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();
}
/// <summary>
/// 获取评分表:表头、评分项、我已录入的值与合计预览
/// </summary>
[HttpGet("sheet/{sessionEntryId}")]
[DisplayName("获取评分表")]
public async Task<JudgeScoreSheetOutput> 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<EventJudgeScoreDetail>()
: 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);
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 = $"{ctx.Session.SessionName ?? ctx.Session.SessionNo}-{venue?.VenueName ?? "-"}",
EntryNo = entry.EntryNo,
ParticipantName = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
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)
};
}
/// <summary>
/// 保存评分草稿。草稿允许留空,不参与成绩计算。
/// </summary>
[HttpPost("save")]
[DisplayName("保存评分草稿")]
[UnitOfWork]
public async Task<JudgeScorePreviewDto> 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);
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);
}
/// <summary>
/// 提交评分。提交后自动聚合本出场成绩(T1、T2、T3 与总分)并重排名次。
/// </summary>
[HttpPost("submit")]
[DisplayName("提交评分")]
[UnitOfWork]
public async Task<SubmitJudgeScoreOutput> 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);
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);
}
/// <summary>
/// 查询场次成绩:各出场的 T1、T2、T3、总分与名次
/// </summary>
[HttpGet("results/{sessionId}")]
[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("仅主裁判可查看场次最终成绩");
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);
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
{
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();
}
/// <summary>
/// 获取可申请成绩修正的评分单
/// </summary>
[HttpGet("corrections/candidates/{sessionId}")]
[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)));
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
: string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
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();
}
/// <summary>
/// 申请成绩修正。规则未要求审批时申请即生效,裁判可直接重新提交评分。
/// </summary>
[HttpPost("corrections/apply")]
[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("评分单不存在");
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;
}
/// <summary>
/// 获取我的消息
/// </summary>
[HttpGet("messages")]
[DisplayName("获取裁判端消息")]
public async Task<List<JudgeMessageDto>> 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 correctionIds = messages
.Where(x => string.Equals(x.RelationType, nameof(EventScoreCorrection), StringComparison.OrdinalIgnoreCase)
&& 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);
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();
}
/// <summary>
/// 标记消息已读,不传消息ID表示全部标记已读
/// </summary>
[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);
}
/// <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;
// 文件头可以伪造;在上传 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);
}
/// <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);
}
/// <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("裁判档案不存在,请先完善裁判资料");
if (!await _assignmentRepository.IsAnyAsync(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1))
throw Oops.Bah("裁判分配尚未提交,暂不能进入裁判端");
return referee;
}
/// <summary>
/// 校验当前场次状态是否允许评分。
/// </summary>
/// <param name="sessionStatus">场次状态。</param>
/// <param name="eventStatus">赛事状态。</param>
private static void EnsureCanScore(int sessionStatus, EventStatus eventStatus)
{
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;
}
/// <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>
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);
}
/// <summary>
/// 取出场对应的运动员姓名,用于评分表表头展示
/// </summary>
private async Task<List<string>> LoadMemberNamesAsync(EventSessionEntry entry)
{
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 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
};
/// <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));
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 = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName,
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;
}
/// <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;
// 规定元素完成量、缺失量由服务端按折算系数与目标数算出,供成绩聚合换算 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;
}
/// <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 = 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<EntryRoleProgress>()).Select(ToProgressDto).ToList()
};
}
/// <summary>
/// 把录入项转成计算器的入参
/// </summary>
private static List<ScoreItemInputValue> ToInputValues(List<JudgeScoreItemInput> items) =>
items.Select(x => new ScoreItemInputValue
{
ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue
}).ToList();
/// <summary>
/// 组装合计预览
/// </summary>
private static JudgeScorePreviewDto ToPreviewDto(ScoreSheetCalculationResult calculation, ScoreRuleConfig config) =>
new()
{
DifficultyScore = 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()
};
/// <summary>
/// 组装裁判类型的提交进度
/// </summary>
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
};
/// <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 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];
/// <summary>
/// 出场状态:退赛
/// </summary>
private const int EntryStatusWithdrawn = 80;
/// <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 => "已取消",
_ => "未申请"
};
}