using EasyCaching.Core; using YD_Event.Application.Auth; using YD_Event.Application.Client.Dtos; using YD_Event.Application.Event; namespace YD_Event.Application.Client; /// /// 小程序裁判认证与执裁报名接口。 /// 裁判自己注册账号后在小程序提交认证资料,由后台审核;审核通过后才能针对赛事报名执裁。 /// [ApiDescriptionSettings("微信小程序接口")] [Authorize] public class RefereeController : IDynamicApiController { private const string SmsCodeCachePrefix = "sms:code:"; private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _applicationRepository; private readonly ISqlSugarRepository _refereeRepository; private readonly ISqlSugarRepository _eventRefereeRepository; private readonly ISqlSugarRepository _limitRepository; private readonly IEasyCachingProvider _cache; private readonly AuthManager _authManager; private readonly IIdGenerator _idGenerator; public RefereeController( ISqlSugarRepository userRepository, ISqlSugarRepository eventRepository, ISqlSugarRepository applicationRepository, ISqlSugarRepository refereeRepository, ISqlSugarRepository eventRefereeRepository, ISqlSugarRepository limitRepository, IEasyCachingProvider cache, AuthManager authManager, IIdGenerator idGenerator) { _userRepository = userRepository; _eventRepository = eventRepository; _applicationRepository = applicationRepository; _refereeRepository = refereeRepository; _eventRefereeRepository = eventRefereeRepository; _limitRepository = limitRepository; _cache = cache; _authManager = authManager; _idGenerator = idGenerator; } /// /// 获取裁判认证页面的级别、角色下拉数据。 /// [HttpGet("profile/options")] [DisplayName("获取裁判认证选项")] public RefereeProfileOptionsDto GetProfileOptions() { return new RefereeProfileOptionsDto { Levels = System.Enum.GetValues() .Select(x => new RefereeEnumOptionDto { Value = (int)x, Label = x.Description() }).ToList(), Roles = System.Enum.GetValues() .Where(x => x != RefereeRole.None) .Select(x => new RefereeEnumOptionDto { Value = (int)x, Label = x.Description() }).ToList() }; } /// /// 解析裁判报名小程序码。扫码进入时尚未登录,因此该接口允许匿名访问。 /// [HttpGet("registration-code/{code}")] [AllowAnonymous] [DisplayName("解析裁判报名码")] public async Task ResolveRegistrationCode(string code) { var normalizedCode = code?.Trim(); if (string.IsNullOrWhiteSpace(normalizedCode) || !Regex.IsMatch(normalizedCode, @"^RJ[A-Za-z0-9]{10,30}$")) throw Oops.Bah("裁判报名码无效"); var evt = await _eventRepository.GetFirstAsync(x => x.RefereeRegistrationCode == normalizedCode && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (evt == null) throw Oops.Bah("裁判报名码无效或赛事已停用"); return evt.Id; } /// /// 获取我的裁判认证资料。尚未提交时返回 null。 /// [HttpGet("profile")] [DisplayName("获取我的裁判认证")] public async Task GetProfile() { var userId = await EnsureRefereeAsync(); var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId && !x.DeleteMark); return referee == null ? null : ToProfileDto(referee); } /// /// 提交或更新裁判认证资料。已通过审核的资料再次提交后重新进入待审核。 /// [HttpPost("profile/submit")] [UnitOfWork] [DisplayName("提交裁判认证")] public async Task SubmitProfile([FromBody] RefereeProfileSubmitInput input) { var userId = await EnsureRefereeAsync(); var user = await _userRepository.GetFirstAsync(x => x.Id == userId && !x.DeleteMark); if (user == null) throw Oops.Bah("用户不存在"); var mobile = string.IsNullOrWhiteSpace(input.Mobile) ? user.Mobile : input.Mobile.Trim(); if (!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$")) throw Oops.Bah("手机号格式不正确"); var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId && !x.DeleteMark); // 换绑手机号必须验证码校验,避免占用他人裁判档案。 var needVerify = !string.Equals(mobile, user.Mobile, StringComparison.Ordinal) || (referee != null && !string.Equals(mobile, referee.Mobile, StringComparison.Ordinal)); if (needVerify) { await VerifyCodeAsync(mobile, input.VerificationCode); } // 裁判库中手机号唯一,允许命中自己的档案。 var sameMobile = await _refereeRepository.AsQueryable() .Where(x => x.Mobile == mobile && !x.DeleteMark).ToListAsync(); if (sameMobile.Any(x => referee == null || x.Id != referee.Id)) throw Oops.Bah("该手机号已存在于裁判库,请联系赛事组委会处理"); var isNew = referee == null; referee ??= new YD_Event.Core.Entities.Referee { Id = _idGenerator.NextId(), AppUserId = userId, CreatedUserId = userId, CreatedTime = DateTime.Now }; referee.Name = input.Name.Trim(); referee.Mobile = mobile; referee.ProvinceCode = input.ProvinceCode; referee.ProvinceName = input.ProvinceName; referee.CityCode = input.CityCode; referee.CityName = input.CityName; referee.DistrictCode = input.DistrictCode; referee.DistrictName = input.DistrictName; referee.Level = input.Level; referee.CertificateUrl = input.CertificateUrl; referee.Roles = input.Roles; // 小程序提交的资料统一走后台审核。 referee.AuditStatus = AuditStatus.PendingAudit; referee.AuditRemark = null; referee.AuditUserId = null; referee.AuditTime = null; referee.Status = AvailabilityStatus.Enable; if (isNew) { await _refereeRepository.InsertAsync(referee); } else { referee.UpdatedUserId = userId; referee.UpdatedTime = DateTime.Now; await _refereeRepository.UpdateAsync(referee); } if (needVerify) await _cache.RemoveAsync($"{SmsCodeCachePrefix}{mobile}"); return ToProfileDto(referee); } /// /// 查询指定赛事的裁判报名名额状态。 /// [HttpGet("applications/quota")] [DisplayName("获取赛事裁判名额")] public async Task GetQuota([FromQuery] long eventId) { var userId = await EnsureRefereeAsync(); var evt = await _eventRepository.GetFirstAsync(x => x.Id == eventId && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (evt == null) throw Oops.Bah("赛事不存在"); var quota = await BuildQuotaAsync(evt, userId); return quota; } [HttpGet("applications")] [DisplayName("获取我的执裁报名")] public async Task GetApplications() { var userId = await EnsureRefereeAsync(); var applications = await _applicationRepository.AsQueryable() .Where(x => x.RefereeUserId == userId && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .OrderByDescending(x => x.CreatedTime) .ToListAsync(); var eventIds = applications.Select(x => x.EventId).Distinct().ToList(); var events = eventIds.Count == 0 ? new List() : await _eventRepository.AsQueryable().Where(x => eventIds.Contains(x.Id)).ToListAsync(); var eventMap = events.ToDictionary(x => x.Id); var rows = applications.Select(x => { eventMap.TryGetValue(x.EventId, out var evt); return new RefereeApplicationDto { Id = x.Id, ApplicationId = x.Id, EventId = x.EventId, EventName = evt?.EventName ?? string.Empty, CoverImage = evt?.CoverImage, Address = evt?.DetailAddress, EventStatus = evt == null ? 0 : (int)evt.EventStatus, ApplicationStatus = x.ApplicationStatus, ApplicationStatusText = ApplicationStatusText(x.ApplicationStatus), AppliedAt = x.CreatedTime, MatchStartTime = evt?.MatchStartTime ?? DateTime.MinValue, MatchEndTime = evt?.MatchEndTime ?? DateTime.MinValue }; }).ToList(); return new RefereeApplicationListOutput { Rows = rows, Total = rows.Count }; } [HttpPost("applications/apply")] [UnitOfWork] [DisplayName("报名执裁")] public async Task Apply([FromBody] RefereeApplyInput input) { var userId = await EnsureRefereeAsync(); var evt = await _eventRepository.GetFirstAsync(x => x.Id == input.EventId && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (evt == null) throw Oops.Bah("赛事不存在"); if (GetEffectiveEventStatus(evt) != EventStatus.Enrolling) throw Oops.Bah("当前赛事不在执裁报名时间内"); // 同一裁判的报名请求必须串行化,否则并发报名两场重叠赛事时, // 两个事务可能都在对方写入前通过撞期校验。锁顺序固定为:裁判 -> 赛事名额。 var referee = await _refereeRepository.AsQueryable() .With("WITH(UPDLOCK, HOLDLOCK)") .Where(x => x.AppUserId == userId && !x.DeleteMark) .FirstAsync(); if (referee == null) throw Oops.Bah("裁判档案不存在,请先完善裁判资料"); if (referee.AuditStatus != AuditStatus.Pass) throw Oops.Bah("裁判认证尚未通过审核,暂不能报名执裁"); var exists = await _applicationRepository.IsAnyAsync(x => x.EventId == input.EventId && x.RefereeUserId == userId && (x.ApplicationStatus == 1 || x.ApplicationStatus == 2) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (exists) throw Oops.Bah("已报名执裁当前赛事"); var quota = await BuildQuotaAsync(evt, userId, true, referee.Id); if (!quota.CanApply) throw Oops.Bah(quota.Message); var application = new RefereeApplication { Id = _idGenerator.NextId(), EventId = input.EventId, RefereeUserId = userId, // 小程序报名无需后台审批,提交成功即成为赛事裁判。 ApplicationStatus = 2, Status = AvailabilityStatus.Enable, CreatedUserId = userId, CreatedTime = DateTime.Now }; await _applicationRepository.InsertAsync(application); if (!await _eventRefereeRepository.IsAnyAsync(x => x.EventId == input.EventId && x.RefereeId == referee.Id && !x.DeleteMark)) { await _eventRefereeRepository.InsertAsync(new EventReferee { Id = _idGenerator.NextId(), EventId = input.EventId, RefereeId = referee.Id, RefereeApplicationId = application.Id, SourceType = 2, Status = AvailabilityStatus.Enable, CreatedUserId = userId, CreatedTime = DateTime.Now }); } return new { success = true }; } /// /// 报名名额按赛事报名限制中的裁判限报数量控制,为 0 时不限名额。 /// private async Task BuildQuotaAsync( EventInfo evt, long userId, bool lockForUpdate = false, long? refereeId = null) { var limit = await _limitRepository.GetFirstAsync(x => x.EventId == evt.Id && !x.DeleteMark); var max = limit?.RefereeLimit ?? 0; var applicationsQuery = _applicationRepository.AsQueryable(); if (lockForUpdate) { // Apply 由 UnitOfWork 包裹。更新锁和范围锁会把同一赛事的“统计名额并新增报名”串行化, // 防止最后一个名额被并发请求同时占用。 applicationsQuery = applicationsQuery.With("WITH(UPDLOCK, HOLDLOCK)"); } var activeApplications = await applicationsQuery .Where(x => x.EventId == evt.Id && (x.ApplicationStatus == 1 || x.ApplicationStatus == 2) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .Select(x => new { x.Id, x.RefereeUserId }) .ToListAsync(); var applied = activeApplications.Count; var appliedByMe = activeApplications.Any(x => x.RefereeUserId == userId); var conflictingEvent = appliedByMe ? null : await GetConflictingCommittedEventAsync(userId, refereeId, evt); var isFull = max > 0 && applied >= max && !appliedByMe; var eventStatus = GetEffectiveEventStatus(evt); var canApply = eventStatus == EventStatus.Enrolling && !isFull && !appliedByMe && conflictingEvent == null; string message; if (appliedByMe) message = "已报名执裁当前赛事"; else if (isFull) message = "当前赛事裁判报名名额已满"; else if (eventStatus != EventStatus.Enrolling) message = "当前赛事不在执裁报名时间内"; else if (conflictingEvent != null) message = BuildTimeConflictMessage(conflictingEvent, evt); else message = max > 0 ? $"裁判名额未满,剩余{max - applied}个" : "裁判名额未满"; return new RefereeQuotaDto { EventId = evt.Id, EventName = evt.EventName, Limit = max, Applied = applied, Remaining = max > 0 ? Math.Max(0, max - applied) : 0, IsFull = isFull, Applied4Me = appliedByMe, CanApply = canApply, Message = message }; } /// /// 查找当前裁判已报名、后台添加或秩序册导入,且执裁时间与目标赛事重叠的赛事。 /// 时间区间按左闭右开处理,相邻赛事首尾时间相等时不视为冲突。 /// private async Task GetConflictingCommittedEventAsync( long userId, long? refereeId, EventInfo targetEvent) { var appliedEventIds = await _applicationRepository.AsQueryable() .Where(x => x.RefereeUserId == userId && x.EventId != targetEvent.Id && (x.ApplicationStatus == 1 || x.ApplicationStatus == 2) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .Select(x => x.EventId) .Distinct() .ToListAsync(); if (!refereeId.HasValue) { refereeId = (await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId && !x.DeleteMark))?.Id; } if (refereeId.HasValue) { var relatedEventIds = await _eventRefereeRepository.AsQueryable() .Where(x => x.RefereeId == refereeId.Value && x.EventId != targetEvent.Id && !x.DeleteMark) .Select(x => x.EventId) .Distinct() .ToListAsync(); appliedEventIds = appliedEventIds.Concat(relatedEventIds).Distinct().ToList(); } if (appliedEventIds.Count == 0) return null; return await _eventRepository.AsQueryable() .Where(x => appliedEventIds.Contains(x.Id) && x.Status == AvailabilityStatus.Enable && !x.DeleteMark && x.MatchStartTime < targetEvent.MatchEndTime && x.MatchEndTime > targetEvent.MatchStartTime) .OrderBy(x => x.MatchStartTime) .FirstAsync(); } private static string BuildTimeConflictMessage(EventInfo appliedEvent, EventInfo targetEvent) => $"您已参与执裁:\n{appliedEvent.EventName}\n{FormatMatchPeriod(appliedEvent)}\n\n" + $"当前赛事执裁时间:\n{FormatMatchPeriod(targetEvent)}\n\n" + "执裁时间存在重叠,暂无法报名。"; private static string FormatMatchPeriod(EventInfo evt) { var start = evt.MatchStartTime; var end = evt.MatchEndTime; return start.Date == end.Date ? $"{start:M月d日 HH:mm}-{end:HH:mm}" : $"{start:M月d日 HH:mm}-{end:M月d日 HH:mm}"; } private static EventStatus GetEffectiveEventStatus(EventInfo evt) => EventStatusUpdateService.CalculateEventStatus(evt, DateTime.Now); private async Task VerifyCodeAsync(string mobile, string? code) { if (string.IsNullOrWhiteSpace(code)) throw Oops.Bah("请输入验证码"); var cached = await _cache.GetAsync($"{SmsCodeCachePrefix}{mobile}"); if (!cached.HasValue || cached.Value != code) throw Oops.Bah("验证码错误或已过期"); } private async Task EnsureRefereeAsync() { var userId = _authManager.UserId; var user = await _userRepository.GetFirstAsync(x => x.Id == userId && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); if (user == null) throw Oops.Bah("用户不存在"); if (user.MiniProgramRole != MiniProgramRole.Referee) throw Oops.Bah("当前账号不是裁判身份"); return userId; } private static RefereeProfileDto ToProfileDto(YD_Event.Core.Entities.Referee referee) { var roles = System.Enum.GetValues() .Where(x => x != RefereeRole.None && referee.Roles.HasFlag(x)).ToList(); return new RefereeProfileDto { Id = referee.Id, Name = referee.Name, Mobile = referee.Mobile, ProvinceCode = referee.ProvinceCode, ProvinceName = referee.ProvinceName, CityCode = referee.CityCode, CityName = referee.CityName, DistrictCode = referee.DistrictCode, DistrictName = referee.DistrictName, RegionName = $"{referee.ProvinceName}/{referee.CityName}/{referee.DistrictName}", Level = referee.Level, LevelName = referee.Level.Description(), CertificateUrl = referee.CertificateUrl, Roles = referee.Roles, RoleValues = roles.Select(x => (int)x).ToList(), RoleNames = roles.Select(x => x.Description()).ToList(), AuditStatus = referee.AuditStatus, AuditStatusText = referee.AuditStatus.Description(), AuditRemark = referee.AuditRemark, CanApply = referee.AuditStatus == AuditStatus.Pass, CreatedTime = referee.CreatedTime }; } private static string ApplicationStatusText(int status) => status switch { 2 => "报名成功", 3 => "报名未通过", 4 => "已取消", _ => "待审批" }; }