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
};
}
}