检录通知模板调整

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

View File

@ -8,6 +8,7 @@ public class CheckinScheduleService : ITransient
{
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
@ -18,6 +19,7 @@ public class CheckinScheduleService : ITransient
public CheckinScheduleService(
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
ISqlSugarRepository<EventSession> sessionRepository,
ISqlSugarRepository<EventSessionEntry> entryRepository,
ISqlSugarRepository<EventProject> eventProjectRepository,
@ -27,6 +29,7 @@ public class CheckinScheduleService : ITransient
{
_eventRepository = eventRepository;
_personRepository = personRepository;
_registrationRepository = registrationRepository;
_sessionRepository = sessionRepository;
_entryRepository = entryRepository;
_eventProjectRepository = eventProjectRepository;
@ -57,6 +60,13 @@ public class CheckinScheduleService : ITransient
.Where(x => personIds.Contains(x.Id) && !x.DeleteMark && x.PersonType == PersonType.Athlete)
.ToListAsync();
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,
// 避免单位账号在同一报名项目下填报多人时共用同一个检录键。
@ -75,6 +85,10 @@ public class CheckinScheduleService : ITransient
GroupId = row.EventProjectGroupId,
GroupName = row.GroupName,
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,
UnitName = row.UnitName ?? string.Empty,
RegistrationPersonIds = row.RegistrationPersonIds.Distinct().ToList(),

View File

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

View File

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

View File

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