检录通知模板调整

This commit is contained in:
马梓彭 2026-10-08 16:13:04 +08:00
parent f61d86550d
commit e21aaf3150
5 changed files with 100 additions and 66 deletions

View File

@ -408,8 +408,7 @@ public partial class CheckinController : IDynamicApiController
/// <summary> /// <summary>
/// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。 /// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。
/// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。 /// 个人报名:通知运动员本人;单位报名:通知教练和领队,不受项目类型影响。
/// 团队项目:通知教练和领队(从 TeamMember 取,因为报名表中不存储教练/领队的 RegistrationItemId)。
/// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。 /// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。
/// </summary> /// </summary>
private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync( private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync(
@ -423,24 +422,24 @@ public partial class CheckinController : IDynamicApiController
if (!isIndividualProject && !isTeamProject) if (!isIndividualProject && !isTeamProject)
throw Oops.Bah("当前项目类型不支持检录短信通知"); throw Oops.Bah("当前项目类型不支持检录短信通知");
// 收集通知接收人:{ Phone, SessionOrder } // 已知报名方式优先按个人/单位报名区分;缺少报名方式时按项目类型兜底。
List<(string? Phone, int SessionOrder)> recipients; var individualUnits = units.Where(x => x.RegistrationMode == 1 ||
(x.RegistrationMode == null && isIndividualProject)).ToList();
var unitUnits = units.Where(x => x.RegistrationMode == 2 ||
(x.RegistrationMode == null && isTeamProject)).ToList();
var recipients = new List<(string? Phone, List<int> SessionOrders)>();
if (individualUnits.Count > 0)
recipients.AddRange(await CollectIndividualRecipientsAsync(individualUnits));
if (unitUnits.Count > 0)
recipients.AddRange(await CollectTeamCoachLeaderRecipientsAsync(unitUnits));
if (isIndividualProject) // 同一手机号在本项目的多个报名单元/队伍只收到一条,场次号合并展示。
{
recipients = await CollectIndividualRecipientsAsync(units);
}
else
{
recipients = await CollectTeamCoachLeaderRecipientsAsync(units);
}
// 按手机号去重(同一教练带多个队取最早场次)
var missingPhoneCount = recipients.Count(x => string.IsNullOrWhiteSpace(x.Phone)); var missingPhoneCount = recipients.Count(x => string.IsNullOrWhiteSpace(x.Phone));
var validRecipients = recipients var validRecipients = recipients
.Where(x => !string.IsNullOrWhiteSpace(x.Phone)) .Where(x => !string.IsNullOrWhiteSpace(x.Phone))
.GroupBy(x => x.Phone!.Trim()) .GroupBy(x => x.Phone!.Trim())
.Select(g => g.OrderBy(x => x.SessionOrder).First()) .Select(g => (Phone: g.Key, SessionOrders: g.SelectMany(x => x.SessionOrders)
.Where(order => order > 0).Distinct().OrderBy(order => order).ToList()))
.ToList(); .ToList();
// 当前检录进度:获取该项目在秩序册中的起始场次 // 当前检录进度:获取该项目在秩序册中的起始场次
@ -455,7 +454,8 @@ public partial class CheckinController : IDynamicApiController
try try
{ {
var sent = await _smsService.SendCheckinNotice( var sent = await _smsService.SendCheckinNotice(
recipient.Phone!.Trim(), eventName, currentSession, recipient.SessionOrder); recipient.Phone, eventName, currentSession,
recipient.SessionOrders.Count == 0 ? "1" : string.Join("、", recipient.SessionOrders));
if (sent) success++; if (sent) success++;
else failed++; else failed++;
} }
@ -470,22 +470,27 @@ public partial class CheckinController : IDynamicApiController
return (validRecipients.Count + missingPhoneCount, success, failed); return (validRecipients.Count + missingPhoneCount, success, failed);
} }
/// <summary>个人项目:取 RegistrationItemId 下的运动员及其场次号。</summary> /// <summary>个人报名:取 RegistrationItemId 下的运动员及其场次号。</summary>
private async Task<List<(string? Phone, int SessionOrder)>> CollectIndividualRecipientsAsync( private async Task<List<(string? Phone, List<int> SessionOrders)>> CollectIndividualRecipientsAsync(
List<CheckinOrderBookUnitDto> units) List<CheckinOrderBookUnitDto> units)
{ {
// 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号) // 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号)
var userIdToOrder = new Dictionary<long, int>(); var userIdToOrders = new Dictionary<long, HashSet<int>>();
var personIdToOrder = new Dictionary<long, int>(); var personIdToOrders = new Dictionary<long, HashSet<int>>();
for (var i = 0; i < units.Count; i++) for (var i = 0; i < units.Count; i++)
{ {
var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1); var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1);
foreach (var athlete in units[i].Athletes) foreach (var athlete in units[i].Athletes)
{ {
if (athlete.UserId > 0 && !userIdToOrder.ContainsKey(athlete.UserId)) if (athlete.UserId > 0)
userIdToOrder[athlete.UserId] = sessionOrder; {
if (!personIdToOrder.ContainsKey(athlete.Id)) if (!userIdToOrders.TryGetValue(athlete.UserId, out var userOrders))
personIdToOrder[athlete.Id] = sessionOrder; userIdToOrders[athlete.UserId] = userOrders = new HashSet<int>();
userOrders.Add(sessionOrder);
}
if (!personIdToOrders.TryGetValue(athlete.Id, out var personOrders))
personIdToOrders[athlete.Id] = personOrders = new HashSet<int>();
personOrders.Add(sessionOrder);
} }
} }
@ -510,54 +515,57 @@ public partial class CheckinController : IDynamicApiController
userMap.TryGetValue(p.UserId, out var user); userMap.TryGetValue(p.UserId, out var user);
var phone = !string.IsNullOrWhiteSpace(p.Phone) ? p.Phone : user?.Mobile; var phone = !string.IsNullOrWhiteSpace(p.Phone) ? p.Phone : user?.Mobile;
// 取该人的场次号 var orders = p.UserId > 0 && userIdToOrders.TryGetValue(p.UserId, out var userOrders)
var order = 0; ? userOrders
if (p.UserId > 0) userIdToOrder.TryGetValue(p.UserId, out order); : personIdToOrders.GetValueOrDefault(p.Id);
if (order == 0) personIdToOrder.TryGetValue(p.Id, out order); var sessionOrders = orders?.OrderBy(order => order).ToList() ?? new List<int> { 1 };
if (order == 0) order = 1; // 兜底
return (phone, order); return (phone, sessionOrders);
}).ToList(); }).ToList();
} }
/// <summary> /// <summary>
/// 团队项目:运动员 → TeamMember → 同团队教练/领队 → AppUser。 /// 单位报名:运动员 → TeamMember → 同团队教练/领队 → AppUser,汇总其队伍涉及的场次。
/// 每位教练/领队的场次号取其所带队伍中最早的场次。
/// </summary> /// </summary>
private async Task<List<(string? Phone, int SessionOrder)>> CollectTeamCoachLeaderRecipientsAsync( private async Task<List<(string? Phone, List<int> SessionOrders)>> CollectTeamCoachLeaderRecipientsAsync(
List<CheckinOrderBookUnitDto> units) List<CheckinOrderBookUnitDto> units)
{ {
// 构建 athleteUserId → 最早场次号(优先使用 SessionIndex) // 构建 athleteUserId → 场次号集合(优先使用 SessionIndex)
var athleteToOrder = new Dictionary<long, int>(); var athleteToOrders = new Dictionary<long, HashSet<int>>();
for (var i = 0; i < units.Count; i++) for (var i = 0; i < units.Count; i++)
{ {
var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1); var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1);
foreach (var a in units[i].Athletes.Where(a => a.UserId > 0)) foreach (var a in units[i].Athletes.Where(a => a.UserId > 0))
{ {
if (!athleteToOrder.ContainsKey(a.UserId)) if (!athleteToOrders.TryGetValue(a.UserId, out var orders))
athleteToOrder[a.UserId] = sessionOrder; athleteToOrders[a.UserId] = orders = new HashSet<int>();
orders.Add(sessionOrder);
} }
} }
var athleteUserIds = athleteToOrder.Keys.ToList(); var athleteUserIds = athleteToOrders.Keys.ToList();
if (athleteUserIds.Count == 0) return new(); if (athleteUserIds.Count == 0)
return await FallbackCollectCoachLeaderAsync(units);
// 步骤1:运动员 → TeamMember(运动员身份) // 步骤1:运动员 → TeamMember(运动员身份)
var athleteMembers = await _teamMemberRepository.AsQueryable() var athleteMembers = await _teamMemberRepository.AsQueryable()
.Where(x => athleteUserIds.Contains(x.UserId) && !x.DeleteMark) .Where(x => athleteUserIds.Contains(x.UserId)
&& x.PersonType == PersonType.Athlete && !x.DeleteMark)
.ToListAsync(); .ToListAsync();
var teamIds = athleteMembers.Select(x => x.TeamId).Distinct().ToList(); var teamIds = athleteMembers.Select(x => x.TeamId).Distinct().ToList();
if (teamIds.Count == 0) return new(); if (teamIds.Count == 0)
return await FallbackCollectCoachLeaderAsync(units);
// 构建 teamId → 最早场次号(取该队运动员中最早的场次) // 构建 teamId → 场次号集合
var teamToOrder = new Dictionary<long, int>(); var teamToOrders = new Dictionary<long, HashSet<int>>();
foreach (var am in athleteMembers) foreach (var am in athleteMembers)
{ {
if (athleteToOrder.TryGetValue(am.UserId, out var order)) if (athleteToOrders.TryGetValue(am.UserId, out var orders))
{ {
if (!teamToOrder.ContainsKey(am.TeamId) || teamToOrder[am.TeamId] > order) if (!teamToOrders.TryGetValue(am.TeamId, out var teamOrders))
teamToOrder[am.TeamId] = order; teamToOrders[am.TeamId] = teamOrders = new HashSet<int>();
teamOrders.UnionWith(orders);
} }
} }
@ -574,38 +582,41 @@ public partial class CheckinController : IDynamicApiController
return await FallbackCollectCoachLeaderAsync(units); return await FallbackCollectCoachLeaderAsync(units);
} }
// 教练/领队 UserId → 最早场次号 // 教练/领队 UserId → 场次号集合
var coachOrderMap = new Dictionary<long, int>(); var coachOrderMap = new Dictionary<long, HashSet<int>>();
foreach (var cl in coachLeaders) foreach (var cl in coachLeaders)
{ {
if (teamToOrder.TryGetValue(cl.TeamId, out var order)) if (teamToOrders.TryGetValue(cl.TeamId, out var orders))
{ {
if (!coachOrderMap.ContainsKey(cl.UserId) || coachOrderMap[cl.UserId] > order) if (!coachOrderMap.TryGetValue(cl.UserId, out var coachOrders))
coachOrderMap[cl.UserId] = order; coachOrderMap[cl.UserId] = coachOrders = new HashSet<int>();
coachOrders.UnionWith(orders);
} }
} }
if (coachOrderMap.Count == 0)
return await FallbackCollectCoachLeaderAsync(units);
var userMap = await LoadUserMapAsync(coachOrderMap.Keys.ToList()); var userMap = await LoadUserMapAsync(coachOrderMap.Keys.ToList());
return coachOrderMap.Select(kv => return coachOrderMap.Select(kv =>
{ {
userMap.TryGetValue(kv.Key, out var user); userMap.TryGetValue(kv.Key, out var user);
return (user?.Mobile, kv.Value); return (user?.Mobile, kv.Value.OrderBy(order => order).ToList());
}).ToList(); }).ToList();
} }
/// <summary>降级路径:从 EventRegistrationPerson 查教练/领队(默认场次号 1)。</summary> /// <summary>降级路径:从 EventRegistrationPerson 查同报名下教练/领队。</summary>
private async Task<List<(string? Phone, int SessionOrder)>> FallbackCollectCoachLeaderAsync( private async Task<List<(string? Phone, List<int> SessionOrders)>> FallbackCollectCoachLeaderAsync(
List<CheckinOrderBookUnitDto> units) List<CheckinOrderBookUnitDto> units)
{ {
var registrationItemIds = units.Select(x => x.RegistrationItemId).Distinct().ToList(); var registrationItemIds = units.Select(x => x.RegistrationItemId).Distinct().ToList();
if (registrationItemIds.Count == 0) return new(); if (registrationItemIds.Count == 0) return new();
var registrationIds = await _personRepository.AsQueryable() var athletePersons = await _personRepository.AsQueryable()
.Where(x => registrationItemIds.Contains(x.RegistrationItemId) && !x.DeleteMark) .Where(x => registrationItemIds.Contains(x.RegistrationItemId) && !x.DeleteMark)
.Select(x => x.RegistrationId)
.Distinct()
.ToListAsync(); .ToListAsync();
var registrationIds = athletePersons.Select(x => x.RegistrationId).Distinct().ToList();
if (registrationIds.Count == 0) return new(); if (registrationIds.Count == 0) return new();
@ -615,18 +626,25 @@ public partial class CheckinController : IDynamicApiController
&& !x.DeleteMark) && !x.DeleteMark)
.ToListAsync(); .ToListAsync();
var coachUserIds = fallbackPersons.Where(p => p.UserId > 0).Select(p => p.UserId).Distinct().ToList(); var itemOrders = units.GroupBy(x => x.RegistrationItemId)
.ToDictionary(g => g.Key, g => g.Select(x => x.SessionIndex > 0 ? x.SessionIndex : x.Order)
.Where(order => order > 0).Distinct().ToList());
var registrationOrders = athletePersons.GroupBy(x => x.RegistrationId)
.ToDictionary(g => g.Key, g => g.SelectMany(person => itemOrders.GetValueOrDefault(person.RegistrationItemId) ?? new List<int>())
.Distinct().OrderBy(order => order).ToList());
var coachOrders = fallbackPersons.Where(p => p.UserId > 0)
.GroupBy(p => p.UserId)
.ToDictionary(g => g.Key, g => g.SelectMany(p => registrationOrders.GetValueOrDefault(p.RegistrationId) ?? new List<int>())
.Distinct().OrderBy(order => order).ToList());
var coachUserIds = coachOrders.Keys.ToList();
if (coachUserIds.Count == 0) return new(); if (coachUserIds.Count == 0) return new();
var userMap = await LoadUserMapAsync(coachUserIds); var userMap = await LoadUserMapAsync(coachUserIds);
var defaultOrder = units.Select(x => x.SessionIndex).FirstOrDefault(x => x > 0);
if (defaultOrder <= 0) defaultOrder = 1;
return coachUserIds.Select(uid => return coachUserIds.Select(uid =>
{ {
userMap.TryGetValue(uid, out var user); userMap.TryGetValue(uid, out var user);
return (user?.Mobile, defaultOrder); return (user?.Mobile, coachOrders[uid]);
}).ToList(); }).ToList();
} }

