添加赛事限制的友好提示

This commit is contained in:
马梓彭 2026-04-16 15:42:29 +08:00
parent 41c55fafe0
commit 2a38a5a1aa
10 changed files with 310 additions and 55 deletions

View File

@ -82,7 +82,7 @@ public class MyController : IDynamicApiController
if (user == null) throw Oops.Bah("用户信息不存在");
// 统计证书数量
var certCount = await _certificateRepository.CountAsync(c => c.CreatedUserId == userId && c.CertificateStatus == 2);
var certCount = await _certificateRepository.CountAsync(c => c.UserId == userId && c.CertificateStatus == 2);
// 积分 (需计算积分总和,PointsRecord 表有 Points 字段,Type 1-加 2-减)
// 简单计算:Sum(Type=1) - Sum(Type=2)
@ -351,7 +351,7 @@ public class MyController : IDynamicApiController
public async Task<MyCertificateOverviewDto> GetCertificatesOverview()
{
var userId = GetCurrentUserId();
var count = await _certificateRepository.CountAsync(c => c.CreatedUserId == userId && c.CertificateStatus == 2 && !c.DeleteMark);
var count = await _certificateRepository.CountAsync(c => c.UserId == userId && c.CertificateStatus == 2 && !c.DeleteMark);
return new MyCertificateOverviewDto
{
@ -372,7 +372,7 @@ public class MyController : IDynamicApiController
var query = _certificateRepository.AsQueryable()
.LeftJoin<EventInfo>((c, e) => c.EventId == e.Id)
.LeftJoin<ProjectEntity>((c, e, p) => c.ProjectId == p.Id)
.Where((c, e, p) => c.CreatedUserId == userId && c.CertificateStatus == 2 && !c.DeleteMark)
.Where((c, e, p) => c.UserId == userId && c.CertificateStatus == 2 && !c.DeleteMark)
.Select((c, e, p) => new
{
Id = c.Id,

View File

@ -193,6 +193,12 @@ public class IssueCertificateInput
/// </summary>
[Required(ErrorMessage = "请选择证书")]
public long CertificateId { get; set; }
/// <summary>
/// 页面类型:1-运动员页面,2-教练页面,3-领队页面
/// 用于明确区分不同角色页面的证书生成逻辑
/// </summary>
public CertificatePageType PageType { get; set; }
}
/// <summary>

View File

@ -418,16 +418,66 @@ public class EventScoreService : BaseService<EventResult>, ITransient
throw Oops.Bah("证书关联的模板不存在或不可用");
}
// 批量预加载所有需要的数据
var persons = await _personRepository.GetListAsync(x => input.PersonIds.Contains(x.Id));
var personDict = persons.ToDictionary(p => p.Id);
// 根据页面类型确定允许的人员类型
List<PersonType> allowedPersonTypes = GetAllowedPersonTypes(input.PageType);
_logger.LogInformation("发放证书 - 页面类型: {PageType}, 允许的人员类型: {AllowedTypes}",
input.PageType, string.Join(", ", allowedPersonTypes));
var personIds = persons.Select(p => p.Id).ToList();
var existingResults = await _resultRepository.GetListAsync(x => personIds.Contains(x.RegistrationPersonId));
// 批量预加载所有需要的数据
// 先尝试直接从 EventRegistrationPerson 表获取所有人员
var directPersons = await _personRepository.GetListAsync(x => input.PersonIds.Contains(x.Id));
var directPersonDict = directPersons.ToDictionary(p => p.Id);
// 找出在 directPersonDict 中找不到的人员ID,这些可能是教练/领队的 AppUser ID
var missingPersonIds = input.PersonIds.Where(id => !directPersonDict.ContainsKey(id)).ToList();
// 构建最终的人员字典
var personDict = new Dictionary<long, EventRegistrationPerson>();
foreach (var kvp in directPersonDict)
{
personDict[kvp.Key] = kvp.Value;
}
// 对于找不到的人员ID,尝试通过团队关系查找对应的 EventRegistrationPerson 记录
if (missingPersonIds.Any())
{
_logger.LogInformation("查找 {Count} 个缺失的人员ID,尝试通过团队关系获取", missingPersonIds.Count);
// 获取该赛事的所有 EventRegistrationPerson 记录
var allEventPersons = await _personRepository.AsQueryable()
.InnerJoin<EventRegistration>((p, r) => p.RegistrationId == r.Id)
.Where((p, r) => r.EventId == input.EventId && !p.DeleteMark)
.Select((p, r) => p)
.ToListAsync();
// 建立 UserId 到 EventRegistrationPerson 的映射
var userToPersonMap = allEventPersons
.Where(p => p.UserId > 0)
.GroupBy(p => p.UserId)
.ToDictionary(g => g.Key, g => g.First());
// 尝试通过 UserId 匹配
foreach (var missingId in missingPersonIds)
{
if (userToPersonMap.TryGetValue(missingId, out var person))
{
personDict[missingId] = person;
_logger.LogInformation("通过 UserId {UserId} 找到对应的 EventRegistrationPerson: {PersonId}", missingId, person.Id);
}
else
{
_logger.LogWarning("无法找到人员ID: {PersonId}", missingId);
}
}
}
// 使用最终找到的人员ID列表
var foundPersonIds = personDict.Values.Select(p => p.Id).ToList();
var existingResults = await _resultRepository.GetListAsync(x => foundPersonIds.Contains(x.RegistrationPersonId));
var resultDict = existingResults.ToDictionary(r => r.RegistrationPersonId);
var existingCerts = await _certificateRepository.GetListAsync(x =>
personIds.Contains(x.ParticipationRecordId));
foundPersonIds.Contains(x.ParticipationRecordId));
var certDict = existingCerts.ToDictionary(c => c.ParticipationRecordId);
var certsToInsert = new List<EventCertificate>();
@ -442,19 +492,30 @@ public class EventScoreService : BaseService<EventResult>, ITransient
continue;
}
// 验证人员类型是否与当前页面类型匹配
if (!allowedPersonTypes.Contains(person.PersonType))
{
result.FailCount++;
result.FailDetails.Add($"人员 {person.Name} 的类型 ({person.PersonType}) 与当前页面类型不匹配");
continue;
}
// 获取实际的 EventRegistrationPerson ID(如果通过 UserId 映射过来的,需要使用实际的 Id)
var actualPersonId = person.Id;
EventCertificate? cert = null;
bool isNewCert = false;
if (person.PersonType == PersonType.Athlete || person.PersonType == PersonType.User)
{
if (!resultDict.TryGetValue(person.Id, out var resultRecord))
if (!resultDict.TryGetValue(actualPersonId, out var resultRecord))
{
result.FailCount++;
result.FailDetails.Add($"人员 {person.Name} 没有成绩记录");
continue;
}
if (certDict.TryGetValue(person.Id, out var existingCert))
if (certDict.TryGetValue(actualPersonId, out var existingCert))
{
cert = existingCert;
}
@ -466,12 +527,14 @@ public class EventScoreService : BaseService<EventResult>, ITransient
EventId = input.EventId,
ResultId = resultRecord.Id,
RegistrationId = person.RegistrationId,
ParticipationRecordId = person.Id,
ParticipationRecordId = actualPersonId,
ProjectId = person.ProjectId,
UserId = person.UserId,
ParticipantName = person.Name,
GroupName = person.GroupName,
CertificateStatus = 1,
GeneratedTime = now,
CertificateNo = $"C{now:yyyyMMdd}{person.Id}",
CertificateNo = $"C{now:yyyyMMdd}{actualPersonId}",
CreatedTime = now,
TemplateId = templateId,
CertificateUrl = string.Empty
@ -515,7 +578,7 @@ public class EventScoreService : BaseService<EventResult>, ITransient
}
else
{
if (certDict.TryGetValue(person.Id, out var existingCert))
if (certDict.TryGetValue(actualPersonId, out var existingCert))
{
cert = existingCert;
}
@ -526,12 +589,14 @@ public class EventScoreService : BaseService<EventResult>, ITransient
Id = _idGenerator.NewLong(),
EventId = input.EventId,
RegistrationId = person.RegistrationId,
ParticipationRecordId = person.Id,
ParticipationRecordId = actualPersonId,
ProjectId = person.ProjectId,
UserId = person.UserId,
ParticipantName = person.Name,
GroupName = person.GroupName,
CertificateStatus = 1,
GeneratedTime = now,
CertificateNo = $"CC{now:yyyyMMdd}{person.Id}",
CertificateNo = $"CC{now:yyyyMMdd}{actualPersonId}",
CreatedTime = now,
TemplateId = templateId,
CertificateUrl = string.Empty
@ -609,6 +674,22 @@ public class EventScoreService : BaseService<EventResult>, ITransient
return result;
}
/// <summary>
/// 根据页面类型获取允许的人员类型列表
/// </summary>
/// <param name="pageType">页面类型</param>
/// <returns>允许的人员类型列表</returns>
private List<PersonType> GetAllowedPersonTypes(CertificatePageType pageType)
{
return pageType switch
{
CertificatePageType.Athlete => new List<PersonType> { PersonType.Athlete, PersonType.User },
CertificatePageType.Coach => new List<PersonType> { PersonType.Coach },
CertificatePageType.Leader => new List<PersonType> { PersonType.Leader },
_ => new List<PersonType> { PersonType.Athlete, PersonType.User, PersonType.Coach, PersonType.Leader }
};
}
/// <summary>
/// 构建运动员占位符数据
/// </summary>
@ -708,7 +789,8 @@ public class EventScoreService : BaseService<EventResult>, ITransient
/// <param name="input">输入参数</param>
/// <returns>证书详情</returns>
[DisplayName("查看证书")]
public async Task<object> GetCertificateDetail(YD_Event.Application.Certificate.Dtos.IdInput input)
[HttpPost("getcertificatedetail")]
public async Task<object> GetCertificateDetail([FromBody] YD_Event.Application.Certificate.Dtos.IdInput input)
{
if (input.Id <= 0)
{
@ -727,7 +809,16 @@ public class EventScoreService : BaseService<EventResult>, ITransient
throw Oops.Bah("未找到对应的证书信息");
}
return new { Url = cert.CertificateUrl };
return new
{
Id = cert.Id,
CertificateNo = cert.CertificateNo,
CertificateUrl = cert.CertificateUrl,
CertificateStatus = cert.CertificateStatus,
IssuedTime = cert.IssuedTime,
ParticipantName = cert.ParticipantName,
GroupName = cert.GroupName
};
}
/// <summary>
@ -817,7 +908,8 @@ public class EventScoreService : BaseService<EventResult>, ITransient
/// 导出证书
/// </summary>
[DisplayName("导出证书")]
public async Task<string> ExportCertificate(ScoreQueryInput input)
[HttpGet]
public async Task<IActionResult> ExportCertificate([FromQuery] ScoreQueryInput input)
{
var list = await _certificateRepository.AsQueryable()
.LeftJoin<EventResult>((c, r) => c.ResultId == r.Id)
@ -845,8 +937,13 @@ public class EventScoreService : BaseService<EventResult>, ITransient
.ToListAsync();
using var stream = new MemoryStream();
stream.SaveAs(list);
return Convert.ToBase64String(stream.ToArray());
await stream.SaveAsAsync(list);
var bytes = stream.ToArray();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = $"证书列表_{DateTime.Now:yyyyMMddHHmmss}.xlsx"
};
}
/// <summary>

