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;
///
/// 优惠券发放、锁定、核销与释放。
///
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 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;
}
///
/// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。
///
public async Task 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 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 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> 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 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 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(),
TargetType = targetType,
TargetId = targetId,
BeforeJson = beforeJson,
AfterJson = afterJson,
Remark = remark
}, cancellationToken);
}
}