admin/src/Oms.Application/Shared/MarketingCouponManager.cs
zzh 5003cacd79 fix: repair marketing compile errors and harden deploy CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:51:13 +08:00

354 lines
13 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 Fake.DependencyInjection;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.Security.Claims;
using Fake.Users;
using Oms.Application.Dtos.Marketing;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Consts;
using Oms.Domain.MarketingAggregate.Entities;
using Oms.Domain.MarketingAggregate.Enums;
using Oms.Domain.MarketingAggregate.Repositories;
using System.Text.Json;
namespace Oms.Application.Shared;
/// <summary>
/// 优惠券发放、锁定、核销与释放。
/// </summary>
public class MarketingCouponManager : IScopedDependency
{
private const int LockMinutes = 30;
private readonly ICouponTemplateRepository _templateRepository;
private readonly ICouponInstanceRepository _instanceRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IMarketingAuditLogRepository _auditLogRepository;
private readonly ICurrentUser _currentUser;
public MarketingCouponManager(
ICouponTemplateRepository templateRepository,
ICouponInstanceRepository instanceRepository,
ICustomerRepository customerRepository,
IMarketingAuditLogRepository auditLogRepository,
ICurrentUser currentUser)
{
_templateRepository = templateRepository;
_instanceRepository = instanceRepository;
_customerRepository = customerRepository;
_auditLogRepository = auditLogRepository;
_currentUser = currentUser;
}
public async Task<CouponInstance> GrantAsync(int templateId, int mainCustomerId, string? remark = null, CancellationToken cancellationToken = default)
{
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == templateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
if (template.Status != CouponTemplateStatus.Active)
{
throw new BusinessException("券模板未发布,无法发放");
}
if (template.TotalQuantity > 0 && template.IssuedQuantity >= template.TotalQuantity)
{
throw new BusinessException("券模板库存不足");
}
var count = await _instanceRepository.CountByTemplateAndCustomerAsync(templateId, mainCustomerId, cancellationToken);
if (count >= template.PerCustomerLimit)
{
throw new BusinessException("该客户已达领取上限");
}
var (validStart, validEnd) = ResolveValidRange(template);
var instance = new CouponInstance
{
TemplateId = templateId,
CustomerId = mainCustomerId,
CouponCode = await GenerateCouponCodeAsync(cancellationToken),
Status = CouponInstanceStatus.Available,
ReceiveTime = DateTime.Now,
ValidStartTime = validStart,
ValidEndTime = validEnd
};
await _instanceRepository.InsertAsync(instance, cancellationToken);
template.IssuedQuantity += 1;
await _templateRepository.UpdateAsync(template, cancellationToken);
await WriteAuditAsync(MarketingAuditActions.GrantCoupon, "customer", mainCustomerId, null,
JsonSerializer.Serialize(new { templateId, instance.Id }), remark, cancellationToken);
return instance;
}
/// <summary>
/// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。
/// </summary>
public async Task<int> GrantNewcomerCouponsAsync(int mainCustomerId, CancellationToken cancellationToken = default)
{
if (mainCustomerId <= 0)
{
return 0;
}
var templates = await _templateRepository.GetListAsync(
x => x.Status == CouponTemplateStatus.Active
&& x.GrantChannel == MarketingGrantChannels.Newcomer);
if (templates.Count == 0)
{
return 0;
}
var granted = 0;
foreach (var template in templates.OrderBy(x => x.Id))
{
try
{
await GrantAsync(template.Id, mainCustomerId, "newcomer_register", cancellationToken);
granted++;
}
catch (BusinessException)
{
// 库存不足、已达上限等跳过
}
}
return granted;
}
public async Task LockForOrderAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
EnsureUsable(instance);
instance.Status = CouponInstanceStatus.Locked;
instance.OrderId = orderId;
instance.LockExpireTime = DateTime.Now.AddMinutes(LockMinutes);
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
instance.Status = CouponInstanceStatus.Used;
instance.OrderId = orderId;
instance.UseTime = DateTime.Now;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked)
{
return;
}
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
public async Task ReleaseExpiredLocksAsync(int batchSize, CancellationToken cancellationToken = default)
{
var expired = await _instanceRepository.GetExpiredLocksAsync(DateTime.Now, batchSize, cancellationToken);
foreach (var instance in expired)
{
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
}
public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync(
int? couponInstanceId,
int mainCustomerId,
CancellationToken cancellationToken = default)
{
if (!couponInstanceId.HasValue)
{
return null;
}
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
EnsureUsable(instance);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
return new CouponDiscountInput
{
CouponInstanceId = instance.Id,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount
};
}
public async Task<int> ResolveMainCustomerIdAsync(int customerId, CancellationToken cancellationToken = default)
{
var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken)
?? throw new BusinessException("客户不存在");
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
}
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
int mainCustomerId,
decimal? amountAfterLevel,
CancellationToken cancellationToken = default)
{
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
if (instances.Count == 0)
{
return [];
}
var templateIds = instances.Select(x => x.TemplateId).Distinct().ToArray();
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
var templateMap = templates.ToDictionary(x => x.Id);
return instances.Select(instance =>
{
templateMap.TryGetValue(instance.TemplateId, out var template);
template ??= new CouponTemplate();
var canUse = true;
string? unavailableReason = null;
decimal? estimatedDiscount = null;
if (amountAfterLevel.HasValue)
{
if (amountAfterLevel.Value < template.MinOrderAmount)
{
canUse = false;
unavailableReason = $"未满 {template.MinOrderAmount:0.##} 元门槛";
}
else
{
var discountInput = new CouponDiscountInput
{
CouponInstanceId = instance.Id,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount
};
estimatedDiscount = MarketingBillingCalculator.CalculateCouponDiscount(
discountInput,
amountAfterLevel.Value);
if (estimatedDiscount <= 0)
{
canUse = false;
unavailableReason = "当前金额不可用";
}
}
}
return new CouponAvailableOutput
{
CouponInstanceId = instance.Id,
CouponCode = instance.CouponCode,
TemplateName = template.TemplateName,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount,
ValidEndTime = instance.ValidEndTime,
CanUse = canUse,
UnavailableReason = unavailableReason,
EstimatedDiscount = estimatedDiscount
};
}).ToList();
}
private async Task<CouponInstance> GetOwnedInstanceAsync(int id, int mainCustomerId, CancellationToken cancellationToken)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
?? throw new BusinessException("优惠券不存在");
if (instance.CustomerId != mainCustomerId)
{
throw new BusinessException("优惠券不属于当前客户");
}
return instance;
}
private static void EnsureUsable(CouponInstance instance)
{
var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.Used)
{
throw new BusinessException("优惠券已使用");
}
if (instance.Status == CouponInstanceStatus.Expired || instance.ValidEndTime < now)
{
throw new BusinessException("优惠券已过期");
}
if (instance.ValidStartTime > now)
{
throw new BusinessException("优惠券尚未生效");
}
if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue
&& instance.LockExpireTime.Value >= now)
{
throw new BusinessException("优惠券已被其他订单占用");
}
}
private static (DateTime Start, DateTime End) ResolveValidRange(CouponTemplate template)
{
if (template.ValidType == CouponValidType.DaysAfterReceive && template.ValidDaysAfterReceive.HasValue)
{
var start = DateTime.Now;
return (start, start.AddDays(template.ValidDaysAfterReceive.Value));
}
return (template.ValidStartTime ?? DateTime.Now, template.ValidEndTime ?? DateTime.Now.AddDays(30));
}
private async Task<string> GenerateCouponCodeAsync(CancellationToken cancellationToken)
{
for (var i = 0; i < 5; i++)
{
var code = $"CPN{DateTime.Now:yyyyMMdd}{Random.Shared.Next(100000, 999999)}";
if (!await _instanceRepository.AnyAsync(x => x.CouponCode == code, cancellationToken))
{
return code;
}
}
throw new BusinessException("券码生成失败");
}
private async Task WriteAuditAsync(
string action,
string targetType,
int? targetId,
string? beforeJson,
string? afterJson,
string? remark,
CancellationToken cancellationToken)
{
await _auditLogRepository.InsertAsync(new MarketingAuditLog
{
Action = action,
OperatorId = _currentUser.GetId<int>(),
TargetType = targetType,
TargetId = targetId,
BeforeJson = beforeJson,
AfterJson = afterJson,
Remark = remark
}, cancellationToken);
}
}