View File

@ -1266,7 +1266,7 @@ public class UnifiedRegistrationService : ITransient
0,
eventProjectGroup.GroupName ?? "未知组别",
"ProjectGroupCapacity",
$"项目组别限报设置:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"限报{projectLimit.LimitCount}队,本次提交{submittedTeamCount}队,超出限报配置"));
$"{project.Name}项目已达到报名限制"));
}
}
else
@ -1286,7 +1286,7 @@ public class UnifiedRegistrationService : ITransient
0,
eventProjectGroup.GroupName ?? "未知组别",
"ProjectGroupCapacity",
$"项目组别限报设置:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"限报{projectLimit.LimitCount}人,本次提交{submittedPersonCount}人,超出限报配置"));
$"{project.Name}项目已达到报名限制"));
}
}
}
@ -1434,7 +1434,7 @@ public class UnifiedRegistrationService : ITransient
member.Id,
member.Name ?? member.NickName ?? "未知",
"IndividualProjectLimit",
$"成员项目限报设置:队员\"{member.Name}\"已报名{individualProjectCount}个个人项目,个人项目限报{limit.IndividualProjectLimit}个,请减少个人项目报名数量"));
$"{member.Name}队员已达到报名限制"));
}
/// <summary>
@ -1459,7 +1459,7 @@ public class UnifiedRegistrationService : ITransient
member.Id,
member.Name ?? member.NickName ?? "未知",
"TeamProjectLimit",
$"成员项目限报设置:队员\"{member.Name}\"已参与{teamProjectCount}个团队项目,团队项目限报{limit.TeamProjectLimit}个,请减少团队项目报名数量"));
$"{member.Name}队员已达到报名限制"));
}
/// <summary>
@ -1632,22 +1632,33 @@ public class UnifiedRegistrationService : ITransient
.Distinct()
.Count();
// ==================== 项目组别条件判断逻辑 ====================
// 当且仅当某个项目组别中已选择至少一名人员时,才对该项目组别执行后续条件判断流程
if (totalAssignedMembers == 0)
{
// 若项目组别未选择任何人员,直接忽略该项目组别,不进行任何条件判断
continue;
}
// ==================== 报名限制验证规则 ====================
// 个人项目不进行完成人数限制,仅适用项目报名限制规则
if (!isTeamProject)
{
continue;
}
// ==================== 项目完成人数验证 ====================
// 验证用户选择的参与人数必须与项目的"完成人数"完全一致
// 只对团队项目进行完成人数验证
if (project.CompletionCount > 0 && totalAssignedMembers != project.CompletionCount)
{
string errorMessage;
if (totalAssignedMembers < project.CompletionCount)
{
errorMessage = isTeamProject
? $"项目完成人数限制:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前只分配了{totalAssignedMembers}人,请补充队员"
: $"项目完成人数限制:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前只分配了{totalAssignedMembers}人,请补充队员";
errorMessage = $"{project.Name}参赛人数错误(少{project.CompletionCount - totalAssignedMembers}人)";
}
else
{
errorMessage = isTeamProject
? $"项目完成人数限制:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前分配了{totalAssignedMembers}人,人数超额,请移除多余队员"
: $"项目完成人数限制:项目\"{project.Name}\"的组别\"{eventProjectGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前分配了{totalAssignedMembers}人,人数超额,请移除多余队员";
errorMessage = $"{project.Name}参赛人数错误(多{totalAssignedMembers - project.CompletionCount}人)";
}
result.HasLimit = true;

View File

@ -347,10 +347,19 @@ public class UserEventRecordService : IDynamicApiController, ITransient
[HttpGet("certificate/{registrationId}")]
public async Task<ResponseDTO<CertificateDTO>> GetCertificate([FromRoute] long registrationId)
{
var userId = _authManager.UserId;
// 2-已发放
var cert = await _certificateRepository.GetFirstAsync(x => x.RegistrationId == registrationId && x.CertificateStatus == 2);
if (cert == null) return new ResponseDTO<CertificateDTO> { Code = 404, Message = "证书未颁发", Success = false };
// 权限验证
bool hasPermission = await CheckCertificatePermissionAsync(userId, cert);
if (!hasPermission)
{
return new ResponseDTO<CertificateDTO> { Code = 403, Message = "无权查看该证书", Success = false };
}
return new ResponseDTO<CertificateDTO>
{
Code = 200, Success = true,
@ -364,6 +373,44 @@ public class UserEventRecordService : IDynamicApiController, ITransient
};
}
/// <summary>
/// 检查证书查看权限
/// </summary>
private async Task<bool> CheckCertificatePermissionAsync(long currentUserId, EventCertificate cert)
{
// 如果是自己的证书,直接允许
if (cert.UserId == currentUserId)
{
return true;
}
// 检查当前用户是否是单位用户,且该证书属于其管理的团队成员
var currentUser = await _userRepository.GetByIdAsync(currentUserId);
if (currentUser == null)
{
return false;
}
// 如果当前用户有单位认证
if (currentUser.MerchantCertificationId.HasValue)
{
// 查询当前用户管理的团队成员
var teamMemberUserIds = await _db.Queryable<YD_Event.Core.Entities.Team>()
.InnerJoin<TeamMember>((t, tm) => t.Id == tm.TeamId)
.Where((t, tm) => t.CreatedUserId == currentUserId && !t.DeleteMark && !tm.DeleteMark)
.Select((t, tm) => tm.UserId)
.ToListAsync();
// 检查证书用户是否在团队成员列表中
if (teamMemberUserIds.Contains(cert.UserId))
{
return true;
}
}
return false;
}
/// <summary>
/// 7. 取消报名预览
/// </summary>

View File

@ -1141,35 +1141,43 @@ public class UserEventRegistrationService : IDynamicApiController, ITransient
// 计算该组别的分配人数(不跨组别,也不跨项目去重)
var assignedCount = groupMemberAssignment.MemberIds?.Distinct().Count() ?? 0;
if (project.CompletionCount > 0)
// ==================== 项目组别条件判断逻辑 ====================
// 当且仅当某个项目组别中已选择至少一名人员时,才对该项目组别执行后续条件判断流程
if (assignedCount == 0)
{
// 若项目组别未选择任何人员,直接忽略该项目组别,不进行任何条件判断
continue;
}
// ==================== 报名限制验证规则 ====================
// 个人项目不进行完成人数限制,仅适用项目报名限制规则
if (!isTeamProject)
{
continue;
}
// ==================== 项目完成人数验证 ====================
// 只对团队项目进行完成人数验证
if (project.CompletionCount > 0 && assignedCount != project.CompletionCount)
{
string errorMessage;
if (assignedCount < project.CompletionCount)
{
result.HasError = true;
result.Errors.Add(new ProjectCompletionError
{
ProjectId = eventProject.Id,
ProjectName = project.Name ?? "未知项目",
ErrorType = "Insufficient",
ErrorMessage = isTeamProject
? $"项目\"{project.Name}\"的组别\"{assignedGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前只分配了{assignedCount}人,请补充队员"
: $"项目\"{project.Name}\"的组别\"{assignedGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前只分配了{assignedCount}人,请补充队员"
});
errorMessage = $"{project.Name}参赛人数错误(少{project.CompletionCount - assignedCount}人)";
}
else if (assignedCount > project.CompletionCount)
else
{
// 只要达到或超过完成人数就提示超限(特别是个人项目)
result.HasError = true;
result.Errors.Add(new ProjectCompletionError
{
ProjectId = eventProject.Id,
ProjectName = project.Name ?? "未知项目",
ErrorType = "Excessive",
ErrorMessage = isTeamProject
? $"项目\"{project.Name}\"的组别\"{assignedGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前分配了{assignedCount}人,人数超额,请移除多余队员"
: $"项目\"{project.Name}\"的组别\"{assignedGroup.GroupName}\"要求完成人数为{project.CompletionCount}人,当前分配了{assignedCount}人,人数超额,请移除多余队员"
});
errorMessage = $"{project.Name}参赛人数错误(多{assignedCount - project.CompletionCount}人)";
}
result.HasError = true;
result.Errors.Add(new ProjectCompletionError
{
ProjectId = eventProject.Id,
ProjectName = project.Name ?? "未知项目",
ErrorType = assignedCount < project.CompletionCount ? "Insufficient" : "Excessive",
ErrorMessage = errorMessage
});
}
}
}

