2026-09-17 10:25:06 +08:00

368 lines
15 KiB
C#

using EasyCaching.Core;
using YD_Event.Application.Auth;
using YD_Event.Application.Client.Dtos;
namespace YD_Event.Application.Client;
/// <summary>
/// 小程序裁判认证与执裁报名接口。
/// 裁判自己注册账号后在小程序提交认证资料,由后台审核;审核通过后才能针对赛事报名执裁。
/// </summary>
[ApiDescriptionSettings("微信小程序接口")]
[Authorize]
public class RefereeController : IDynamicApiController
{
private const string SmsCodeCachePrefix = "sms:code:";
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<RefereeApplication> _applicationRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Referee> _refereeRepository;
private readonly ISqlSugarRepository<EventReferee> _eventRefereeRepository;
private readonly ISqlSugarRepository<EventRegistrationLimit> _limitRepository;
private readonly IEasyCachingProvider _cache;
private readonly AuthManager _authManager;
private readonly IIdGenerator _idGenerator;
public RefereeController(
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<RefereeApplication> applicationRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Referee> refereeRepository,
ISqlSugarRepository<EventReferee> eventRefereeRepository,
ISqlSugarRepository<EventRegistrationLimit> 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;
}
/// <summary>
/// 获取裁判认证页面的级别、角色下拉数据。
/// </summary>
[HttpGet("profile/options")]
[DisplayName("获取裁判认证选项")]
public RefereeProfileOptionsDto GetProfileOptions()
{
return new RefereeProfileOptionsDto
{
Levels = System.Enum.GetValues<RefereeLevel>()
.Select(x => new RefereeEnumOptionDto { Value = (int)x, Label = x.Description() }).ToList(),
Roles = System.Enum.GetValues<RefereeRole>()
.Where(x => x != RefereeRole.None)
.Select(x => new RefereeEnumOptionDto { Value = (int)x, Label = x.Description() }).ToList()
};
}
/// <summary>
/// 解析裁判报名小程序码。扫码进入时尚未登录,因此该接口允许匿名访问。
/// </summary>
[HttpGet("registration-code/{code}")]
[AllowAnonymous]
[DisplayName("解析裁判报名码")]
public async Task<long> 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;
}
/// <summary>
/// 获取我的裁判认证资料。尚未提交时返回 null。
/// </summary>
[HttpGet("profile")]
[DisplayName("获取我的裁判认证")]
public async Task<RefereeProfileDto?> GetProfile()
{
var userId = await EnsureRefereeAsync();
var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId && !x.DeleteMark);
return referee == null ? null : ToProfileDto(referee);
}
/// <summary>
/// 提交或更新裁判认证资料。已通过审核的资料再次提交后重新进入待审核。
/// </summary>
[HttpPost("profile/submit")]
[UnitOfWork]
[DisplayName("提交裁判认证")]
public async Task<RefereeProfileDto> 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);
}
/// <summary>
/// 查询指定赛事的裁判报名名额状态。
/// </summary>
[HttpGet("applications/quota")]
[DisplayName("获取赛事裁判名额")]
public async Task<RefereeQuotaDto> 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<RefereeApplicationListOutput> GetApplications()
{
var userId = await EnsureRefereeAsync();
var applications = await _applicationRepository.AsQueryable()
.Where(x => x.RefereeUserId == userId && x.Status == AvailabilityStatus.Enable)
.OrderByDescending(x => x.CreatedTime)
.ToListAsync();
var eventIds = applications.Select(x => x.EventId).Distinct().ToList();
var events = eventIds.Count == 0
? new List<EventInfo>()
: 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<object> Apply([FromBody] RefereeApplyInput input)
{
var userId = await EnsureRefereeAsync();
var evt = await _eventRepository.GetFirstAsync(x => x.Id == input.EventId && x.Status == AvailabilityStatus.Enable);
if (evt == null) throw Oops.Bah("赛事不存在");
if (evt.EventStatus != EventStatus.Enrolling) throw Oops.Bah("当前赛事不在执裁报名时间内");
var exists = await _applicationRepository.IsAnyAsync(x =>
x.EventId == input.EventId && x.RefereeUserId == userId && x.ApplicationStatus != 4);
if (exists) throw Oops.Bah("已报名执裁当前赛事");
var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId && !x.DeleteMark);
if (referee == null) throw Oops.Bah("裁判档案不存在,请先完善裁判资料");
if (referee.AuditStatus != AuditStatus.Pass) throw Oops.Bah("裁判认证尚未通过审核,暂不能报名执裁");
var quota = await BuildQuotaAsync(evt, userId);
if (quota.IsFull) throw Oops.Bah("当前赛事裁判报名名额已满");
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))
{
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 };
}
/// <summary>
/// 报名名额按赛事报名限制中的裁判限报数量控制,为 0 时不限名额。
/// </summary>
private async Task<RefereeQuotaDto> BuildQuotaAsync(EventInfo evt, long userId)
{
var limit = await _limitRepository.GetFirstAsync(x => x.EventId == evt.Id && !x.DeleteMark);
var max = limit?.RefereeLimit ?? 0;
var applied = await _applicationRepository.AsQueryable()
.Where(x => x.EventId == evt.Id && x.ApplicationStatus != 4 && x.Status == AvailabilityStatus.Enable)
.CountAsync();
var appliedByMe = await _applicationRepository.IsAnyAsync(x =>
x.EventId == evt.Id && x.RefereeUserId == userId && x.ApplicationStatus != 4);
var isFull = max > 0 && applied >= max && !appliedByMe;
var canApply = evt.EventStatus == EventStatus.Enrolling && !isFull && !appliedByMe;
string message;
if (appliedByMe) message = "已报名执裁当前赛事";
else if (isFull) message = "当前赛事裁判报名名额已满";
else if (evt.EventStatus != EventStatus.Enrolling) message = "当前赛事不在执裁报名时间内";
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 VerifyCodeAsync(string mobile, string? code)
{
if (string.IsNullOrWhiteSpace(code)) throw Oops.Bah("请输入验证码");
var cached = await _cache.GetAsync<string>($"{SmsCodeCachePrefix}{mobile}");
if (!cached.HasValue || cached.Value != code) throw Oops.Bah("验证码错误或已过期");
}
private async Task<long> EnsureRefereeAsync()
{
var userId = _authManager.UserId;
var user = await _userRepository.GetFirstAsync(x => x.Id == userId);
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<RefereeRole>()
.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 => "已取消",
_ => "待审批"
};
}