YD_Event.Admin/YD_Event.Application/Client/CheckinAdminController.cs
ltx 25830581d2 feat(api): 完善小程序赛事角色与秩序册检录接口
新增成绩查询、裁判认证及检录员账号管理接口,并统一小程序角色登录与个人中心数据。

检录任务绑定赛事秩序册场次,按项目、组别、场地和场次顺序执行;支持最多三名检录员、分配检录轮次及前序检录校验。

补充检录任务、检录记录、裁判申请实体及四份数据库迁移,包含表字段备注、秩序册绑定和检录序号。
2026-08-07 14:59:15 +08:00

334 lines
15 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 System.Text.RegularExpressions;
using YD_Event.Application.Auth;
using YD_Event.Application.Client.Dtos;
namespace YD_Event.Application.Client;
/// <summary>后台检录员及赛事顺序检录管理。</summary>
[ApiDescriptionSettings("检录管理")]
[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? excludeCheckerUserId = null)
{
EnsureManager();
mobile = mobile?.Trim() ?? string.Empty;
if (!IsValidMobile(mobile))
{
return new CheckerAccountAvailabilityDto { Available = false, Message = "手机号格式不正确" };
}
var conflict = await GetMobileConflictAsync(mobile, excludeCheckerUserId);
return conflict == null
? new CheckerAccountAvailabilityDto { Available = true, Message = "手机号可以使用" }
: new CheckerAccountAvailabilityDto { Available = false, Message = conflict };
}
[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 conflict = await GetMobileConflictAsync(input.Mobile, null);
if (conflict != null) throw Oops.Bah(conflict);
}
var editConflict = await GetMobileConflictAsync(input.Mobile, checker?.Id);
if (editConflict != null) throw Oops.Bah(editConflict);
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();
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,
CreatedUserId = managerUserId,
CreatedTime = DateTime.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 = new CheckinTask
{
EventId = input.EventId,
CheckerUserId = checker.Id,
CreatedUserId = managerUserId,
CreatedTime = DateTime.Now
};
}
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 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.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<string?> GetMobileConflictAsync(string mobile, long? excludeCheckerUserId)
{
// SqlSugar 无法稳定翻译“Nullable.HasValue + != Nullable.Value”的组合表达式,
// SQL 只按手机号查询,排除当前编辑对象的判断放到内存执行。
var usersWithSameMobile = await _userRepository.AsQueryable()
.Where(x => x.Mobile == mobile)
.ToListAsync();
var appUserExists = usersWithSameMobile.Any(x =>
!excludeCheckerUserId.HasValue || x.Id != excludeCheckerUserId.Value);
if (appUserExists) return "该手机号已被添加过其他身份,不能重复添加";
var sysUserExists = await _sysUserRepository.IsAnyAsync(x =>
(x.Mobile == mobile || x.Account == mobile) && !x.DeleteMark);
return sysUserExists ? "该手机号已是后台系统账号,不能再添加为检录员" : null;
}
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 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
};
}
}