View File

@ -5895,6 +5895,12 @@
证书ID(必选)
</summary>
</member>
<member name="P:YD_Event.Application.Event.Dtos.IssueCertificateInput.PageType">
<summary>
页面类型:1-运动员页面,2-教练页面,3-领队页面
用于明确区分不同角色页面的证书生成逻辑
</summary>
</member>
<member name="T:YD_Event.Application.Event.Dtos.IssueCertificateResult">
<summary>
发放证书结果
@ -7749,6 +7755,13 @@
发放证书(支持模板选择和批量发放)
</summary>
</member>
<member name="M:YD_Event.Application.Event.EventScoreService.GetAllowedPersonTypes(YD_Event.Core.Enum.CertificatePageType)">
<summary>
根据页面类型获取允许的人员类型列表
</summary>
<param name="pageType">页面类型</param>
<returns>允许的人员类型列表</returns>
</member>
<member name="M:YD_Event.Application.Event.EventScoreService.BuildPlaceholderData(YD_Event.Core.Entities.EventRegistrationPerson,YD_Event.Core.Entities.EventResult)">
<summary>
构建运动员占位符数据
@ -8289,6 +8302,11 @@
6. 查看证书
</summary>
</member>
<member name="M:YD_Event.Application.Event.UserEventRecordService.CheckCertificatePermissionAsync(System.Int64,YD_Event.Core.Entities.EventCertificate)">
<summary>
检查证书查看权限
</summary>
</member>
<member name="M:YD_Event.Application.Event.UserEventRecordService.CancelPreview(System.Int64)">
<summary>
7. 取消报名预览

