YD_Event.Admin/YD_Event.Application/Client/EventClientController.cs
2026-04-04 23:31:38 +08:00

1141 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using YD_Event.Application.Client.Dtos;
using YD_Event.Core.Entities;
using YD_Event.Core.Enum;
using YD_Event.Core.Infrastructure.WeChat;
using YD_Event.Core.Infrastructure.WeChat.Dto;
using YD_Event.Core.Options;
using SqlSugar;
using Furion.DatabaseAccessor;
using Microsoft.AspNetCore.Authorization;
using Furion.FriendlyException;
using System.Security.Claims;
using System.Text.Json;
namespace YD_Event.Application.Client;
/// <summary>
/// 小程序-赛事接口
/// </summary>
[ApiDescriptionSettings("微信小程序接口")]
[Authorize]
public class EventClientController : IDynamicApiController
{
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Project> _projectRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
private readonly ISqlSugarRepository<ProjectGroup> _groupRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly ISqlSugarRepository<EventRegistrationItem> _registrationItemRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Banner> _bannerRepository;
private readonly ISqlSugarRepository<EventUnit> _unitRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventRegistrationLimit> _limitRepository;
private readonly ISqlSugarRepository<EventProjectRegistrationLimit> _projectLimitRepository;
private readonly ISqlSugarRepository<EventFeeConfig> _feeConfigRepository;
private readonly ISqlSugarRepository<WeChatPayConfig> _weChatPayConfigRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Merchant> _merchantRepository;
private readonly ISqlSugarRepository<PointsRule> _pointsRuleRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _appUserRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IIdGenerator _idGenerator;
private readonly WeChatPayService _weChatPayService;
private readonly IOptionsMonitor<WechatTenpayClientOptions> _wechatTenpayClientOptionsMonitor;
private readonly ILogger<EventClientController> _logger;
public EventClientController(
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Project> projectRepository,
ISqlSugarRepository<EventProject> eventProjectRepository,
ISqlSugarRepository<ProjectGroup> groupRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
ISqlSugarRepository<EventRegistrationItem> registrationItemRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Banner> bannerRepository,
ISqlSugarRepository<EventUnit> unitRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventRegistrationLimit> limitRepository,
ISqlSugarRepository<EventProjectRegistrationLimit> projectLimitRepository,
ISqlSugarRepository<EventFeeConfig> feeConfigRepository,
ISqlSugarRepository<WeChatPayConfig> weChatPayConfigRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Merchant> merchantRepository,
ISqlSugarRepository<PointsRule> pointsRuleRepository,
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> appUserRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
WeChatPayService weChatPayService,
IOptionsMonitor<WechatTenpayClientOptions> wechatTenpayClientOptionsMonitor,
ILogger<EventClientController> logger)
{
_eventRepository = eventRepository;
_projectRepository = projectRepository;
_eventProjectRepository = eventProjectRepository;
_groupRepository = groupRepository;
_registrationRepository = registrationRepository;
_registrationItemRepository = registrationItemRepository;
_bannerRepository = bannerRepository;
_unitRepository = unitRepository;
_personRepository = personRepository;
_limitRepository = limitRepository;
_projectLimitRepository = projectLimitRepository;
_feeConfigRepository = feeConfigRepository;
_weChatPayConfigRepository = weChatPayConfigRepository;
_merchantRepository = merchantRepository;
_pointsRuleRepository = pointsRuleRepository;
_appUserRepository = appUserRepository;
_httpContextAccessor = httpContextAccessor;
_idGenerator = idGenerator;
_weChatPayService = weChatPayService;
_wechatTenpayClientOptionsMonitor = wechatTenpayClientOptionsMonitor;
_logger = logger;
}
private long GetCurrentUserId()
{
var userIdStr = _httpContextAccessor.HttpContext?.User?.FindFirst(AuthClaimsConst.AuthIdKey)?.Value;
if (string.IsNullOrEmpty(userIdStr) || !long.TryParse(userIdStr, out long userId))
{
throw Oops.Bah("未登录或登录已失效");
}
return userId;
}
/// <summary>
/// 获取轮播图列表
/// </summary>
/// <returns></returns>
[HttpGet("banners")]
[AllowAnonymous]
public async Task<List<BannerDto>> GetBannerList()
{
var list = await _bannerRepository.AsQueryable()
.Where(b => !b.DeleteMark && b.Status == AvailabilityStatus.Enable)
.OrderByDescending(b => b.Sort)
.ToListAsync();
return list.Select(b => new BannerDto
{
Title = b.Title,
ImageUrl = b.ImageUrl,
LinkType = b.LinkType,
LinkUrl = b.LinkUrl,
EventId = b.EventId
}).ToList();
}
/// <summary>
/// 获取赛事列表
/// </summary>
/// <param name="page">页码</param>
/// <param name="pageSize">页大小</param>
/// <param name="status">状态筛选:0-全部 1-报名中 2-比赛中 3-已结束</param>
/// <param name="keyword">搜索关键字</param>
/// <returns></returns>
[HttpGet("list")]
[AllowAnonymous] // 允许未登录查看列表?根据需求,原型中有“我的”,可能需要登录。但通常列表可公开。这里先设为允许匿名,获取详情再校验。
// 修正:Class上有[Authorize],如果需要匿名需显式[AllowAnonymous]。
// 假设小程序进入即登录,保持Authorize
public async Task<PageResult<EventListDto>> GetEventList([FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] int status = 0, [FromQuery] string? keyword = null)
{
var query = _eventRepository.AsQueryable()
.Where(e => !e.DeleteMark && e.Status == AvailabilityStatus.Enable)
.WhereIF(!string.IsNullOrEmpty(keyword), e => e.EventName.Contains(keyword));
// 状态筛选逻辑
// 1-报名中: Now >= RegStart && Now <= RegEnd
// 2-比赛中: Now >= MatchStart && Now <= MatchEnd
// 3-已结束: Now > MatchEnd
var now = DateTime.Now;
if (status == 1)
{
query = query.Where(e => now >= e.RegistrationStartTime && now <= e.RegistrationEndTime);
}
else if (status == 2)
{
query = query.Where(e => now >= e.MatchStartTime && now <= e.MatchEndTime);
}
else if (status == 3)
{
query = query.Where(e => now > e.MatchEndTime);
}
// 排序:优先按报名开始时间倒序
query = query.OrderByDescending(e => e.RegistrationStartTime);
var list = await query.ToPagedListAsync(new Pagination { PageNo = page, PageSize = pageSize });
var result = new PageResult<EventListDto>
{
PageNo = list.PageNo,
PageSize = list.PageSize,
Total = list.Total,
Pages = list.Pages,
Rows = list.Rows.Select(e => new EventListDto
{
Id = e.Id,
EventName = e.EventName,
CoverImage = e.CoverImage,
EventStatus = CalculateEventStatus(e), // 动态计算状态
RegistrationStartTime = e.RegistrationStartTime,
RegistrationEndTime = e.RegistrationEndTime,
MatchStartTime = e.MatchStartTime,
MatchEndTime = e.MatchEndTime,
Address = $"{e.Province}{e.City}{e.District}{e.DetailAddress}"
}).ToList()
};
return result;
}
/// <summary>
/// 获取赛事详情
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<EventDetailDto> GetEventDetail(long id)
{
var evt = await _eventRepository.GetFirstAsync(e => e.Id == id && !e.DeleteMark);
if (evt == null) throw Oops.Bah("赛事不存在");
// 获取项目及组别
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(ep => ep.EventId == id)
.ToListAsync();
var eventProjectIds = eventProjects.Select(ep => ep.Id).Distinct().ToList();
var projectIds = eventProjects.Select(ep => ep.ProjectId).Distinct().ToList();
var baseProjects = await _projectRepository.AsQueryable()
.LeftJoin<ProjectType>((p, t) => p.ProjectTypeId == t.Id)
.Where(p => projectIds.Contains(p.Id) && !p.DeleteMark && p.Status == AvailabilityStatus.Enable)
.Select((p, t) => new {
p.Id,
p.Name,
TypeName = t.Name
})
.ToListAsync();
var groups = await _groupRepository.AsQueryable()
.Where(g => projectIds.Contains(g.ProjectId) && !g.DeleteMark)
.ToListAsync();
// 获取项目限制信息
var projectLimits = await _projectLimitRepository.AsQueryable()
.Where(pl => pl.EventId == id && !pl.DeleteMark)
.ToListAsync();
// 获取单位
var units = await _unitRepository.AsQueryable()
.Where(u => u.EventId == id && !u.DeleteMark)
.OrderBy(u => u.Sort)
.ToListAsync();
// 获取限制规则
var limit = await _limitRepository.GetFirstAsync(l => l.EventId == id && !l.DeleteMark);
// 从 CustomConfigJson 解析退款规则和积分规则
string refundRule = "开赛前7天扣除金额5%,3-7天扣除金额10%,3天内扣除金额20%";
string pointsRule = "参与积分:5积分;名次积分:1-8名 15积分,9-20名 10积分";
if (!string.IsNullOrWhiteSpace(evt.CustomConfigJson))
{
try
{
var config = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(evt.CustomConfigJson);
if (config != null)
{
if (config.TryGetValue("RefundRule", out var rule) && !string.IsNullOrWhiteSpace(rule))
{
refundRule = rule;
}
if (config.TryGetValue("PointsRule", out var pRule) && !string.IsNullOrWhiteSpace(pRule))
{
pointsRule = pRule;
}
}
}
catch
{
// 解析失败时使用默认值
}
}
// 如果有积分规则ID,从数据库获取
if (evt.PointsRuleId.HasValue)
{
var pointsRuleEntity = await _pointsRuleRepository.GetFirstAsync(r => r.Id == evt.PointsRuleId.Value && !r.DeleteMark);
if (pointsRuleEntity != null)
{
pointsRule = pointsRuleEntity.Remark ?? pointsRule;
}
}
var detail = new EventDetailDto
{
Id = evt.Id,
EventName = evt.EventName,
CoverImage = evt.CoverImage,
EventStatus = CalculateEventStatus(evt),
RegistrationStartTime = evt.RegistrationStartTime,
RegistrationEndTime = evt.RegistrationEndTime,
MatchStartTime = evt.MatchStartTime,
MatchEndTime = evt.MatchEndTime,
Address = $"{evt.Province}{evt.City}{evt.District}{evt.DetailAddress}",
EventVideo = evt.EventVideo,
EventType = evt.EventType,
ChargeMode = evt.ChargeMode,
RegistrationType = evt.RegistrationType,
EventIntroduction = evt.EventIntroduction,
MaterialRemark = evt.MaterialRemark,
MaterialUploadStartTime = evt.MaterialUploadStartTime,
MaterialUploadEndTime = evt.MaterialUploadEndTime,
RefundRule = refundRule,
PointsRule = pointsRule,
IndividualProjectLimit = limit?.IndividualProjectLimit ?? 0,
TeamProjectLimit = limit?.TeamProjectLimit ?? 0,
Units = units.Select(u => new EventUnitDto
{
UnitType = u.UnitType,
UnitName = u.UnitName,
LogoUrl = u.LogoUrl
}).ToList(),
Projects = eventProjects.Select(ep =>
{
var bp = baseProjects.FirstOrDefault(b => b.Id == ep.ProjectId);
var pl = projectLimits.FirstOrDefault(x => x.ProjectId == ep.Id);
return new EventProjectDto
{
Id = ep.Id,
Name = bp?.Name ?? "",
TypeName = bp?.TypeName ?? "",
Fee = ep.Price / 100m,
ProjectLimitCount = pl?.LimitCount,
Groups = groups.Where(g => g.ProjectId == ep.ProjectId).Select(g => new EventProjectGroupDto
{
Id = g.Id,
GroupName = g.GroupName,
GenderRequirement = g.Gender == Gender.Male ? "男" : (g.Gender == Gender.Female ? "女" : "不限"),
AgeRequirement = $"{g.BirthDateStart:yyyy-MM-dd} 至 {g.BirthDateEnd:yyyy-MM-dd}"
}).ToList()
};
}).Where(x => !string.IsNullOrEmpty(x.Name)).ToList()
};
return detail;
}
/// <summary>
/// 计算报名金额
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[HttpPost("calculate-amount")]
public async Task<CalculateAmountOutput> CalculateRegistrationAmount([FromBody] CalculateAmountInput input)
{
var evt = await _eventRepository.GetFirstAsync(e => e.Id == input.EventId && !e.DeleteMark);
if (evt == null) throw Oops.Bah("赛事不存在");
var result = new CalculateAmountOutput
{
ChargeMode = evt.ChargeMode,
TotalAmount = 0,
ItemDetails = new List<CalculateAmountItemOutput>()
};
if (evt.ChargeMode == ChargeMode.Free)
{
return result;
}
// 获取赛事费用配置
var feeConfig = await _feeConfigRepository.GetFirstAsync(f => f.EventId == input.EventId && !f.DeleteMark);
// 获取项目信息
var eventProjectIds = input.Items.Select(i => i.ProjectId).Distinct().ToList();
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(ep => eventProjectIds.Contains(ep.Id))
.ToListAsync();
var projectIds = eventProjects.Select(ep => ep.ProjectId).Distinct().ToList();
var baseProjects = await _projectRepository.AsQueryable()
.Where(p => projectIds.Contains(p.Id))
.ToListAsync();
decimal totalAmount = 0;
foreach (var item in input.Items)
{
var ep = eventProjects.FirstOrDefault(p => p.Id == item.ProjectId);
if (ep == null) throw Oops.Bah($"项目不存在: {item.ProjectId}");
var bp = baseProjects.FirstOrDefault(p => p.Id == ep.ProjectId);
decimal unitPrice = 0;
decimal subtotal = 0;
// 根据收费模式计算金额
switch (evt.ChargeMode)
{
case ChargeMode.FixedPrice:
// 定价(赛事):整个赛事一个固定价格,每人一份
if (feeConfig != null)
{
unitPrice = feeConfig.Price;
subtotal = unitPrice * item.PersonCount;
}
break;
case ChargeMode.UnitPriceEvent:
// 单价(赛事):按人数收费
if (feeConfig != null)
{
unitPrice = feeConfig.Price;
subtotal = unitPrice * item.PersonCount;
}
break;
case ChargeMode.UnitPriceProject:
// 单价(项目):每个项目有自己的价格
unitPrice = ep.Price / 100m;
subtotal = unitPrice * item.PersonCount;
break;
}
result.ItemDetails.Add(new CalculateAmountItemOutput
{
ProjectId = item.ProjectId,
ProjectName = bp?.Name ?? "",
UnitPrice = unitPrice,
PersonCount = item.PersonCount,
Subtotal = subtotal
});
totalAmount += subtotal;
}
result.TotalAmount = totalAmount;
return result;
}
/// <summary>
/// 提交赛事报名
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[HttpPost("apply")]
[UnitOfWork]
public async Task<long> SubmitRegistration([FromBody] SubmitRegistrationInput input)
{
var userId = GetCurrentUserId();
// 0. 校验报名模式
if (input.RegistrationMode == 2) // 假设2为团队
throw Oops.Bah("团队报名请前往网页端进行操作");
var evt = await _eventRepository.GetFirstAsync(e => e.Id == input.EventId);
if (evt == null) throw Oops.Bah("赛事不存在");
// 1. 校验时间
if (DateTime.Now < evt.RegistrationStartTime || DateTime.Now > evt.RegistrationEndTime)
throw Oops.Bah("不在报名时间内");
// 2. 校验重复报名 (同一赛事同一人只能报一次?还是可以多次?通常一次报名包含多个项目)
// 假设同一用户对同一赛事只能有一个“未取消”的报名记录
var exist = await _registrationRepository.IsAnyAsync(r => r.EventId == input.EventId && r.CreatedUserId == userId && r.RegistrationStatus != 4); // 4=已取消
if (exist) throw Oops.Bah("您已报名该赛事,请勿重复提交");
// 3. 校验项目有效性 & 限制
if (input.Items == null || !input.Items.Any()) throw Oops.Bah("请至少选择一个项目");
var limit = await _limitRepository.GetFirstAsync(l => l.EventId == input.EventId && !l.DeleteMark);
if (limit != null)
{
// 简单校验项目总数,更严谨需按项目类型统计
if (input.Items.Count > limit.IndividualProjectLimit)
throw Oops.Bah($"每人最多可报{limit.IndividualProjectLimit}个项目");
}
// 4. 创建报名主表
var registrationId = _idGenerator.NewLong();
var registration = new EventRegistration
{
Id = registrationId,
EventId = input.EventId,
RegistrationMode = input.RegistrationMode,
TeamName = input.RegistrationMode == 2 ? input.TeamName : null,
ContactName = input.ContactName,
ContactPhone = input.ContactPhone,
RegistrationStatus = 0, // 待提交/待支付
PaymentStatus = 0, // 未支付
RegistrationTime = DateTime.Now,
CreatedUserId = userId,
CreatedTime = DateTime.Now,
Status = AvailabilityStatus.Enable,
TotalPersonCount = 0 // 稍后计算
};
// 5. 创建报名明细 & 参赛人员 & 计算费用
decimal totalAmount = 0;
int totalPerson = 0;
var regItems = new List<EventRegistrationItem>();
var persons = new List<EventRegistrationPerson>();
foreach (var item in input.Items)
{
var eventProject = await _eventProjectRepository.GetFirstAsync(p => p.Id == item.ProjectId);
if (eventProject == null) throw Oops.Bah($"项目不存在: {item.ProjectId}");
var project = await _projectRepository.GetFirstAsync(p => p.Id == eventProject.ProjectId);
if (project == null) throw Oops.Bah($"关联基础项目不存在: {item.ProjectId}");
// 校验组别
// 略,假设前端传参正确,或需根据 GroupId 查
if (item.Persons == null || !item.Persons.Any())
throw Oops.Bah($"项目[{project.Name}]未填写参赛人信息");
var regItemId = _idGenerator.NewLong();
var regItem = new EventRegistrationItem
{
Id = regItemId,
RegistrationId = registrationId,
ProjectId = item.ProjectId,
ProjectName = project.Name,
GroupName = item.GroupName,
UnitPrice = eventProject.Price / 100m,
PersonCount = item.Persons.Count,
TotalPrice = (eventProject.Price / 100m) * item.Persons.Count,
CreatedUserId = userId,
CreatedTime = DateTime.Now
};
regItems.Add(regItem);
totalAmount += regItem.TotalPrice;
totalPerson += regItem.PersonCount;
// 添加参赛人员
foreach (var pInput in item.Persons)
{
persons.Add(new EventRegistrationPerson
{
Id = _idGenerator.NewLong(),
RegistrationId = registrationId,
RegistrationItemId = regItemId,
ProjectId = item.ProjectId,
PersonType = PersonType.Athlete, // 运动员
Name = pInput.Name,
Gender = pInput.Gender,
IdCard = pInput.IdCardNumber,
BirthDate = pInput.BirthDate,
Phone = pInput.Mobile,
GroupName = item.GroupName,
ProjectName = project.Name,
CreatedUserId = userId,
CreatedTime = DateTime.Now
});
}
}
registration.TotalAmount = totalAmount;
registration.TotalPersonCount = totalPerson;
registration.OrderNo = _idGenerator.NewLong().ToString(); // 使用雪花ID生成订单号
await _registrationRepository.InsertAsync(registration);
await _registrationItemRepository.InsertRangeAsync(regItems);
await _personRepository.InsertRangeAsync(persons);
return registrationId;
}
/// <summary>
/// 获取支付信息
/// </summary>
/// <remarks>
/// 用于获取赛事报名的支付信息,包括微信JSAPI支付参数
/// OpenId 参数必须通过请求体(JSON)传入,格式验证由DTO自动处理
/// </remarks>
/// <param name="input">支付信息查询输入参数,包含报名记录ID和OpenId</param>
/// <returns>支付信息,包括微信支付参数(如果是微信支付)</returns>
[HttpPost("payment-info")]
public async Task<PaymentInfoDto> GetPaymentInfo([FromBody] GetPaymentInfoInput input)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == input.RegistrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
var evt = await _eventRepository.GetFirstAsync(e => e.Id == reg.EventId);
if (evt == null) throw Oops.Bah("赛事不存在");
var configOptions = _wechatTenpayClientOptionsMonitor.CurrentValue;
var paymentInfo = new PaymentInfoDto
{
RegistrationId = reg.Id,
Amount = reg.TotalAmount,
PaymentMethod = reg.PaymentMethod,
BankAccountName = "上海跃动文化科技有限公司",
BankAccountNo = "456498465198456261",
BankName = "招商银行"
};
if (reg.PaymentMethod == 2 && reg.TotalAmount > 0)
{
WeChatPayConfig? payConfig = null;
if (evt.MerchantId.HasValue)
{
payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.MerchantId == evt.MerchantId.Value &&
x.Status == AvailabilityStatus.Enable &&
!x.DeleteMark);
}
if (payConfig == null)
{
payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.Status == AvailabilityStatus.Enable && !x.DeleteMark);
}
if (payConfig != null)
{
_logger.LogInformation("开始创建微信 JSAPI 支付订单,订单号={OrderNo},OpenId={OpenId}", reg.OrderNo, input.OpenId);
var jsapiRequest = new CreateJsApiPayRequest
{
OutTradeNo = reg.OrderNo,
AppId = configOptions.AppId,
Description = $"{evt.EventName} - 报名费",
NotifyUrl = configOptions.NotifyUrl,
TotalAmount = (int)(reg.TotalAmount * 100),
ExpireTime = DateTimeOffset.Now.AddMinutes(30),
OpenId = input.OpenId
};
var payResult = await _weChatPayService.CreateJsApiPayAsync(payConfig, jsapiRequest);
if (payResult.Success && payResult.JsApiParameters != null)
{
_logger.LogInformation("微信 JSAPI 支付下单成功,订单号={OrderNo}", reg.OrderNo);
paymentInfo.WechatPayParams = new
{
appId = payResult.JsApiParameters.AppId,
timeStamp = payResult.JsApiParameters.TimeStamp,
nonceStr = payResult.JsApiParameters.NonceStr,
package = payResult.JsApiParameters.Package,
signType = payResult.JsApiParameters.SignType,
paySign = payResult.JsApiParameters.PaySign
};
}
else
{
_logger.LogError("微信 JSAPI 支付下单失败:订单号={OrderNo},错误信息={Message}", reg.OrderNo, payResult.Message);
throw Oops.Bah($"微信支付下单失败:{payResult.Message}");
}
}
else
{
_logger.LogError("未找到可用的微信支付配置,赛事ID={EventId}", evt.Id);
throw Oops.Bah("未找到可用的微信支付配置");
}
}
return paymentInfo;
}
/// <summary>
/// 上传支付凭证(线下支付)
/// </summary>
[HttpPost("upload-voucher/{registrationId}")]
public async Task<bool> UploadPaymentVoucher(long registrationId, [FromBody] UploadVoucherInput input)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == registrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
reg.PaymentMethod = 1; // 线下
reg.PaymentProofUrl = input.VoucherUrl;
reg.RegistrationStatus = 1; // 待审核
reg.UpdatedTime = DateTime.Now;
reg.UpdatedUserId = userId;
return await _registrationRepository.UpdateAsync(reg);
}
/// <summary>
/// 更新参赛人信息
/// </summary>
[HttpPost("update-player")]
public async Task<bool> UpdateRegistrationPlayer([FromBody] UpdatePlayerInput input)
{
var userId = GetCurrentUserId();
var person = await _personRepository.GetFirstAsync(p => p.Id == input.PersonId);
if (person == null) throw Oops.Bah("参赛人不存在");
// 校验权限:确保是该用户创建的报名
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == person.RegistrationId);
if (reg == null || reg.CreatedUserId != userId) throw Oops.Bah("无权操作");
person.Name = input.Name;
person.IdCard = input.IdCardNumber;
person.Gender = input.Gender;
person.Phone = input.Mobile;
person.UpdatedTime = DateTime.Now;
person.UpdatedUserId = userId;
return await _personRepository.UpdateAsync(person);
}
/// <summary>
/// 获取我的已报赛事列表
/// </summary>
/// <returns></returns>
[HttpGet("my-events")]
public async Task<PageResult<MyEventListDto>> GetMyEventList([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
var userId = GetCurrentUserId();
var query = _registrationRepository.AsQueryable()
.LeftJoin<EventInfo>((r, e) => r.EventId == e.Id)
.Where(r => r.CreatedUserId == userId && !r.DeleteMark)
.OrderByDescending(r => r.RegistrationTime)
.Select((r, e) => new MyEventListDto
{
RegistrationId = r.Id,
EventId = r.EventId,
EventName = e.EventName,
CoverImage = e.CoverImage,
RegistrationStatus = r.RegistrationStatus,
PaymentStatus = r.PaymentStatus,
RegistrationTime = r.RegistrationTime,
ProjectCount = SqlFunc.Subqueryable<EventRegistrationItem>().Where(i => i.RegistrationId == r.Id).Count()
});
return await query.ToPagedListAsync(new Pagination { PageNo = page, PageSize = pageSize });
}
/// <summary>
/// 获取报名详情(包含参赛人)
/// </summary>
[HttpGet("my-registration/{registrationId}")]
public async Task<RegistrationDetailDto> GetRegistrationDetail(long registrationId)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.AsQueryable()
.LeftJoin<EventInfo>((r, e) => r.EventId == e.Id)
.Where(r => r.Id == registrationId && r.CreatedUserId == userId)
.Select((r, e) => new {
r.Id,
r.EventId,
e.EventName,
e.CoverImage,
r.RegistrationStatus,
r.PaymentStatus,
r.TotalAmount,
r.OrderNo,
r.RegistrationTime,
r.ReviewRemark,
// 为了计算 EventStatus
RegStart = e.RegistrationStartTime,
RegEnd = e.RegistrationEndTime,
MatchStart = e.MatchStartTime,
MatchEnd = e.MatchEndTime
})
.FirstAsync();
if (reg == null) throw Oops.Bah("报名记录不存在");
// 计算 EventStatus
EventStatus status = EventStatus.Enrolling;
var now = DateTime.Now;
if (now >= reg.RegStart && now <= reg.RegEnd) status = EventStatus.Enrolling;
else if (now >= reg.MatchStart && now <= reg.MatchEnd) status = EventStatus.Ongoing;
else if (now > reg.MatchEnd) status = EventStatus.Ended;
// 获取 Items 和 Persons
var items = await _registrationItemRepository.AsQueryable()
.Where(i => i.RegistrationId == registrationId)
.ToListAsync();
var persons = await _personRepository.AsQueryable()
.Where(p => p.RegistrationId == registrationId)
.ToListAsync();
return new RegistrationDetailDto
{
RegistrationId = reg.Id,
EventId = reg.EventId,
EventName = reg.EventName,
CoverImage = reg.CoverImage,
EventStatus = status,
RegistrationStatus = reg.RegistrationStatus,
PaymentStatus = reg.PaymentStatus,
TotalAmount = reg.TotalAmount,
OrderNo = reg.OrderNo,
RegistrationTime = reg.RegistrationTime,
ReviewRemark = reg.ReviewRemark,
Items = items.Select(i => new RegistrationItemDto
{
Id = i.Id,
ProjectName = i.ProjectName,
GroupName = i.GroupName,
Price = i.UnitPrice, // 单价
Persons = persons.Where(p => p.RegistrationItemId == i.Id).Select(p => new RegistrationPersonDto
{
Id = p.Id,
Name = p.Name,
IdCardNumber = p.IdCard,
Gender = p.Gender,
Mobile = p.Phone
}).ToList()
}).ToList()
};
}
/// <summary>
/// 查询报名状态
/// </summary>
/// <param name="registrationId"></param>
/// <returns></returns>
[HttpGet("status/{registrationId}")]
public async Task<RegistrationStatusDto> GetRegistrationStatus(long registrationId)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == registrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
return new RegistrationStatusDto
{
RegistrationId = reg.Id,
RegistrationStatus = reg.RegistrationStatus,
PaymentStatus = reg.PaymentStatus,
RejectReason = reg.ReviewRemark
};
}
/// <summary>
/// 取消报名
/// </summary>
/// <param name="registrationId"></param>
/// <returns></returns>
[HttpPost("cancel/{registrationId}")]
public async Task<bool> CancelRegistration(long registrationId)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == registrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
// 校验状态:只有待提交、待审核、未支付状态可直接取消
// 已支付状态需走退款流程(暂不实现)
if (reg.PaymentStatus == 1) throw Oops.Bah("已支付订单请联系客服退款");
if (reg.RegistrationStatus == 4) throw Oops.Bah("订单已取消,无需重复操作");
reg.RegistrationStatus = 4; // 4=已取消
reg.UpdatedTime = DateTime.Now;
reg.UpdatedUserId = userId;
return await _registrationRepository.UpdateAsync(reg);
}
/// <summary>
/// 选择支付方式
/// </summary>
/// <remarks>
/// 用于选择赛事报名的支付方式
/// - 选择微信支付时,必须提供有效的 OpenId
/// - OpenId 的格式验证由 DTO 自动处理
/// </remarks>
/// <param name="input">选择支付方式的输入参数</param>
/// <returns>操作是否成功</returns>
[HttpPost("select-payment-method")]
[UnitOfWork]
public async Task<bool> SelectPaymentMethod([FromBody] SelectPaymentMethodInput input)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == input.RegistrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
if (reg.PaymentStatus == 1) throw Oops.Bah("订单已支付,无法修改支付方式");
if (reg.RegistrationStatus == 4) throw Oops.Bah("订单已取消");
// 如果选择微信支付,验证 OpenId 是否提供
if (input.PaymentMethod == 2)
{
if (string.IsNullOrWhiteSpace(input.OpenId))
{
_logger.LogWarning("选择微信支付时未提供 OpenId,报名ID={RegistrationId}", input.RegistrationId);
throw Oops.Bah("选择微信支付时必须提供 OpenId");
}
}
reg.PaymentMethod = input.PaymentMethod;
reg.UpdatedTime = DateTime.Now;
reg.UpdatedUserId = userId;
_logger.LogInformation("成功修改支付方式,报名ID={RegistrationId},支付方式={PaymentMethod}",
input.RegistrationId, input.PaymentMethod);
return await _registrationRepository.UpdateAsync(reg);
}
/// <summary>
/// 同步支付状态(主动查询微信支付)
/// </summary>
[HttpPost("sync-payment/{registrationId}")]
[UnitOfWork]
public async Task<bool> SyncPaymentStatus(long registrationId)
{
var userId = GetCurrentUserId();
var reg = await _registrationRepository.GetFirstAsync(r => r.Id == registrationId && r.CreatedUserId == userId);
if (reg == null) throw Oops.Bah("报名记录不存在");
if (reg.PaymentStatus == 1) return true; // 已支付无需同步
var evt = await _eventRepository.GetFirstAsync(e => e.Id == reg.EventId);
if (evt == null) throw Oops.Bah("赛事不存在");
WeChatPayConfig? payConfig = null;
if (evt.MerchantId.HasValue)
{
payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.MerchantId == evt.MerchantId.Value &&
x.Status == AvailabilityStatus.Enable &&
!x.DeleteMark);
}
if (payConfig == null)
{
payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.Status == AvailabilityStatus.Enable && !x.DeleteMark);
}
if (payConfig == null) throw Oops.Bah("微信支付配置未设置");
var queryResult = await _weChatPayService.QueryOrderAsync(payConfig, reg.OrderNo);
if (queryResult.Success && queryResult.TradeState == "SUCCESS")
{
// 更新支付状态
reg.PaymentStatus = 1;
reg.PaymentTime = queryResult.SuccessTime?.DateTime;
reg.RegistrationRemark = queryResult.TransactionId; // 将第三方交易号存储在备注字段
reg.RegistrationStatus = 1; // 待审核
reg.UpdatedTime = DateTime.Now;
reg.UpdatedUserId = userId;
await _registrationRepository.UpdateAsync(reg);
_logger.LogInformation($"订单 {reg.OrderNo} 支付状态同步成功,微信交易号:{queryResult.TransactionId}");
return true;
}
return false;
}
/// <summary>
/// 微信支付回调通知处理
/// </summary>
[HttpPost("wechat-pay-notify")]
[AllowAnonymous]
[UnitOfWork]
[NonAction]
public async Task<object> WeChatPayNotify()
{
try
{
using var reader = new StreamReader(_httpContextAccessor.HttpContext.Request.Body);
var body = await reader.ReadToEndAsync();
var headers = _httpContextAccessor.HttpContext.Request.Headers;
var timestamp = headers["Wechatpay-Timestamp"].FirstOrDefault();
var nonce = headers["Wechatpay-Nonce"].FirstOrDefault();
var signature = headers["Wechatpay-Signature"].FirstOrDefault();
var serialNumber = headers["Wechatpay-Serial"].FirstOrDefault();
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce) ||
string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(serialNumber))
{
_logger.LogWarning("微信支付回调通知参数不完整");
return new { code = "FAIL", message = "参数不完整" };
}
// 获取默认支付配置(后续可根据订单号找到对应的商户配置)
var payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.Status == AvailabilityStatus.Enable && !x.DeleteMark);
if (payConfig == null)
{
_logger.LogError("微信支付配置未找到");
return new { code = "FAIL", message = "支付配置未找到" };
}
var notifyRequest = new WeChatPayNotifyRequest
{
Timestamp = timestamp,
Nonce = nonce,
Signature = signature,
SerialNumber = serialNumber,
Body = body
};
// 验证签名
var isValid = await _weChatPayService.VerifyPayNotifyAsync(payConfig, notifyRequest);
if (!isValid)
{
_logger.LogWarning("微信支付回调签名验证失败");
return new { code = "FAIL", message = "签名验证失败" };
}
// 解密通知数据
var notifyData = await _weChatPayService.DecryptPayNotifyDataAsync(payConfig, notifyRequest);
if (notifyData == null || notifyData.TradeState != "SUCCESS")
{
_logger.LogWarning("微信支付回调数据无效或交易未成功");
return new { code = "SUCCESS", message = "成功" };
}
// 根据商户订单号查找报名记录
var reg = await _registrationRepository.GetFirstAsync(r => r.OrderNo == notifyData.OutTradeNo);
if (reg == null)
{
_logger.LogWarning($"未找到订单:{notifyData.OutTradeNo}");
return new { code = "SUCCESS", message = "成功" };
}
if (reg.PaymentStatus == 1)
{
_logger.LogInformation($"订单 {reg.OrderNo} 已处理过支付回调");
return new { code = "SUCCESS", message = "成功" };
}
// 更新支付状态
reg.PaymentStatus = 1;
reg.PaymentTime = notifyData.SuccessTime?.DateTime;
reg.RegistrationRemark = notifyData.TransactionId; // 将第三方交易号存储在备注字段
reg.RegistrationStatus = 1; // 待审核
reg.UpdatedTime = DateTime.Now;
await _registrationRepository.UpdateAsync(reg);
_logger.LogInformation($"订单 {reg.OrderNo} 支付成功,微信交易号:{notifyData.TransactionId}");
return new { code = "SUCCESS", message = "成功" };
}
catch (Exception ex)
{
_logger.LogError(ex, "处理微信支付回调通知异常");
return new { code = "FAIL", message = "处理失败" };
}
}
/// <summary>
/// 从请求中获取 OpenId 参数
/// </summary>
/// <remarks>
/// 支持从以下来源获取 OpenId(按优先级顺序):
/// 1. 查询字符串 (QueryString):openId 或 openid 参数
/// 2. 表单数据 (FormData):openId 或 openid 字段
/// 3. 请求体 (JSON):openId 或 openid 属性
/// 4. 用户 Claims(兜底方案):openid claim
/// </remarks>
/// <returns>获取到的 OpenId 值,如果未找到则返回空字符串</returns>
private string GetOpenIdFromRequest()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
{
_logger.LogWarning("无法获取 HttpContext,OpenId 获取失败");
return string.Empty;
}
// 1. 尝试从查询字符串获取
var openId = httpContext.Request.Query["openId"].FirstOrDefault() ??
httpContext.Request.Query["openid"].FirstOrDefault();
if (!string.IsNullOrEmpty(openId))
{
_logger.LogDebug("从查询字符串获取到 OpenId");
return openId;
}
// 2. 尝试从表单数据获取
if (httpContext.Request.HasFormContentType)
{
openId = httpContext.Request.Form["openId"].FirstOrDefault() ??
httpContext.Request.Form["openid"].FirstOrDefault();
if (!string.IsNullOrEmpty(openId))
{
_logger.LogDebug("从表单数据获取到 OpenId");
return openId;
}
}
// 3. 尝试从请求体 JSON 中获取(需要先读取)
// 注意:为了避免多次读取请求体,这里暂时不实现
// 如果需要从 JSON 中获取,可以在具体接口的 DTO 中添加 openId 字段
// 4. 兜底方案:从用户 Claims 中获取
openId = httpContext.User?.FindFirst("openid")?.Value;
if (!string.IsNullOrEmpty(openId))
{
_logger.LogDebug("从用户 Claims 中获取到 OpenId");
return openId;
}
_logger.LogWarning("未在请求中找到 OpenId 参数");
return string.Empty;
}
/// <summary>
/// 验证 OpenId 参数的有效性
/// </summary>
/// <remarks>
/// 验证规则:
/// 1. OpenId 不能为空或空白字符串
/// 2. OpenId 长度必须在合理范围内(微信 OpenId 通常为 28 个字符左右)
/// 3. OpenId 只能包含字母、数字和下划线
/// </remarks>
/// <param name="openId">待验证的 OpenId</param>
/// <param name="errorMessage">验证失败时的错误信息</param>
/// <returns>验证通过返回 true,否则返回 false</returns>
private bool ValidateOpenId(string openId, out string errorMessage)
{
errorMessage = string.Empty;
// 验证是否为空
if (string.IsNullOrWhiteSpace(openId))
{
errorMessage = "OpenId 参数不能为空";
_logger.LogWarning("OpenId 验证失败:参数为空");
return false;
}
// 验证长度(微信 OpenId 通常为 28 个字符,允许一定的波动范围)
if (openId.Length < 10 || openId.Length > 50)
{
errorMessage = $"OpenId 参数长度不合法,当前长度:{openId.Length}";
_logger.LogWarning("OpenId 验证失败:长度不合法,长度={Length}", openId.Length);
return false;
}
// 验证格式(只能包含字母、数字和下划线)
foreach (char c in openId)
{
if (!char.IsLetterOrDigit(c) && c != '_' && c != '-')
{
errorMessage = "OpenId 参数格式不合法,只能包含字母、数字、下划线和连字符";
_logger.LogWarning("OpenId 验证失败:格式不合法,OpenId={OpenId}", openId);
return false;
}
}
_logger.LogDebug("OpenId 验证通过");
return true;
}
private EventStatus CalculateEventStatus(EventInfo e)
{
var now = DateTime.Now;
if (now >= e.RegistrationStartTime && now <= e.RegistrationEndTime) return EventStatus.Enrolling; // 报名中
if (now >= e.MatchStartTime && now <= e.MatchEndTime) return EventStatus.Ongoing; // 比赛中
if (now > e.MatchEndTime) return EventStatus.Ended; // 已结束
return EventStatus.Enrolling; // 默认? 或者新增未开始状态
}
}