442 lines
19 KiB
C#
442 lines
19 KiB
C#
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;
|
||
|
||
/// <summary>后台检录员及赛事顺序检录管理。</summary>
|
||
[ApiDescriptionSettings("打分系统")]
|
||
[Route("api/checkinadmin")]
|
||
[Authorize]
|
||
public class CheckinAdminController : IDynamicApiController
|
||
{
|
||
private const int MaxCheckerCount = 3;
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.SysUser> _sysUserRepository;
|
||
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
|
||
private readonly ISqlSugarRepository<CheckinTask> _taskRepository;
|
||
private readonly CheckinScheduleService _scheduleService;
|
||
private readonly AuthManager _authManager;
|
||
private readonly IIdGenerator _idGenerator;
|
||
|
||
public CheckinAdminController(
|
||
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
|
||
ISqlSugarRepository<YD_Event.Core.Entities.SysUser> sysUserRepository,
|
||
ISqlSugarRepository<EventInfo> eventRepository,
|
||
ISqlSugarRepository<CheckinTask> 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<CheckinEventOptionsDto> 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<CheckerAccountAvailabilityDto> 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<List<CheckinCheckerDto>> 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<YD_Event.Core.Entities.AppUser>()
|
||
: await _userRepository.AsQueryable().Where(x => checkerIds.Contains(x.Id) && !x.DeleteMark).ToListAsync();
|
||
var events = eventIds.Count == 0
|
||
? new List<EventInfo>()
|
||
: 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<CheckinCheckerDto> 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<CheckinCheckerDto> 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<List<CheckinTask>> 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<CheckerAccountAvailabilityDto> 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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 统一检录管理使用的赛事状态口径。后台状态与比赛时间不一致时按生命周期靠后的状态处理,
|
||
/// 避免状态同步延迟导致比赛中仍可解绑,或赛事结束后账号未及时释放。
|
||
/// </summary>
|
||
private static EventStatus GetEffectiveEventStatus(EventInfo eventInfo, DateTime now)
|
||
{
|
||
var calculatedStatus = EventStatusUpdateService.CalculateEventStatus(eventInfo, now);
|
||
if (calculatedStatus == EventStatus.Ended || eventInfo.EventStatus == EventStatus.Ended)
|
||
return EventStatus.Ended;
|
||
if (calculatedStatus == EventStatus.Ongoing || eventInfo.EventStatus == EventStatus.Ongoing)
|
||
return EventStatus.Ongoing;
|
||
|
||
return calculatedStatus;
|
||
}
|
||
|
||
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
|
||
};
|
||
}
|
||
}
|