551 lines
25 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 Microsoft.Extensions.Logging;
using YD_Event.Application.Auth;
using YD_Event.Application.Client.Dtos;
using YD_Event.Application.Sms;
namespace YD_Event.Application.Client;
/// <summary>小程序检录员按秩序册列进行顺序检录。</summary>
[ApiDescriptionSettings("微信小程序接口")]
[Authorize]
public partial class CheckinController : IDynamicApiController
{
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
private readonly ISqlSugarRepository<CheckinTask> _taskRepository;
private readonly ISqlSugarRepository<CheckinRecord> _recordRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly CheckinScheduleService _scheduleService;
private readonly AliyunSmsService _smsService;
private readonly ILogger<CheckinController> _logger;
private readonly AuthManager _authManager;
public CheckinController(
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
ISqlSugarRepository<CheckinTask> taskRepository,
ISqlSugarRepository<CheckinRecord> recordRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
CheckinScheduleService scheduleService,
AliyunSmsService smsService,
ILogger<CheckinController> 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<CheckinContextDto?> 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<CheckinContextDto> 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<StartCheckinOutput> 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<ScanCheckinOutput> 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<CheckinContextDto> 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);
}
/// <summary>按扫码内容定位秩序册列;支持号码布、报名人员ID和姓名。</summary>
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);
}
/// <summary>检录顺序卡控:非一检必须先通过上一检次。</summary>
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);
}
/// <summary>对报名了所选项目组别的人员发送检录通知;单人失败不影响整体流程。</summary>
private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync(
List<CheckinOrderBookUnitDto> 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<EventRegistration>()
: 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<CheckinContextDto> 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<string> previousPassedKeys;
if (task.CheckinNo == 1)
{
previousPassedKeys = allUnitKeys;
}
else
{
var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1);
var previousRecords = previousTask == null
? new List<CheckinRecord>()
: 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<CheckinTask?> 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<CheckinTask?> 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<List<CheckinTask>> 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();
/// <summary>末检次通过的秩序册列即为已上场,供检录端与运动员端统一判定。</summary>
private async Task<HashSet<string>> GetOnStageKeysAsync(
List<CheckinTask> activeTasks, HashSet<string> validUnitKeys)
{
if (activeTasks.Count == 0) return new HashSet<string>();
var lastTask = activeTasks.OrderByDescending(x => x.CheckinNo).First();
var records = await _recordRepository.AsQueryable()
.Where(x => x.TaskId == lastTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark)
.ToListAsync();
return records.Where(x => validUnitKeys.Contains(x.SessionKey!))
.Select(x => x.SessionKey!).ToHashSet();
}
private async Task<long> 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<CheckinOrderBookUnitStatusDto> 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;
}
}