View File

@ -33,6 +33,11 @@ public class EventCertificate : Entity<long>, ISoftDelete, ICreatedUserId, ICrea
/// </summary>
public long ProjectId { get; set; }
/// <summary>
/// 关联的用户ID
/// </summary>
public long UserId { get; set; }
/// <summary>
/// 参赛选手姓名
/// </summary>

View File

@ -0,0 +1,33 @@
using System.ComponentModel;
namespace YD_Event.Core.Enum;
/// <summary>
/// 证书页面类型
/// </summary>
public enum CertificatePageType
{
/// <summary>
/// 未指定(向后兼容)
/// </summary>
[Description("未指定")]
Unspecified = 0,
/// <summary>
/// 运动员页面
/// </summary>
[Description("运动员页面")]
Athlete = 1,
/// <summary>
/// 教练页面
/// </summary>
[Description("教练页面")]
Coach = 2,
/// <summary>
/// 领队页面
/// </summary>
[Description("领队页面")]
Leader = 3
}

View File

@ -1142,6 +1142,11 @@
赛事项目ID
</summary>
</member>
<member name="P:YD_Event.Core.Entities.EventCertificate.UserId">
<summary>
关联的用户ID
</summary>
</member>
<member name="P:YD_Event.Core.Entities.EventCertificate.ParticipantName">
<summary>
参赛选手姓名
@ -4290,6 +4295,31 @@
禁用
</summary>
</member>
<member name="T:YD_Event.Core.Enum.CertificatePageType">
<summary>
证书页面类型
</summary>
</member>
<member name="F:YD_Event.Core.Enum.CertificatePageType.Unspecified">
<summary>
未指定(向后兼容)
</summary>
</member>
<member name="F:YD_Event.Core.Enum.CertificatePageType.Athlete">
<summary>
运动员页面
</summary>
</member>
<member name="F:YD_Event.Core.Enum.CertificatePageType.Coach">
<summary>
教练页面
</summary>
</member>
<member name="F:YD_Event.Core.Enum.CertificatePageType.Leader">
<summary>
领队页面
</summary>
</member>
<member name="T:YD_Event.Core.Enum.ChargeMode">
<summary>
收费模式枚举