YD_Event.Admin/YD_Event.Application/Client/EventClientController.cs
2026-04-09 15:16:43 +08:00

1595 lines
67 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;
using System.IO;
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<EventProjectGroup> _eventProjectGroupRepository;
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<PointsRuleDetail> _pointsRuleDetailRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _appUserRepository;
private readonly ISqlSugarRepository<ProjectType> _projectTypeRepository;
private readonly ISqlSugarRepository<SysProvince> _provinceRepository;
private readonly ISqlSugarRepository<SysCity> _cityRepository;
private readonly ISqlSugarRepository<SysDistrict> _districtRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Order> _orderRepository;
private readonly ISqlSugarRepository<MerchantCertification> _merchantCertificationRepository;
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<EventProjectGroup> eventProjectGroupRepository,
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<PointsRuleDetail> pointsRuleDetailRepository,
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> appUserRepository,
ISqlSugarRepository<ProjectType> projectTypeRepository,
ISqlSugarRepository<SysProvince> provinceRepository,
ISqlSugarRepository<SysCity> cityRepository,
ISqlSugarRepository<SysDistrict> districtRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Order> orderRepository,
ISqlSugarRepository<MerchantCertification> merchantCertificationRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
WeChatPayService weChatPayService,
IOptionsMonitor<WechatTenpayClientOptions> wechatTenpayClientOptionsMonitor,
ILogger<EventClientController> logger)
{
_eventRepository = eventRepository;
_projectRepository = projectRepository;
_eventProjectRepository = eventProjectRepository;
_groupRepository = groupRepository;
_eventProjectGroupRepository = eventProjectGroupRepository;
_registrationRepository = registrationRepository;
_registrationItemRepository = registrationItemRepository;
_bannerRepository = bannerRepository;
_unitRepository = unitRepository;
_personRepository = personRepository;
_limitRepository = limitRepository;
_projectLimitRepository = projectLimitRepository;
_feeConfigRepository = feeConfigRepository;
_weChatPayConfigRepository = weChatPayConfigRepository;
_merchantRepository = merchantRepository;
_pointsRuleRepository = pointsRuleRepository;
_pointsRuleDetailRepository = pointsRuleDetailRepository;
_appUserRepository = appUserRepository;
_projectTypeRepository = projectTypeRepository;
_provinceRepository = provinceRepository;
_cityRepository = cityRepository;
_districtRepository = districtRepository;
_orderRepository = orderRepository;
_merchantCertificationRepository = merchantCertificationRepository;
_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]
[DisplayName("获取轮播图列表")]
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="input">查询参数</param>
/// <returns></returns>
[HttpGet("list")]
[AllowAnonymous]
[DisplayName("获取赛事列表")]
public async Task<PageResult<EventListDto>> GetEventList([FromQuery] EventClientQueryInput input)
{
var query = _eventRepository.AsQueryable()
.Where(e => !e.DeleteMark && e.Status == AvailabilityStatus.Enable)
.WhereIF(!string.IsNullOrEmpty(input.Keyword), e => e.EventName.Contains(input.Keyword))
.WhereIF(input.EventType.HasValue, e => e.EventType == input.EventType)
.WhereIF(input.ChargeMode.HasValue, e => e.ChargeMode == input.ChargeMode);
var now = DateTime.Now;
if (input.Status == 1)
{
query = query.Where(e => now >= e.RegistrationStartTime && now <= e.RegistrationEndTime);
}
else if (input.Status == 2)
{
query = query.Where(e => now >= e.MatchStartTime && now <= e.MatchEndTime);
}
else if (input.Status == 3)
{
query = query.Where(e => now > e.MatchEndTime);
}
query = query.OrderByDescending(e => e.RegistrationStartTime);
var list = await query.ToPagedListAsync(input);
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]
[DisplayName("获取赛事详情")]
public async Task<EventDetailDto> GetEventDetail(long id)
{
var evt = await _eventRepository.GetFirstAsync(e => e.Id == id && !e.DeleteMark);
if (evt == null) throw Oops.Bah("赛事不存在");
bool isRegistered = false;
try
{
var userId = GetCurrentUserId();
isRegistered = await _registrationRepository.IsAnyAsync(r =>
r.EventId == id && r.CreatedUserId == userId && r.RegistrationStatus != 4 && r.PaymentStatus == 1);
}
catch
{
isRegistered = false;
}
var feeConfig = await _feeConfigRepository.GetFirstAsync(f => f.EventId == id && !f.DeleteMark);
string provinceName = evt.Province ?? "";
string cityName = evt.City ?? "";
string districtName = evt.District ?? "";
if (evt.ProvinceId.HasValue && evt.ProvinceId > 0)
{
var province = await _provinceRepository.GetFirstAsync(p => p.Id == evt.ProvinceId.Value && !p.DeleteMark);
if (province != null) provinceName = province.ProvinceName;
}
if (evt.CityId.HasValue && evt.CityId > 0)
{
var city = await _cityRepository.GetFirstAsync(c => c.Id == evt.CityId.Value && !c.DeleteMark);
if (city != null) cityName = city.CityName;
}
if (evt.DistrictId.HasValue && evt.DistrictId > 0)
{
var district = await _districtRepository.GetFirstAsync(d => d.Id == evt.DistrictId.Value && !d.DeleteMark);
if (district != null) districtName = district.DistrictName;
}
PointsRuleDto? pointsRuleDetail = null;
if (evt.PointsRuleId.HasValue)
{
var pointsRuleEntity = await _pointsRuleRepository.GetFirstAsync(r => r.Id == evt.PointsRuleId.Value && !r.DeleteMark);
if (pointsRuleEntity != null)
{
var details = await _pointsRuleDetailRepository.AsQueryable()
.Where(d => d.PointsRuleId == pointsRuleEntity.Id && !d.DeleteMark)
.ToListAsync();
pointsRuleDetail = new PointsRuleDto
{
RuleName = pointsRuleEntity.RuleName,
ParticipationPoints = pointsRuleEntity.ParticipationPoints,
Remark = pointsRuleEntity.Remark,
Details = details.Select(d => new PointsRuleDetailDto
{
StartRank = d.StartRank,
EndRank = d.EndRank,
Points = d.Points
}).ToList()
};
}
}
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);
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
{
}
}
if (evt.PointsRuleId.HasValue)
{
var pointsRuleEntity = await _pointsRuleRepository.GetFirstAsync(r => r.Id == evt.PointsRuleId.Value && !r.DeleteMark);
if (pointsRuleEntity != null)
{
pointsRule = pointsRuleEntity.Remark ?? pointsRule;
}
}
EventFeeConfigDto? feeConfigDto = null;
if (feeConfig != null)
{
feeConfigDto = new EventFeeConfigDto
{
ChargeMode = feeConfig.ChargeMode,
Price = feeConfig.Price,
MerchantId = evt.MerchantId
};
if (evt.MerchantId.HasValue && evt.MerchantId.Value > 0)
{
var merchant = await _merchantRepository.GetByIdAsync(evt.MerchantId.Value);
if (merchant != null)
{
feeConfigDto.MerchantName = merchant.MerchantName;
feeConfigDto.CreditCode = merchant.CreditCode;
feeConfigDto.BankName = merchant.BankName;
feeConfigDto.AccountNumber = merchant.AccountNumber;
feeConfigDto.BusinessLicenseUrl = merchant.BusinessLicenseUrl;
}
}
if (eventProjects.Any())
{
feeConfigDto.ProjectPrices = new Dictionary<long, decimal>();
foreach (var ep in eventProjects)
{
feeConfigDto.ProjectPrices[ep.Id] = ep.Price / 100m;
}
}
}
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 = $"{provinceName}{cityName}{districtName}{evt.DetailAddress}",
EventVideo = evt.EventVideo,
EventType = evt.EventType,
ChargeMode = evt.ChargeMode,
FeeConfig = feeConfigDto,
RegistrationType = evt.RegistrationType,
EventIntroduction = evt.EventIntroduction,
IsNeedMaterial = evt.IsNeedMaterial,
MaterialRemark = evt.MaterialRemark,
MaterialUploadStartTime = evt.MaterialUploadStartTime,
MaterialUploadEndTime = evt.MaterialUploadEndTime,
RefundRule = refundRule,
PointsRule = pointsRule,
PointsRuleDetail = pointsRuleDetail,
IsRegistered = isRegistered,
ProvinceName = provinceName,
CityName = cityName,
DistrictName = districtName,
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")]
[DisplayName("计算报名金额")]
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();
// 计算总人数
int totalParticipantCount = input.Items.Sum(i => i.PersonCount);
// 构建定价服务输入
var pricingInput = new YD_Event.Core.Domain.Pricing.EventPricingService.PricingInput
{
ChargeMode = evt.ChargeMode,
ParticipantCount = totalParticipantCount,
EventPrice = feeConfig?.Price ?? 0m
};
// 添加项目信息(用于单价项目模式)
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);
pricingInput.Projects.Add(new YD_Event.Core.Domain.Pricing.EventPricingService.ProjectPricingInfo
{
ProjectId = ep.Id,
ProjectName = bp?.Name ?? "",
UnitPrice = ep.Price / 100m
});
}
// 使用统一的定价服务计算费用
var pricingResult = YD_Event.Core.Domain.Pricing.EventPricingService.Calculate(pricingInput);
result.TotalAmount = pricingResult.TotalAmount;
// 构建明细输出
foreach (var item in input.Items)
{
var ep = eventProjects.FirstOrDefault(p => p.Id == item.ProjectId);
var bp = baseProjects.FirstOrDefault(p => p.Id == ep?.ProjectId);
decimal unitPrice = 0;
decimal subtotal = 0;
switch (evt.ChargeMode)
{
case ChargeMode.FixedPrice:
// 定价(赛事):每个项目显示0,但实际只收一次固定费用
unitPrice = 0;
subtotal = 0;
break;
case ChargeMode.UnitPriceEvent:
// 单价(赛事):按赛事单价计算
unitPrice = feeConfig?.Price ?? 0;
subtotal = unitPrice * item.PersonCount;
break;
case ChargeMode.UnitPriceProject:
// 单价(项目):按项目单价计算
unitPrice = ep?.Price / 100m ?? 0;
subtotal = unitPrice * item.PersonCount;
break;
}
result.ItemDetails.Add(new CalculateAmountItemOutput
{
ProjectId = item.ProjectId,
ProjectName = bp?.Name ?? "",
UnitPrice = unitPrice,
PersonCount = item.PersonCount,
Subtotal = subtotal
});
}
return result;
}
/// <summary>
/// 提交赛事报名
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[HttpPost("apply")]
[UnitOfWork]
[DisplayName("提交赛事报名")]
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. 校验重复报名 - 如果已有未取消的报名记录,直接返回现有记录ID
var existingRegistration = await _registrationRepository.GetFirstAsync(
r => r.EventId == input.EventId && r.CreatedUserId == userId && r.RegistrationStatus != 4); // 4=已取消
if (existingRegistration != null)
{
// 已有未取消的报名记录,直接返回现有记录ID,让前端跳转到支付页面
return existingRegistration.Id;
}
// 3. 获取当前用户信息(用于个人报名时自动填充)
var appUser = await _appUserRepository.GetFirstAsync(u => u.CreatedUserId == userId && !u.DeleteMark);
if (appUser == null && input.RegistrationMode == 1)
throw Oops.Bah("请先完成身份认证");
// 3.5 个人报名模式下自动填充联系人信息
if (input.RegistrationMode == 1)
{
if (string.IsNullOrWhiteSpace(input.ContactName))
input.ContactName = appUser?.Name;
if (string.IsNullOrWhiteSpace(input.ContactPhone))
input.ContactPhone = appUser?.Mobile;
// 验证必填联系人信息
if (string.IsNullOrWhiteSpace(input.ContactName))
throw Oops.Bah("联系人姓名不能为空,请先完善身份认证信息");
if (string.IsNullOrWhiteSpace(input.ContactPhone))
throw Oops.Bah("联系人电话不能为空,请先完善身份认证信息");
}
// 4. 校验项目有效性 & 限制
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}个项目");
}
var projectLimits = await _projectLimitRepository.AsQueryable()
.Where(pl => pl.EventId == input.EventId && !pl.DeleteMark)
.ToListAsync();
foreach (var item in input.Items)
{
var projectLimit = projectLimits.FirstOrDefault(pl => pl.ProjectId == item.ProjectId);
if (projectLimit != null)
{
var currentCount = await _registrationItemRepository.AsQueryable()
.InnerJoin<EventRegistration>((ri, r) => ri.RegistrationId == r.Id)
.Where((ri, r) => ri.ProjectId == item.ProjectId && r.RegistrationStatus != 4)
.CountAsync();
if (currentCount >= projectLimit.LimitCount)
{
var eventProject = await _eventProjectRepository.GetFirstAsync(ep => ep.Id == item.ProjectId);
var project = await _projectRepository.GetFirstAsync(p => p.Id == eventProject.ProjectId);
throw Oops.Bah($"项目[{project?.Name}]已达到报名上限,无法继续报名");
}
}
// 个人报名模式下自动填充参赛人员信息
if (input.RegistrationMode == 1 && (item.Persons == null || !item.Persons.Any()))
{
if (appUser == null)
throw Oops.Bah("请先完成身份认证");
item.Persons = new List<PersonInput>
{
new PersonInput
{
Name = appUser.Name ?? "",
Gender = appUser.Gender,
IdCardNumber = appUser.IdCardNumber ?? "",
BirthDate = appUser.BirthDate ?? DateTime.Now,
Mobile = appUser.Mobile
}
};
}
// 最终验证参赛人员信息
if (item.Persons == null || !item.Persons.Any())
throw Oops.Bah($"项目未填写参赛人信息");
}
// 5. 创建报名主表
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. 获取费用配置并使用统一的定价服务计算费用
var feeConfig = await _feeConfigRepository.GetFirstAsync(f => f.EventId == input.EventId && !f.DeleteMark);
// 先获取所有需要的 eventProjectGroup 信息和 eventProject 信息
var eventProjectIds = input.Items.Select(i => i.ProjectId).Distinct().ToList();
var allEventProjectGroups = await _eventProjectGroupRepository.GetListAsync(
eg => eventProjectIds.Contains(eg.EventProjectId) && !eg.DeleteMark);
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();
// 计算总人数
int totalPerson = input.Items.Sum(i => i.Persons!.Count);
// 构建定价服务输入
var pricingInput = new YD_Event.Core.Domain.Pricing.EventPricingService.PricingInput
{
ChargeMode = evt.ChargeMode,
ParticipantCount = totalPerson,
EventPrice = feeConfig?.Price ?? 0m
};
// 添加项目信息(用于单价项目模式)
foreach (var item in input.Items)
{
var ep = eventProjects.FirstOrDefault(p => p.Id == item.ProjectId);
var bp = baseProjects.FirstOrDefault(p => p.Id == ep?.ProjectId);
if (ep != null)
{
pricingInput.Projects.Add(new YD_Event.Core.Domain.Pricing.EventPricingService.ProjectPricingInfo
{
ProjectId = ep.Id,
ProjectName = bp?.Name ?? "",
UnitPrice = ep.Price / 100m
});
}
}
// 使用统一的定价服务计算费用
var pricingResult = YD_Event.Core.Domain.Pricing.EventPricingService.Calculate(pricingInput);
// 6. 创建报名明细 & 参赛人员
var regItems = new List<EventRegistrationItem>();
var persons = new List<EventRegistrationPerson>();
foreach (var item in input.Items)
{
var eventProject = eventProjects.FirstOrDefault(p => p.Id == item.ProjectId);
if (eventProject == null) throw Oops.Bah($"项目不存在: {item.ProjectId}");
var project = baseProjects.FirstOrDefault(p => p.Id == eventProject.ProjectId);
if (project == null) throw Oops.Bah($"关联基础项目不存在: {item.ProjectId}");
// 获取项目类型名称
var projectType = await _projectTypeRepository.GetFirstAsync(t => t.Id == project.ProjectTypeId);
var projectTypeName = projectType?.Name ?? "";
// 获取组别名称 - 优先从前端传的获取,否则从数据库获取默认组别
var groupName = item.GroupName;
if (string.IsNullOrWhiteSpace(groupName))
{
var eventProjectGroups = allEventProjectGroups.Where(eg => eg.EventProjectId == item.ProjectId).ToList();
if (item.GroupId.HasValue)
{
// 如果传了 groupId,优先使用
var selectedGroup = eventProjectGroups.FirstOrDefault(eg => eg.Id == item.GroupId.Value);
groupName = selectedGroup?.GroupName ?? "";
}
// 如果还是没有,使用第一个组别
if (string.IsNullOrWhiteSpace(groupName))
{
var firstGroup = eventProjectGroups.FirstOrDefault();
groupName = firstGroup?.GroupName ?? "";
}
// 如果还是没有,设置默认值
if (string.IsNullOrWhiteSpace(groupName))
{
groupName = "默认组别";
}
}
// 根据收费模式计算该项目的单价和总价
decimal unitPrice = 0;
decimal totalPrice = 0;
switch (evt.ChargeMode)
{
case ChargeMode.Free:
unitPrice = 0;
totalPrice = 0;
break;
case ChargeMode.FixedPrice:
// 定价(赛事):项目单价显示为0,因为总金额是固定的
unitPrice = 0;
totalPrice = 0;
break;
case ChargeMode.UnitPriceEvent:
// 单价(赛事):使用赛事单价
unitPrice = feeConfig?.Price ?? 0;
totalPrice = unitPrice * item.Persons!.Count;
break;
case ChargeMode.UnitPriceProject:
// 单价(项目):使用项目单价
unitPrice = eventProject.Price / 100m;
totalPrice = unitPrice * item.Persons!.Count;
break;
}
var regItemId = _idGenerator.NewLong();
var regItem = new EventRegistrationItem
{
Id = regItemId,
RegistrationId = registrationId,
ProjectId = item.ProjectId,
ProjectName = project.Name,
ProjectTypeName = projectTypeName,
GroupName = groupName,
UnitPrice = unitPrice,
PersonCount = item.Persons!.Count,
TotalPrice = totalPrice,
CreatedUserId = userId,
CreatedTime = DateTime.Now
};
regItems.Add(regItem);
// 添加参赛人员
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 = groupName,
ProjectName = project.Name,
CreatedUserId = userId,
CreatedTime = DateTime.Now
});
}
}
registration.TotalAmount = pricingResult.TotalAmount;
registration.TotalPersonCount = totalPerson;
registration.OrderNo = _idGenerator.NewLong().ToString(); // 使用雪花ID生成订单号
await _registrationRepository.InsertAsync(registration);
await _registrationItemRepository.InsertRangeAsync(regItems);
await _personRepository.InsertRangeAsync(persons);
// 创建订单记录
var certification = await _merchantCertificationRepository.GetFirstAsync(c =>
c.UserId == userId && c.AuditStatus == AuditStatus.Pass && !c.DeleteMark);
var order = new YD_Event.Core.Entities.Order
{
Id = _idGenerator.NewLong(),
OrderNo = registration.OrderNo,
OrderType = 1, // 收入
PaymentMethod = registration.PaymentMethod,
RegistrationMode = registration.RegistrationMode,
MerchantId = evt.MerchantId,
MerchantCertificationId = certification?.Id,
EventId = input.EventId,
RegistrationId = registrationId,
OriginalAmount = pricingResult.TotalAmount,
DiscountAmount = 0,
ActualAmount = pricingResult.TotalAmount,
OrderStatus = OrderStatus.Pending, // 待支付
CreatedTime = DateTime.Now,
CreatedUserId = userId,
Status = AvailabilityStatus.Enable
};
await _orderRepository.InsertAsync(order);
return registrationId;
}
/// <summary>
/// 获取支付信息
/// </summary>
/// <remarks>
/// 用于获取赛事报名的支付信息,包括微信JSAPI支付参数
/// OpenId 参数必须通过请求体(JSON)传入,格式验证由DTO自动处理
/// </remarks>
/// <param name="input">支付信息查询输入参数,包含报名记录ID和OpenId</param>
/// <returns>支付信息,包括微信支付参数(如果是微信支付)</returns>
[HttpPost("payment-info")]
[DisplayName("获取支付信息")]
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 merchant = await _merchantRepository.GetFirstAsync(m => m.Id == evt.MerchantId && !m.DeleteMark);
var paymentInfo = new PaymentInfoDto
{
RegistrationId = reg.Id,
Amount = reg.TotalAmount,
PaymentMethod = reg.PaymentMethod,
BankAccountName = merchant?.MerchantName ?? "上海跃动文化科技有限公司",
BankAccountNo = merchant?.AccountNumber ?? "456498465198456261",
BankName = merchant?.BankName ?? "招商银行"
};
if (reg.PaymentMethod == 2 && reg.TotalAmount > 0)
{
// 根据赛事信息获取对应的商户支付配置
var payConfig = await GetWeChatPayConfigByEventAsync(evt);
if (payConfig == null)
{
_logger.LogError("未找到可用的微信支付配置,赛事ID={EventId},商户ID={MerchantId}", evt.Id, evt.MerchantId);
throw Oops.Bah("未找到可用的微信支付配置,请联系赛事管理员");
}
_logger.LogInformation("开始创建微信 JSAPI 支付订单,订单号={OrderNo},OpenId={OpenId},商户ID={MerchantId}",
reg.OrderNo, input.OpenId, payConfig.MerchantId);
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}");
}
}
return paymentInfo;
}
/// <summary>
/// 根据赛事ID精确获取对应的微信支付配置
/// </summary>
/// <remarks>
/// 1. 通过赛事信息中的 MerchantId 查找对应的微信支付配置
/// 2. 必须确保赛事已关联商户配置,不使用兜底策略
/// 3. 只返回启用状态且未删除的配置
/// </remarks>
/// <param name="eventInfo">赛事信息,必须包含有效的 MerchantId</param>
/// <returns>微信支付配置,如果未找到则返回 null</returns>
/// <exception cref="ArgumentException">当赛事没有关联商户配置时抛出</exception>
private async Task<WeChatPayConfig?> GetWeChatPayConfigByEventAsync(EventInfo eventInfo)
{
if (!eventInfo.MerchantId.HasValue)
{
_logger.LogError("赛事未关联商户配置,赛事ID={EventId},赛事名称={EventName}",
eventInfo.Id, eventInfo.EventName);
throw new ArgumentException($"赛事[{eventInfo.EventName}]未关联商户配置,请联系管理员配置", nameof(eventInfo));
}
var payConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.MerchantId == eventInfo.MerchantId.Value &&
x.Status == AvailabilityStatus.Enable &&
!x.DeleteMark);
if (payConfig == null)
{
_logger.LogError("未找到赛事关联的商户支付配置,赛事ID={EventId},商户ID={MerchantId}",
eventInfo.Id, eventInfo.MerchantId);
}
return payConfig;
}
/// <summary>
/// 上传支付凭证(线下支付)
/// </summary>
[HttpPost("upload-voucher/{registrationId}")]
[DisplayName("上传支付凭证")]
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")]
[DisplayName("更新参赛人信息")]
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")]
[DisplayName("获取我的已报赛事列表")]
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 && r.PaymentStatus == 1)
.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(),
IsNeedMaterial = e.IsNeedMaterial,
MaterialRemark = e.MaterialRemark,
MaterialUploadStartTime = e.MaterialUploadStartTime,
MaterialUploadEndTime = e.MaterialUploadEndTime,
MaterialUrl = r.MaterialUrl
});
return await query.ToPagedListAsync(new Pagination { PageNo = page, PageSize = pageSize });
}
/// <summary>
/// 获取报名详情(包含参赛人)
/// </summary>
[HttpGet("my-registration/{registrationId}")]
[DisplayName("获取报名详情")]
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}")]
[DisplayName("查询报名状态")]
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}")]
[DisplayName("取消报名")]
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]
[DisplayName("选择支付方式")]
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]
[DisplayName("同步支付状态")]
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("赛事不存在");
// 根据赛事信息获取对应的商户支付配置
var payConfig = await GetWeChatPayConfigByEventAsync(evt);
if (payConfig == null)
{
_logger.LogError("未找到可用的微信支付配置,赛事ID={EventId},商户ID={MerchantId}", evt.Id, evt.MerchantId);
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}");
// 更新 Order 记录状态
var order = await _orderRepository.GetFirstAsync(o => o.OrderNo == reg.OrderNo);
if (order != null)
{
order.OrderStatus = OrderStatus.Paid;
order.PaymentTime = queryResult.SuccessTime?.DateTime;
order.UpdatedTime = DateTime.Now;
await _orderRepository.UpdateAsync(order);
_logger.LogInformation($"Order 记录 {order.OrderNo} 状态已更新为已支付");
}
return true;
}
return false;
}
/// <summary>
/// 微信支付回调通知处理
/// </summary>
/// <remarks>
/// 处理流程:
/// 1. 接收微信支付平台回调通知
/// 2. 使用平台级别的支付配置验证签名和解密数据(微信平台证书是平台级别)
/// 3. 获取订单号后查找报名记录
/// 4. 根据报名记录获取赛事信息
/// 5. 根据赛事信息获取商户级别的支付配置(用于后续可能的退款等操作)
/// 6. 更新订单支付状态
/// </remarks>
/// <returns>微信支付平台要求的响应格式</returns>
[HttpPost("wechat-pay-notify")]
[AllowAnonymous]
[UnitOfWork]
[DisplayName("微信支付回调通知")]
public async Task<IActionResult> 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 JsonResult(new { code = "FAIL", message = "参数不完整" });
}
// 第一步:使用平台级别的支付配置验证签名和解密数据
// 注意:微信支付签名验证使用的是微信平台证书(平台级别),所有商户共用
var platformPayConfig = await _weChatPayConfigRepository.GetFirstAsync(x =>
x.Status == AvailabilityStatus.Enable && !x.DeleteMark);
if (platformPayConfig == null)
{
_logger.LogError("未找到平台级别的微信支付配置");
return new JsonResult(new { code = "FAIL", message = "支付配置未找到" });
}
var notifyRequest = new WeChatPayNotifyRequest
{
Timestamp = timestamp,
Nonce = nonce,
Signature = signature,
SerialNumber = serialNumber,
Body = body
};
// 验证签名
var isValid = await _weChatPayService.VerifyPayNotifyAsync(platformPayConfig, notifyRequest);
if (!isValid)
{
_logger.LogWarning("微信支付回调签名验证失败");
return new JsonResult(new { code = "FAIL", message = "签名验证失败" });
}
// 解密通知数据
var notifyData = await _weChatPayService.DecryptPayNotifyDataAsync(platformPayConfig, notifyRequest);
if (notifyData == null || notifyData.TradeState != "SUCCESS")
{
_logger.LogWarning("微信支付回调数据无效或交易未成功");
return new JsonResult(new { code = "SUCCESS", message = "成功" });
}
// 根据商户订单号查找报名记录
var reg = await _registrationRepository.GetFirstAsync(r => r.OrderNo == notifyData.OutTradeNo);
if (reg == null)
{
_logger.LogWarning($"未找到订单:{notifyData.OutTradeNo}");
return new JsonResult(new { code = "SUCCESS", message = "成功" });
}
if (reg.PaymentStatus == 1)
{
_logger.LogInformation($"订单 {reg.OrderNo} 已处理过支付回调");
return new JsonResult(new { code = "SUCCESS", message = "成功" });
}
// 第二步:获取赛事信息并验证商户配置
var evt = await _eventRepository.GetFirstAsync(e => e.Id == reg.EventId);
if (evt == null)
{
_logger.LogError($"未找到赛事信息,订单号:{reg.OrderNo},赛事ID:{reg.EventId}");
return new JsonResult(new { code = "FAIL", message = "赛事信息不存在" });
}
// 验证赛事是否关联了有效的商户支付配置
var merchantPayConfig = await GetWeChatPayConfigByEventAsync(evt);
if (merchantPayConfig == null)
{
_logger.LogError("赛事未配置有效的商户支付配置,订单号={OrderNo},赛事ID={EventId}",
reg.OrderNo, evt.Id);
return new JsonResult(new { code = "FAIL", message = "商户支付配置异常" });
}
_logger.LogInformation("订单支付成功,订单号={OrderNo},赛事ID={EventId},商户ID={MerchantId}",
reg.OrderNo, evt.Id, evt.MerchantId);
// 更新支付状态
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}");
// 更新 Order 记录状态
var order = await _orderRepository.GetFirstAsync(o => o.OrderNo == reg.OrderNo);
if (order != null)
{
order.OrderStatus = OrderStatus.Paid;
order.PaymentTime = notifyData.SuccessTime?.DateTime;
order.UpdatedTime = DateTime.Now;
await _orderRepository.UpdateAsync(order);
_logger.LogInformation($"Order 记录 {order.OrderNo} 状态已更新为已支付");
}
return new JsonResult(new { code = "SUCCESS", message = "成功" });
}
catch (ArgumentException ex)
{
_logger.LogError(ex, "处理微信支付回调通知时参数验证失败");
return new JsonResult(new { code = "FAIL", message = "业务验证失败" });
}
catch (Exception ex)
{
_logger.LogError(ex, "处理微信支付回调通知异常");
return new JsonResult(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) return EventStatus.NotStarted; // 未开始
if (now >= e.RegistrationStartTime && now <= e.RegistrationEndTime) return EventStatus.Enrolling; // 报名中
if (now > e.RegistrationEndTime && now < e.MatchStartTime) return EventStatus.RegistrationEnded; // 报名结束
if (now >= e.MatchStartTime && now <= e.MatchEndTime) return EventStatus.Ongoing; // 比赛中
if (now > e.MatchEndTime) return EventStatus.Ended; // 已结束
return EventStatus.Enrolling; // 默认返回报名中
}
/// <summary>
/// 上传赛事素材
/// </summary>
/// <param name="input">上传素材输入参数</param>
/// <returns>操作是否成功</returns>
[HttpPost("upload-material")]
[DisplayName("上传赛事素材")]
public async Task<bool> UploadMaterial([FromBody] UploadMaterialInput input)
{
var userId = GetCurrentUserId();
var registration = await _registrationRepository.GetFirstAsync(r => r.Id == input.RegistrationId && !r.DeleteMark);
if (registration == null) throw Oops.Bah("报名记录不存在");
if (registration.CreatedUserId != userId) throw Oops.Bah("无权操作此报名记录");
if (registration.RegistrationStatus == 4) throw Oops.Bah("报名已取消,无法上传素材");
var evt = await _eventRepository.GetFirstAsync(e => e.Id == registration.EventId && !e.DeleteMark);
if (evt == null) throw Oops.Bah("赛事不存在");
if (evt.IsNeedMaterial == false) throw Oops.Bah("此赛事不需要上传素材");
if (evt.MaterialUploadStartTime.HasValue && DateTime.Now < evt.MaterialUploadStartTime.Value)
{
throw Oops.Bah("素材上传尚未开始");
}
if (evt.MaterialUploadEndTime.HasValue && DateTime.Now > evt.MaterialUploadEndTime.Value)
{
throw Oops.Bah("素材上传已结束");
}
registration.MaterialUrl = input.MaterialUrl;
registration.UpdatedTime = DateTime.Now;
registration.UpdatedUserId = userId;
return await _registrationRepository.UpdateAsync(registration);
}
}