using System.Text.RegularExpressions; using YD_Event.Application.Auth; using YD_Event.Application.Client.Dtos; using YD_Event.Application.Event; namespace YD_Event.Application.Client; /// 后台检录员及赛事顺序检录管理。 [ApiDescriptionSettings("打分系统")] [Route("api/checkinadmin")] [Authorize] public class CheckinAdminController : IDynamicApiController { private const int MaxCheckerCount = 3; private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _sysUserRepository; private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _taskRepository; private readonly CheckinScheduleService _scheduleService; private readonly AuthManager _authManager; private readonly IIdGenerator _idGenerator; public CheckinAdminController( ISqlSugarRepository userRepository, ISqlSugarRepository sysUserRepository, ISqlSugarRepository eventRepository, ISqlSugarRepository taskRepository, CheckinScheduleService scheduleService, AuthManager authManager, IIdGenerator idGenerator) { _userRepository = userRepository; _sysUserRepository = sysUserRepository; _eventRepository = eventRepository; _taskRepository = taskRepository; _scheduleService = scheduleService; _authManager = authManager; _idGenerator = idGenerator; } [HttpGet("event-options")] [DisplayName("获取赛事秩序册及检次分配选项")] public async Task GetEventOptions([FromQuery] long eventId) { EnsureManager(); var options = await _scheduleService.GetOptionsAsync(eventId); var tasks = await GetActiveEventTasksAsync(eventId); options.MaxCheckers = MaxCheckerCount; options.AssignedCheckinNumbers = tasks.Select(x => x.CheckinNo).OrderBy(x => x).ToList(); options.NextCheckinNo = Math.Min(tasks.Count + 1, MaxCheckerCount); return options; } [HttpGet("checker/mobile-availability")] [DisplayName("检查检录员手机号是否已存在其他身份")] public async Task CheckMobileAvailability( [FromQuery] string mobile, [FromQuery] long? eventId = null, [FromQuery] long? excludeCheckerUserId = null) { EnsureManager(); mobile = mobile?.Trim() ?? string.Empty; if (!IsValidMobile(mobile)) { return new CheckerAccountAvailabilityDto { Available = false, Message = "手机号格式不正确" }; } return await GetCheckerAvailabilityAsync(mobile, eventId, excludeCheckerUserId); } [HttpGet("checkers")] [DisplayName("获取检录员赛事绑定列表")] public async Task> GetCheckers([FromQuery] long? eventId) { EnsureManager(); var tasks = await _taskRepository.AsQueryable() .Where(x => !x.DeleteMark && x.Status == AvailabilityStatus.Enable) .WhereIF(eventId.HasValue, x => x.EventId == eventId.Value) .OrderBy(x => x.CheckinNo) .ToListAsync(); var checkerIds = tasks.Where(x => x.CheckerUserId.HasValue).Select(x => x.CheckerUserId!.Value).Distinct().ToList(); var eventIds = tasks.Select(x => x.EventId).Distinct().ToList(); var users = checkerIds.Count == 0 ? new List() : await _userRepository.AsQueryable().Where(x => checkerIds.Contains(x.Id) && !x.DeleteMark).ToListAsync(); var events = eventIds.Count == 0 ? new List() : await _eventRepository.AsQueryable().Where(x => eventIds.Contains(x.Id) && !x.DeleteMark).ToListAsync(); var userMap = users.ToDictionary(x => x.Id); var eventMap = events.ToDictionary(x => x.Id); return tasks.Where(x => x.CheckerUserId.HasValue).Select(task => { userMap.TryGetValue(task.CheckerUserId!.Value, out var checker); eventMap.TryGetValue(task.EventId, out var evt); return ToDto(checker, task, evt?.EventName ?? string.Empty); }).ToList(); } [HttpPost("checker/save")] [UnitOfWork] [DisplayName("后台添加检录员并分配检次")] public async Task SaveChecker([FromBody] SaveCheckerInput input) { EnsureManager(); var managerUserId = _authManager.UserId; input.Name = input.Name?.Trim() ?? string.Empty; input.Mobile = input.Mobile?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(input.Name)) throw Oops.Bah("姓名不能为空"); if (!IsValidMobile(input.Mobile)) throw Oops.Bah("手机号格式不正确"); if (input.CheckinNo is < 1 or > MaxCheckerCount) throw Oops.Bah("检次只能选择一检、二检或三检"); // 同时验证赛事存在且秩序册包含可检录列。 var options = await _scheduleService.GetOptionsAsync(input.EventId); if (options.Units.Count == 0) throw Oops.Bah("当前赛事秩序册没有可检录列"); CheckinTask? task = null; YD_Event.Core.Entities.AppUser? checker = null; if (input.CheckerUserId.HasValue) { checker = await _userRepository.GetFirstAsync(x => x.Id == input.CheckerUserId.Value && !x.DeleteMark); if (checker == null || checker.MiniProgramRole != MiniProgramRole.Checker) throw Oops.Bah("检录员不存在"); task = await _taskRepository.GetFirstAsync(x => x.CheckerUserId == checker.Id && x.EventId == input.EventId && !x.DeleteMark); if (task == null) throw Oops.Bah("检录员赛事绑定不存在"); if (task.CheckinNo != input.CheckinNo) throw Oops.Bah("已分配的检次不能直接修改;请从最高检次开始解除后重新分配"); } else { var availability = await GetCheckerAvailabilityAsync(input.Mobile, input.EventId, null); if (!availability.Available) throw Oops.Bah(availability.Message); if (availability.ExistingChecker && availability.CheckerUserId.HasValue) { checker = await _userRepository.GetFirstAsync(x => x.Id == availability.CheckerUserId.Value && !x.DeleteMark); if (checker == null) throw Oops.Bah("检录员账号不存在"); } } if (input.CheckerUserId.HasValue) { var editAvailability = await GetCheckerAvailabilityAsync(input.Mobile, input.EventId, checker?.Id); if (!editAvailability.Available) throw Oops.Bah(editAvailability.Message); } var eventTasks = await GetActiveEventTasksAsync(input.EventId); var otherTasks = task == null ? eventTasks : eventTasks.Where(x => x.Id != task.Id).ToList(); if (task == null && otherTasks.Count >= MaxCheckerCount) throw Oops.Bah("每个赛事最多只能添加3名检录员"); if (otherTasks.Any(x => x.CheckinNo == input.CheckinNo)) throw Oops.Bah($"{ToCheckinName(input.CheckinNo)}已分配给其他检录员"); var assignedNumbers = otherTasks.Select(x => x.CheckinNo).Append(input.CheckinNo).OrderBy(x => x).ToList(); var expectedNumbers = Enumerable.Range(1, assignedNumbers.Count).ToList(); if (!assignedNumbers.SequenceEqual(expectedNumbers)) throw Oops.Bah("检次必须从一检开始连续分配,不能跳过前一检"); if (checker == null) { if (string.IsNullOrWhiteSpace(input.Password) || input.Password.Length is < 6 or > 32) throw Oops.Bah("新增检录员时密码必须为6-32位"); var checkerId = _idGenerator.NewLong(); var now = DateTime.Now; checker = new YD_Event.Core.Entities.AppUser { Id = checkerId, Name = input.Name, Mobile = input.Mobile, Password = EncryptPassword(checkerId, input.Password), PersonType = PersonType.User, MiniProgramRole = MiniProgramRole.Checker, Status = AvailabilityStatus.Enable, AuditStatus = AuditStatus.Pass, AuditUserId = managerUserId, AuditTime = now, CreatedUserId = managerUserId, CreatedTime = now }; await _userRepository.InsertAsync(checker); } else { checker.Name = input.Name; checker.Mobile = input.Mobile; checker.Status = AvailabilityStatus.Enable; if (!string.IsNullOrWhiteSpace(input.Password)) { if (input.Password.Length is < 6 or > 32) throw Oops.Bah("密码必须为6-32位"); checker.Password = EncryptPassword(checker.Id, input.Password); } checker.UpdatedUserId = managerUserId; checker.UpdatedTime = DateTime.Now; await _userRepository.UpdateAsync(checker); } if (task == null) { // 兼容历史解绑数据:旧逻辑只禁用了任务,但唯一索引仍将其视为占用。 // 同一赛事、同一检次存在已停用任务时直接复用该任务槽位,避免再次插入触发唯一索引冲突。 task = await _taskRepository.GetFirstAsync(x => x.EventId == input.EventId && x.CheckinNo == input.CheckinNo && !x.DeleteMark); if (task == null) { task = new CheckinTask { EventId = input.EventId, CreatedUserId = managerUserId, CreatedTime = DateTime.Now }; } task.CheckerUserId = checker.Id; } ApplyCheckinAssignment(task, input.CheckinNo, assignedNumbers.Count, input.Venue); task.TaskStatus = 1; task.Status = AvailabilityStatus.Enable; task.DeleteMark = false; task.UpdatedUserId = managerUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.InsertOrUpdateAsync(task); await SyncEventTaskTotalsAsync(input.EventId, assignedNumbers.Count, managerUserId); return ToDto(checker, task, options.EventName); } [HttpPost("task/progress")] [DisplayName("旧版切换场次接口")] public Task UpdateProgress([FromBody] UpdateCheckinProgressInput input) { throw Oops.Bah("检录员已按一检、二检、三检固定分工,不再手动切换场次"); } [HttpPost("checker/unbind")] [UnitOfWork] [DisplayName("解除检录员赛事绑定")] public async Task Unbind([FromBody] UnbindCheckerInput input) { EnsureManager(); var task = await _taskRepository.GetFirstAsync(x => x.Id == input.TaskId && !x.DeleteMark); if (task == null) throw Oops.Bah("检录任务不存在"); var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == task.EventId && !x.DeleteMark); if (eventInfo == null) throw Oops.Bah("赛事不存在"); var eventStatus = GetEffectiveEventStatus(eventInfo, DateTime.Now); if (eventStatus == EventStatus.Ongoing) throw Oops.Bah("赛事比赛中,不支持解除检录员绑定"); var laterExists = await _taskRepository.IsAnyAsync(x => x.EventId == task.EventId && x.CheckinNo > task.CheckinNo && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (laterExists) throw Oops.Bah("请先解除最高检次的检录员,检次不能出现空缺"); task.TaskStatus = 0; task.Status = AvailabilityStatus.Disable; task.DeleteMark = true; task.UpdatedUserId = _authManager.UserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); var remainingCount = (await GetActiveEventTasksAsync(task.EventId)).Count; await SyncEventTaskTotalsAsync(task.EventId, remainingCount, _authManager.UserId); } private async Task> GetActiveEventTasksAsync(long eventId) { return 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 SyncEventTaskTotalsAsync(long eventId, int total, long operatorUserId) { var tasks = await GetActiveEventTasksAsync(eventId); foreach (var task in tasks) { task.TotalRounds = total; task.CurrentRound = task.CheckinNo; task.RoundName = ToCheckinName(task.CheckinNo); task.UpdatedUserId = operatorUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); } } private void EnsureManager() { if (_authManager.AuthPlatformType != AuthPlatformType.Manager) throw Oops.Bah("仅后台账号可以管理检录员和赛事绑定"); } private string EncryptPassword(long userId, string password) => MD5Encryption.Encrypt($"{_idGenerator.Encode(userId)}{password}"); private static bool IsValidMobile(string mobile) => Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"); private async Task GetCheckerAvailabilityAsync( string mobile, long? eventId, long? excludeCheckerUserId) { // SqlSugar 无法稳定翻译“Nullable.HasValue + != Nullable.Value”的组合表达式, // SQL 只按手机号查询,排除当前编辑对象的判断放到内存执行。 var usersWithSameMobile = await _userRepository.AsQueryable() .Where(x => x.Mobile == mobile) .ToListAsync(); var existingUser = usersWithSameMobile.FirstOrDefault(x => !excludeCheckerUserId.HasValue || x.Id != excludeCheckerUserId.Value); if (existingUser != null) { if (existingUser.DeleteMark || existingUser.MiniProgramRole != MiniProgramRole.Checker) { return new CheckerAccountAvailabilityDto { Available = false, Message = "该手机号已被添加过其他身份,不能重复添加" }; } var activeTasks = await _taskRepository.AsQueryable() .Where(x => x.CheckerUserId == existingUser.Id && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); if (eventId.HasValue && activeTasks.Any(x => x.EventId == eventId.Value)) { return new CheckerAccountAvailabilityDto { Available = false, Message = "该检录员已添加到当前赛事" }; } var occupiedEventIds = activeTasks .Where(x => !eventId.HasValue || x.EventId != eventId.Value) .Select(x => x.EventId) .Distinct() .ToList(); if (occupiedEventIds.Count > 0) { var occupiedEvents = await _eventRepository.AsQueryable() .Where(x => occupiedEventIds.Contains(x.Id) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); var now = DateTime.Now; var occupiedEvent = occupiedEvents.FirstOrDefault(x => GetEffectiveEventStatus(x, now) != EventStatus.Ended); if (occupiedEvent != null) { return new CheckerAccountAvailabilityDto { Available = false, Message = $"该检录员已绑定未结束赛事“{occupiedEvent.EventName}”,暂不能添加到其他赛事" }; } } return new CheckerAccountAvailabilityDto { Available = true, Message = "该手机号为已释放的检录员账号,可直接绑定当前赛事", ExistingChecker = true, CheckerUserId = existingUser.Id, CheckerName = existingUser.Name ?? string.Empty }; } var sysUserExists = await _sysUserRepository.IsAnyAsync(x => (x.Mobile == mobile || x.Account == mobile) && !x.DeleteMark); return sysUserExists ? new CheckerAccountAvailabilityDto { Available = false, Message = "该手机号已是后台系统账号,不能再添加为检录员" } : new CheckerAccountAvailabilityDto { Available = true, Message = "手机号可以使用" }; } private static void ApplyCheckinAssignment(CheckinTask task, int checkinNo, int total, string? venue) { task.CheckinNo = checkinNo; task.TotalRounds = total; task.CurrentRound = checkinNo; task.RoundName = ToCheckinName(checkinNo); task.CurrentSessionKey = null; task.CurrentSessionOrder = 0; task.CurrentSessionName = null; task.CurrentEventProjectId = null; task.CurrentSessionVenue = null; task.CurrentGroupId = null; task.CurrentGroupName = "全部运动员"; task.GroupOrder = 0; task.TotalGroups = 0; task.NextGroupName = null; task.Venue = venue?.Trim(); } /// /// 统一检录管理使用的赛事状态口径。后台状态与比赛时间不一致时按生命周期靠后的状态处理, /// 避免状态同步延迟导致比赛中仍可解绑,或赛事结束后账号未及时释放。 /// private static EventStatus GetEffectiveEventStatus(EventInfo eventInfo, DateTime now) { var calculatedStatus = EventStatusUpdateService.CalculateEventStatus(eventInfo, now); if (calculatedStatus == EventStatus.Ended || eventInfo.EventStatus == EventStatus.Ended) return EventStatus.Ended; if (calculatedStatus == EventStatus.Ongoing || eventInfo.EventStatus == EventStatus.Ongoing) return EventStatus.Ongoing; return calculatedStatus; } private static string ToCheckinName(int checkinNo) => checkinNo switch { 1 => "一检", 2 => "二检", 3 => "三检", _ => $"第{checkinNo}检" }; private static CheckinCheckerDto ToDto(YD_Event.Core.Entities.AppUser? checker, CheckinTask task, string eventName) { return new CheckinCheckerDto { CheckerUserId = checker?.Id ?? task.CheckerUserId ?? 0, Name = checker?.Name ?? string.Empty, Mobile = checker?.Mobile ?? string.Empty, CheckinNo = task.CheckinNo, TaskId = task.Id, EventId = task.EventId, EventName = eventName, CurrentRound = task.CheckinNo, RoundName = ToCheckinName(task.CheckinNo), CurrentGroupName = "全部运动员", Venue = task.Venue ?? string.Empty, TaskStatus = task.TaskStatus, CreatedTime = task.CreatedTime }; } }