View File

@ -8,6 +8,7 @@ public class CheckinScheduleService : ITransient
{ {
private readonly ISqlSugarRepository<EventInfo> _eventRepository; private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository; private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository; private readonly ISqlSugarRepository<EventSession> _sessionRepository;
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository; private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository; private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
@ -18,6 +19,7 @@ public class CheckinScheduleService : ITransient
public CheckinScheduleService( public CheckinScheduleService(
ISqlSugarRepository<EventInfo> eventRepository, ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository, ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
ISqlSugarRepository<EventSession> sessionRepository, ISqlSugarRepository<EventSession> sessionRepository,
ISqlSugarRepository<EventSessionEntry> entryRepository, ISqlSugarRepository<EventSessionEntry> entryRepository,
ISqlSugarRepository<EventProject> eventProjectRepository, ISqlSugarRepository<EventProject> eventProjectRepository,
@ -27,6 +29,7 @@ public class CheckinScheduleService : ITransient
{ {
_eventRepository = eventRepository; _eventRepository = eventRepository;
_personRepository = personRepository; _personRepository = personRepository;
_registrationRepository = registrationRepository;
_sessionRepository = sessionRepository; _sessionRepository = sessionRepository;
_entryRepository = entryRepository; _entryRepository = entryRepository;
_eventProjectRepository = eventProjectRepository; _eventProjectRepository = eventProjectRepository;
@ -57,6 +60,13 @@ public class CheckinScheduleService : ITransient
.Where(x => personIds.Contains(x.Id) && !x.DeleteMark && x.PersonType == PersonType.Athlete) .Where(x => personIds.Contains(x.Id) && !x.DeleteMark && x.PersonType == PersonType.Athlete)
.ToListAsync(); .ToListAsync();
var personMap = persons.ToDictionary(x => x.Id); var personMap = persons.ToDictionary(x => x.Id);
var registrationIds = persons.Select(x => x.RegistrationId).Distinct().ToList();
var registrationModes = registrationIds.Count == 0
? new Dictionary<long, int>()
: (await _registrationRepository.AsQueryable()
.Where(x => registrationIds.Contains(x.Id) && !x.DeleteMark)
.ToListAsync())
.ToDictionary(x => x.Id, x => x.RegistrationMode);
// 团队项目沿用报名项目作为检录单位;个人项目追加报名人员 ID, // 团队项目沿用报名项目作为检录单位;个人项目追加报名人员 ID,
// 避免单位账号在同一报名项目下填报多人时共用同一个检录键。 // 避免单位账号在同一报名项目下填报多人时共用同一个检录键。
@ -75,6 +85,10 @@ public class CheckinScheduleService : ITransient
GroupId = row.EventProjectGroupId, GroupId = row.EventProjectGroupId,
GroupName = row.GroupName, GroupName = row.GroupName,
RegistrationItemId = row.RegistrationItemId, RegistrationItemId = row.RegistrationItemId,
RegistrationMode = row.RegistrationPersonIds
.Where(personMap.ContainsKey)
.Select(personId => registrationModes.GetValueOrDefault(personMap[personId].RegistrationId))
.FirstOrDefault(mode => mode > 0) is var mode && mode > 0 ? mode : null,
TeamName = row.TeamName ?? string.Empty, TeamName = row.TeamName ?? string.Empty,
UnitName = row.UnitName ?? string.Empty, UnitName = row.UnitName ?? string.Empty,
RegistrationPersonIds = row.RegistrationPersonIds.Distinct().ToList(), RegistrationPersonIds = row.RegistrationPersonIds.Distinct().ToList(),

View File

@ -48,6 +48,8 @@ public class CheckinOrderBookUnitDto
public long? GroupId { get; set; } public long? GroupId { get; set; }
public string GroupName { get; set; } = string.Empty; public string GroupName { get; set; } = string.Empty;
public long RegistrationItemId { get; set; } public long RegistrationItemId { get; set; }
/// <summary>报名方式:1-个人报名,2-单位报名;无法关联报名记录时为空。</summary>
public int? RegistrationMode { get; set; }
public string TeamName { get; set; } = string.Empty; public string TeamName { get; set; } = string.Empty;
public string UnitName { get; set; } = string.Empty; public string UnitName { get; set; } = string.Empty;
public List<long> RegistrationPersonIds { get; set; } = new(); public List<long> RegistrationPersonIds { get; set; } = new();

View File

@ -78,10 +78,10 @@ public class AliyunSmsService : ITransient
/// <summary> /// <summary>
/// 发送检录通知短信;未配置检录模板时返回 false 由调用方降级处理。 /// 发送检录通知短信;未配置检录模板时返回 false 由调用方降级处理。
/// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。 /// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。单位报名多个场次时 order 可为“1、3、4、5”。
/// current 参数格式:{项目名称}第{x}场次。 /// current 参数格式:{项目名称}第{x}场次。
/// </summary> /// </summary>
public async Task<bool> SendCheckinNotice(string phone, string eventName, string currentSession, int sessionOrder) public async Task<bool> SendCheckinNotice(string phone, string eventName, string currentSession, string sessionOrders)
{ {
if (string.IsNullOrWhiteSpace(_options.CheckinNoticeTemplateCode)) if (string.IsNullOrWhiteSpace(_options.CheckinNoticeTemplateCode))
{ {
@ -93,7 +93,7 @@ public class AliyunSmsService : ITransient
{ {
@event = eventName, @event = eventName,
current = currentSession, current = currentSession,
order = sessionOrder.ToString() order = sessionOrders
}); });
await SendSms(phone, _options.CheckinNoticeTemplateCode, param); await SendSms(phone, _options.CheckinNoticeTemplateCode, param);
return true; return true;

View File

@ -32,7 +32,7 @@ public class SmsOptions : IConfigurableOptions
public string VerificationCodeTemplateCode { get; set; } public string VerificationCodeTemplateCode { get; set; }
/// <summary> /// <summary>
/// 检录通知模板Code;模板格式:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。 /// 检录通知模板Code;order 支持单个场次或“1、3、4、5”场次列表。
/// current 参数格式为“{项目名称}第{x}场次”;未配置时不发送检录短信。 /// current 参数格式为“{项目名称}第{x}场次”;未配置时不发送检录短信。
/// </summary> /// </summary>
[ConfigurationKeyName("CheckinNoticeTemplate")] [ConfigurationKeyName("CheckinNoticeTemplate")]