using Microsoft.Extensions.Logging; using YD_Event.Application.Auth; using YD_Event.Application.Client.Dtos; using YD_Event.Application.Sms; namespace YD_Event.Application.Client; /// 小程序检录员按秩序册列进行顺序检录。 [ApiDescriptionSettings("微信小程序接口")] [Authorize] public partial class CheckinController : IDynamicApiController { private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _taskRepository; private readonly ISqlSugarRepository _recordRepository; private readonly ISqlSugarRepository _personRepository; private readonly ISqlSugarRepository _registrationRepository; private readonly CheckinScheduleService _scheduleService; private readonly AliyunSmsService _smsService; private readonly ILogger _logger; private readonly AuthManager _authManager; public CheckinController( ISqlSugarRepository userRepository, ISqlSugarRepository taskRepository, ISqlSugarRepository recordRepository, ISqlSugarRepository personRepository, ISqlSugarRepository registrationRepository, CheckinScheduleService scheduleService, AliyunSmsService smsService, ILogger logger, AuthManager authManager) { _userRepository = userRepository; _taskRepository = taskRepository; _recordRepository = recordRepository; _personRepository = personRepository; _registrationRepository = registrationRepository; _scheduleService = scheduleService; _smsService = smsService; _logger = logger; _authManager = authManager; } [HttpGet("current")] [DisplayName("获取当前赛事检录任务")] public async Task GetCurrent( [FromQuery] long? eventProjectId = null, [FromQuery] long? groupId = null) { var userId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(userId); return task == null ? null : await BuildContextAsync(task, eventProjectId, groupId); } [HttpPost("status")] [UnitOfWork] [DisplayName("更新秩序册列当前检次状态")] public async Task UpdateStatus([FromBody] UpdateCheckinStatusInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = CheckinScheduleService.ResolveUnit(options, input.UnitKey); if (task.CheckinNo > 1) { var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousPassed = previousTask != null && await _recordRepository.IsAnyAsync(x => x.TaskId == previousTask.Id && x.SessionKey == unit.UnitKey && x.Checked && !x.DeleteMark); if (!previousPassed) throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}"); } if (!input.Checked) { var laterTasks = await _taskRepository.AsQueryable() .Where(x => x.EventId == task.EventId && x.CheckinNo > task.CheckinNo && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); var laterTaskIds = laterTasks.Select(x => x.Id).ToList(); if (laterTaskIds.Count > 0 && await _recordRepository.IsAnyAsync(x => laterTaskIds.Contains(x.TaskId) && x.SessionKey == unit.UnitKey && x.Checked && !x.DeleteMark)) throw Oops.Bah("该秩序册列已通过后续检录,不能取消当前检录"); } var record = await _recordRepository.GetFirstAsync(x => x.TaskId == task.Id && x.SessionKey == unit.UnitKey && !x.DeleteMark); if (record == null) { record = new CheckinRecord { TaskId = task.Id, Round = task.CheckinNo, SessionKey = unit.UnitKey, SessionOrder = unit.Order, GroupId = unit.GroupId, RegistrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(), CheckerUserId = checkerUserId, Checked = input.Checked, CheckedTime = input.Checked ? DateTime.Now : null, CreatedUserId = checkerUserId, CreatedTime = DateTime.Now }; await _recordRepository.InsertAsync(record); } else { record.Round = task.CheckinNo; record.SessionOrder = unit.Order; record.GroupId = unit.GroupId; record.CheckerUserId = checkerUserId; record.Checked = input.Checked; record.CheckedTime = input.Checked ? DateTime.Now : null; record.UpdatedUserId = checkerUserId; record.UpdatedTime = DateTime.Now; await _recordRepository.UpdateAsync(record); } // 保持页面当前项目/组别筛选;UnitKey 已独立校验检录项归属。 return await BuildContextAsync(task, input.EventProjectId, input.GroupId); } [HttpPost("start")] [UnitOfWork] [DisplayName("开始检录并通知报名人员")] public async Task Start([FromBody] StartCheckinInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var project = options.Projects.FirstOrDefault(x => x.EventProjectId == input.EventProjectId) ?? throw Oops.Bah("所选参赛项目不属于当前赛事秩序册"); var group = project.Groups.FirstOrDefault(x => x.Id == input.GroupId) ?? throw Oops.Bah("所选参赛组别不属于当前参赛项目"); var units = options.Units .Where(x => x.EventProjectId == input.EventProjectId && x.GroupId == input.GroupId) .OrderBy(x => x.Order).ToList(); if (units.Count == 0) throw Oops.Bah("所选项目组别没有可检录的参赛队伍"); // 记录当前检录进度,供运动员端与投屏读取"当前检录项目"。 task.CurrentEventProjectId = input.EventProjectId; task.CurrentGroupId = input.GroupId; task.CurrentGroupName = group.GroupName; task.GroupOrder = group.Order; task.TotalGroups = project.Groups.Count; task.NextGroupName = project.Groups.FirstOrDefault(x => x.Order > group.Order)?.GroupName; task.CurrentSessionKey = units[0].UnitKey; task.CurrentSessionOrder = units[0].Order; task.CurrentSessionName = $"{project.ProjectName}-{group.GroupName}"; task.CurrentSessionVenue = units[0].VenueName; task.UpdatedUserId = checkerUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); var noticeName = $"{project.ProjectName}-{group.GroupName}"; var (total, success, failed) = await NotifyUnitsAsync(units, options.EventName, noticeName); var context = await BuildContextAsync(task, input.EventProjectId, input.GroupId); return new StartCheckinOutput { TaskId = task.Id, EventProjectId = project.EventProjectId, ProjectName = project.ProjectName, GroupId = group.Id, GroupName = group.GroupName, UnitCount = units.Count, NoticeTotal = total, SmsSuccessCount = success, SmsFailedCount = failed, Message = success > 0 ? $"已开始{ToCheckinName(task.CheckinNo)},已通知{success}人,{failed}人通知失败" : $"已开始{ToCheckinName(task.CheckinNo)},共{total}人待检录", Context = context }; } [HttpPost("scan")] [UnitOfWork] [DisplayName("扫码检录")] public async Task Scan([FromBody] ScanCheckinInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); var code = input.Code?.Trim() ?? string.Empty; if (code.Length == 0) throw Oops.Bah("扫码内容不能为空"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = ResolveScannedUnit(options, code, out var athlete); if (unit == null) return new ScanCheckinOutput { Success = false, Message = "扫码内容与当前赛事秩序册不匹配,请核对号码布" }; await EnsurePreviousPassedAsync(task, unit.UnitKey); await UpsertRecordAsync(task, unit, checkerUserId, true); var context = await BuildContextAsync(task, input.EventProjectId ?? unit.EventProjectId, input.GroupId ?? unit.GroupId ?? 0); return new ScanCheckinOutput { Success = true, Message = $"{ToCheckinName(task.CheckinNo)}检录成功", UnitKey = unit.UnitKey, ProjectName = unit.ProjectName, GroupName = unit.GroupName, UnitName = string.IsNullOrWhiteSpace(unit.TeamName) ? unit.UnitName : unit.TeamName, AthleteName = athlete?.Name ?? string.Join(", ", unit.Athletes.Select(x => x.Name)), BibNo = athlete?.BibNo ?? string.Empty, Context = context }; } [HttpPost("on-stage")] [UnitOfWork] [DisplayName("标记参赛队伍上场")] public async Task OnStage([FromBody] CheckinOnStageInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); var activeTasks = await GetActiveEventTasksAsync(task.EventId); var lastCheckinNo = activeTasks.Count == 0 ? task.CheckinNo : activeTasks.Max(x => x.CheckinNo); if (task.CheckinNo != lastCheckinNo) throw Oops.Bah($"上场由{ToCheckinName(lastCheckinNo)}检录员操作"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = CheckinScheduleService.ResolveUnit(options, input.UnitKey); await EnsurePreviousPassedAsync(task, unit.UnitKey); await UpsertRecordAsync(task, unit, checkerUserId, true); // 上场后把当前列同步到任务,供运动员端展示"当前正在比赛"。 task.CurrentEventProjectId = unit.EventProjectId; task.CurrentGroupId = unit.GroupId; task.CurrentGroupName = unit.GroupName; task.CurrentSessionKey = unit.UnitKey; task.CurrentSessionOrder = unit.Order; task.CurrentSessionName = $"{unit.ProjectName}-{unit.GroupName}"; task.CurrentSessionVenue = unit.VenueName; task.UpdatedUserId = checkerUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); return await BuildContextAsync(task, input.EventProjectId, input.GroupId); } /// 按扫码内容定位秩序册列;支持号码布、报名人员ID和姓名。 private static CheckinOrderBookUnitDto? ResolveScannedUnit( CheckinEventOptionsDto options, string code, out CheckinAthleteDto? athlete) { athlete = null; foreach (var unit in options.Units.OrderBy(x => x.Order)) { var matched = unit.Athletes.FirstOrDefault(x => x.BibNo == code || x.Id.ToString() == code || x.Name == code); if (matched != null) { athlete = matched; return unit; } } // 兼容团队二维码:扫码内容直接是秩序册列标识。 return options.Units.FirstOrDefault(x => x.UnitKey == code); } /// 检录顺序卡控:非一检必须先通过上一检次。 private async Task EnsurePreviousPassedAsync(CheckinTask task, string unitKey) { if (task.CheckinNo <= 1) return; var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousPassed = previousTask != null && await _recordRepository.IsAnyAsync(x => x.TaskId == previousTask.Id && x.SessionKey == unitKey && x.Checked && !x.DeleteMark); if (!previousPassed) throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}"); } private async Task UpsertRecordAsync( CheckinTask task, CheckinOrderBookUnitDto unit, long checkerUserId, bool isChecked) { var record = await _recordRepository.GetFirstAsync(x => x.TaskId == task.Id && x.SessionKey == unit.UnitKey && !x.DeleteMark); if (record == null) { await _recordRepository.InsertAsync(new CheckinRecord { TaskId = task.Id, Round = task.CheckinNo, SessionKey = unit.UnitKey, SessionOrder = unit.Order, GroupId = unit.GroupId, RegistrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(), CheckerUserId = checkerUserId, Checked = isChecked, CheckedTime = isChecked ? DateTime.Now : null, CreatedUserId = checkerUserId, CreatedTime = DateTime.Now }); return; } record.Round = task.CheckinNo; record.SessionOrder = unit.Order; record.GroupId = unit.GroupId; record.CheckerUserId = checkerUserId; record.Checked = isChecked; record.CheckedTime = isChecked ? DateTime.Now : null; record.UpdatedUserId = checkerUserId; record.UpdatedTime = DateTime.Now; await _recordRepository.UpdateAsync(record); } /// 对报名了所选项目组别的人员发送检录通知;单人失败不影响整体流程。 private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync( List units, string eventName, string projectGroupName) { var personIds = units.SelectMany(x => x.RegistrationPersonIds).Distinct().ToList(); if (personIds.Count == 0) return (0, 0, 0); var persons = await _personRepository.AsQueryable() .Where(x => personIds.Contains(x.Id) && !x.DeleteMark) .ToListAsync(); // 报名人员未填手机号时回退到报名联系人手机号。 var registrationIds = persons.Select(x => x.RegistrationId).Distinct().ToList(); var registrations = registrationIds.Count == 0 ? new List() : await _registrationRepository.AsQueryable() .Where(x => registrationIds.Contains(x.Id) && !x.DeleteMark).ToListAsync(); var contactMap = registrations.ToDictionary(x => x.Id, x => x.ContactPhone); var success = 0; var failed = 0; foreach (var person in persons) { var phone = person.Phone; if (string.IsNullOrWhiteSpace(phone)) contactMap.TryGetValue(person.RegistrationId, out phone); if (string.IsNullOrWhiteSpace(phone)) { failed++; continue; } try { var sent = await _smsService.SendCheckinNotice(phone, person.Name, projectGroupName); if (sent) success++; else failed++; } catch (Exception ex) { failed++; _logger.LogWarning(ex, "检录通知发送失败:{EventName} {Project} {Phone}", eventName, projectGroupName, phone); } } return (persons.Count, success, failed); } private async Task BuildContextAsync(CheckinTask task, long? eventProjectId, long? groupId) { var options = await _scheduleService.GetOptionsAsync(task.EventId); var selectedProject = options.Projects.FirstOrDefault(x => x.EventProjectId == eventProjectId) ?? options.Projects.FirstOrDefault(); if (selectedProject == null) throw Oops.Bah("当前赛事秩序册没有可检录项目"); var selectedGroup = selectedProject.Groups.FirstOrDefault(x => x.Id == groupId) ?? selectedProject.Groups.FirstOrDefault(); if (selectedGroup == null) throw Oops.Bah("所选参赛项目没有可检录组别"); var allUnits = options.Units.OrderBy(x => x.Order).ToList(); var units = allUnits .Where(x => x.EventProjectId == selectedProject.EventProjectId && x.GroupId == selectedGroup.Id) .OrderBy(x => x.Order).ToList(); var allUnitKeys = allUnits.Select(x => x.UnitKey).ToHashSet(); var records = await _recordRepository.AsQueryable() .Where(x => x.TaskId == task.Id && x.SessionKey != null && !x.DeleteMark) .ToListAsync(); var checkedKeys = records.Where(x => x.Checked && allUnitKeys.Contains(x.SessionKey!)) .Select(x => x.SessionKey!).ToHashSet(); var activeTasks = await GetActiveEventTasksAsync(task.EventId); // 三检通过视为已上场;仅配置两检时,二检通过即视为已上场。 var onStageKeys = await GetOnStageKeysAsync(activeTasks, allUnitKeys); HashSet previousPassedKeys; if (task.CheckinNo == 1) { previousPassedKeys = allUnitKeys; } else { var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousRecords = previousTask == null ? new List() : await _recordRepository.AsQueryable() .Where(x => x.TaskId == previousTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark) .ToListAsync(); previousPassedKeys = previousRecords.Where(x => allUnitKeys.Contains(x.SessionKey!)) .Select(x => x.SessionKey!).ToHashSet(); } var allStatusUnits = allUnits.Select(unit => { var previousPassed = previousPassedKeys.Contains(unit.UnitKey); return new CheckinOrderBookUnitStatusDto { UnitKey = unit.UnitKey, Order = unit.Order, SessionIndex = unit.SessionIndex, VenueIndex = unit.VenueIndex, VenueName = unit.VenueName, EventProjectId = unit.EventProjectId, ProjectId = unit.ProjectId, ProjectName = unit.ProjectName, GroupId = unit.GroupId, GroupName = unit.GroupName, RegistrationItemId = unit.RegistrationItemId, TeamName = unit.TeamName, UnitName = unit.UnitName, RegistrationPersonIds = unit.RegistrationPersonIds, Athletes = unit.Athletes, Checked = checkedKeys.Contains(unit.UnitKey), PreviousPassed = previousPassed, CanCheck = previousPassed, LockReason = previousPassed ? string.Empty : $"待通过{ToCheckinName(task.CheckinNo - 1)}", OnStage = onStageKeys.Contains(unit.UnitKey) }; }).ToList(); var statusUnits = allStatusUnits .Where(x => x.EventProjectId == selectedProject.EventProjectId && x.GroupId == selectedGroup.Id) .ToList(); // 为项目、组别选择区计算当前检次概况,全部完成时由小程序显示绿色状态。 foreach (var project in options.Projects) { var projectUnits = allStatusUnits.Where(x => x.EventProjectId == project.EventProjectId).ToList(); ApplyProgress(projectUnits, out var projectTotal, out var projectChecked, out var projectLocked, out var projectPercent, out var projectCompleted); project.TotalUnits = projectTotal; project.CheckedUnits = projectChecked; project.LockedUnits = projectLocked; project.ProgressPercent = projectPercent; project.IsCompleted = projectCompleted; foreach (var group in project.Groups) { var groupUnits = projectUnits.Where(x => x.GroupId == group.Id).ToList(); ApplyProgress(groupUnits, out var groupTotal, out var groupChecked, out var groupLocked, out var groupPercent, out var groupCompleted); group.TotalUnits = groupTotal; group.CheckedUnits = groupChecked; group.LockedUnits = groupLocked; group.ProgressPercent = groupPercent; group.IsCompleted = groupCompleted; } } return new CheckinContextDto { TaskId = task.Id, EventId = task.EventId, EventName = options.EventName, TotalCheckins = activeTasks.Count, CurrentCheckinNo = task.CheckinNo, CurrentCheckinName = ToCheckinName(task.CheckinNo), TotalRounds = activeTasks.Count, CurrentRound = task.CheckinNo, RoundName = ToCheckinName(task.CheckinNo), SelectedEventProjectId = selectedProject.EventProjectId, SelectedGroupId = selectedGroup.Id, CurrentGroupId = selectedGroup.Id, CurrentGroupName = selectedGroup.GroupName, Projects = options.Projects, Units = statusUnits, PendingUnits = statusUnits.Where(x => x.CanCheck && !x.Checked).ToList(), // 已检录跟随当前选中的项目和组别,便于现场聚焦操作。 CheckedUnits = statusUnits.Where(x => x.Checked).ToList(), LockedUnits = statusUnits.Where(x => !x.CanCheck).ToList(), Rounds = activeTasks.Select(x => new CheckinRoundOptionDto { Round = x.CheckinNo, RoundName = ToCheckinName(x.CheckinNo) }).ToList() }; } private async Task GetCurrentTaskAsync(long userId) => await _taskRepository.AsQueryable() .Where(x => x.CheckerUserId == userId && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .OrderByDescending(x => x.UpdatedTime).OrderByDescending(x => x.CreatedTime).FirstAsync(); private async Task GetEventTaskAsync(long eventId, int checkinNo) => await _taskRepository.GetFirstAsync(x => x.EventId == eventId && x.CheckinNo == checkinNo && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); private async Task> GetActiveEventTasksAsync(long eventId) => await _taskRepository.AsQueryable() .Where(x => x.EventId == eventId && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .OrderBy(x => x.CheckinNo).ToListAsync(); /// 三检通过即为已上场;仅配置两检时由二检标记上场。 private async Task> GetOnStageKeysAsync( List activeTasks, HashSet validUnitKeys) { if (activeTasks.Count == 0) return new HashSet(); var onStageCheckinNo = ResolveOnStageCheckinNo(activeTasks); var onStageTask = activeTasks.First(x => x.CheckinNo == onStageCheckinNo); var records = await _recordRepository.AsQueryable() .Where(x => x.TaskId == onStageTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark) .ToListAsync(); return records.Where(x => validUnitKeys.Contains(x.SessionKey!)) .Select(x => x.SessionKey!).ToHashSet(); } /// 上场检次固定为三检;未配置三检但配置了二检时使用二检。 private static int ResolveOnStageCheckinNo(List activeTasks) { if (activeTasks.Any(x => x.CheckinNo == 3)) return 3; if (activeTasks.Any(x => x.CheckinNo == 2)) return 2; return activeTasks.Count == 0 ? 1 : activeTasks.Min(x => x.CheckinNo); } private async Task EnsureCheckerAsync() { var userId = _authManager.UserId; var user = await _userRepository.GetFirstAsync(x => x.Id == userId && !x.DeleteMark); if (user == null) throw Oops.Bah("用户不存在"); if (user.MiniProgramRole != MiniProgramRole.Checker) throw Oops.Bah("当前账号不是检录员身份"); return userId; } private static string ToCheckinName(int checkinNo) => checkinNo switch { 1 => "一检", 2 => "二检", 3 => "三检", _ => $"第{checkinNo}检" }; private static void ApplyProgress( List units, out int total, out int checkedCount, out int lockedCount, out int percent, out bool completed) { total = units.Count; checkedCount = units.Count(x => x.Checked); lockedCount = units.Count(x => !x.CanCheck); percent = total == 0 ? 0 : (int)Math.Round(checkedCount * 100d / total); completed = total > 0 && checkedCount == total; } }