Files
tiku-backend.net/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

421 lines
16 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Commerce;
public sealed partial class CommerceService
{
private async Task MarkPaidAsync(
CommerceActor actor,
Order order,
Payment payment,
string? providerTradeNo,
JsonElement rawPayload,
string eventType,
string? eventId,
bool signatureValid,
DateTimeOffset? paidAtOverride,
CancellationToken cancellationToken)
{
var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
payment.Status = PaymentStatus.Paid;
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
payment.PaidAt = paidAt;
order.Status = OrderStatus.Paid;
order.TradeNo = payment.ProviderTradeNo;
order.PaidAt = paidAt;
var days = Math.Max(order.Days ?? 0, 0);
var current = await dbContext.Entitlements
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.EntitlementType == "svip" &&
item.Status == EntitlementStatus.Active)
.OrderByDescending(item => item.ExpiresAt)
.FirstOrDefaultAsync(cancellationToken);
if (current is null)
{
dbContext.Entitlements.Add(new Entitlement
{
TenantId = actor.TenantId,
UserId = actor.UserId,
EntitlementType = "svip",
ScopeType = EntitlementScopeType.Tenant,
SourceType = "order",
SourceId = order.Id,
StartsAt = paidAt,
ExpiresAt = days > 0 ? paidAt.AddDays(days) : null,
Status = EntitlementStatus.Active,
Metadata = rawPayload
});
}
else if (days > 0)
{
var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt
? current.ExpiresAt.Value
: paidAt;
current.ExpiresAt = baseAt.AddDays(days);
current.Metadata = rawPayload;
}
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = actor.TenantId,
PaymentId = payment.Id,
Provider = payment.Provider,
EventType = eventType,
EventId = eventId,
SignatureValid = signatureValid,
Payload = rawPayload,
ProcessedAt = paidAt
});
}
private async Task<CouponApplication?> ApplyCouponForOrderAsync(
CommerceActor actor,
CreateCommerceOrderCommand command,
SvipPlan plan,
int originalAmountCents,
CancellationToken cancellationToken)
{
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) return null;
var coupon = command.CouponRedemptionId.HasValue
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
: await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken);
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
}
private async Task<CouponApplication> ResolveCouponForCheckAsync(
CommerceActor actor,
CheckCommerceCouponCommand command,
SvipPlan plan,
int originalAmountCents,
CancellationToken cancellationToken)
{
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
throw new CommerceException("Coupon code or redemption id is required.", "coupon_required");
var coupon = command.CouponRedemptionId.HasValue
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
: await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken);
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
}
private async Task<CouponApplication> ResolveOrClaimCouponByCodeAsync(
CommerceActor actor,
string? couponCode,
CancellationToken cancellationToken)
{
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
ValidateCouponClaimable(coupon, null);
var existing = await dbContext.CouponRedemptions
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.CouponId == coupon.Id)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null) return new CouponApplication(coupon, existing, 0);
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
var redemption = new CouponRedemption
{
TenantId = actor.TenantId,
CouponId = coupon.Id,
UserId = actor.UserId,
PlanId = coupon.PlanId,
CouponCode = coupon.Code,
Status = CouponRedemptionStatus.Claimed,
Source = "checkout_claim",
ClaimedAt = DateTimeOffset.UtcNow
};
coupon.UsedCount += 1;
dbContext.CouponRedemptions.Add(redemption);
return new CouponApplication(coupon, redemption, 0);
}
private async Task<CouponApplication> ResolveCouponByCodeForCheckAsync(
CommerceActor actor,
string? couponCode,
CancellationToken cancellationToken)
{
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
var redemption = await dbContext.CouponRedemptions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.CouponId == coupon.Id)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (redemption is not null) return new CouponApplication(coupon, redemption, 0);
ValidateCouponClaimable(coupon, null);
return new CouponApplication(
coupon,
new CouponRedemption
{
Id = Guid.Empty,
TenantId = actor.TenantId,
CouponId = coupon.Id,
UserId = actor.UserId,
PlanId = coupon.PlanId,
CouponCode = coupon.Code,
Status = CouponRedemptionStatus.Claimed
},
0);
}
private async Task<CouponApplication> ResolveCouponByRedemptionAsync(
CommerceActor actor,
Guid couponRedemptionId,
CancellationToken cancellationToken)
{
var redemption = await dbContext.CouponRedemptions
.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Id == couponRedemptionId,
cancellationToken)
?? throw new CommerceException("Coupon redemption was not found.",
"coupon_redemption_not_found");
if (redemption.CouponId is null)
throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid");
var coupon = await dbContext.Coupons
.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value,
cancellationToken)
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
return new CouponApplication(coupon, redemption, 0);
}
private async Task<Coupon> FindCouponByCodeAsync(
Guid tenantId,
string? couponCode,
CancellationToken cancellationToken)
{
var code = NormalizeRequired(couponCode, "coupon_code_required");
return await dbContext.Coupons
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
}
private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption)
{
var now = DateTimeOffset.UtcNow;
if ((coupon.ValidFrom is not null && coupon.ValidFrom > now) ||
(coupon.ValidTo is not null && coupon.ValidTo <= now))
throw new CommerceException("Coupon is expired or not started.", "coupon_inactive");
if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
}
private static void ValidateCouponUsable(
Coupon coupon,
CouponRedemption redemption,
SvipPlan plan,
Guid? regionId)
{
ValidateCouponClaimable(coupon, redemption);
if (redemption.Status != CouponRedemptionStatus.Claimed)
throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid");
if ((coupon.PlanId.HasValue && coupon.PlanId != plan.Id) ||
(redemption.PlanId.HasValue && redemption.PlanId != plan.Id))
throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable");
if (redemption.RegionId.HasValue &&
regionId.HasValue &&
redemption.RegionId != regionId)
throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable");
}
private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents)
{
var discount = coupon.DiscountType switch
{
DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero),
DiscountType.Percent => (int)Math.Round(
originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0),
MidpointRounding.AwayFromZero),
_ => 0
};
return Math.Clamp(discount, 0, originalAmountCents);
}
private static decimal PercentFactor(decimal value)
{
if (value <= 0) return 0;
return value <= 1 ? value : value / 100;
}
private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken)
{
var exists = await dbContext.TenantMemberships.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active,
cancellationToken);
if (!exists) throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied");
}
private async Task<Order> FindActorOrderAsync(
CommerceActor actor,
string orderNo,
CancellationToken cancellationToken)
{
var trimmed = orderNo.Trim();
return await dbContext.Orders
.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.OrderNo == trimmed,
cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
}
private static CommerceOrderItem ToOrderItem(Order order)
{
return new CommerceOrderItem(
order.Id,
order.OrderNo,
order.Status.ToString(),
order.PlanId,
order.RegionId,
order.ProductType,
order.ProductName,
order.AmountCents,
FormatCny(order.AmountCents),
order.PayMethod,
order.PayProvider,
order.TradeNo,
order.Days,
order.PaidAt,
order.CreatedAt,
order.RawPayload);
}
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload)
{
return new CommercePaymentItem(
payment.Id,
payment.OrderId,
orderNo,
payment.Provider,
payment.Method,
payment.Status.ToString(),
payment.AmountCents,
FormatCny(payment.AmountCents),
payment.ProviderTradeNo,
payment.PaidAt,
clientPayload,
payment.RawPayload);
}
private static CommerceCouponItem ToCouponItem(
Coupon? coupon,
CouponRedemption redemption,
int? discountPreviewCents)
{
return new CommerceCouponItem(
redemption.Id,
redemption.CouponId,
redemption.CouponCode ?? coupon?.Code ?? string.Empty,
redemption.Status.ToString(),
redemption.PlanId ?? coupon?.PlanId,
redemption.RegionId,
coupon?.DiscountType?.ToString(),
coupon?.DiscountValue,
discountPreviewCents,
discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null,
coupon?.ValidFrom,
coupon?.ValidTo,
redemption.ClaimedAt,
redemption.UsedAt);
}
private static OrderStatus ParseOrderStatus(string? status)
{
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
}
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
{
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status");
}
private static string NormalizeEnum(string? value)
{
return string.Concat((value ?? string.Empty).Split(
['_', '-', ' '],
StringSplitOptions.RemoveEmptyEntries));
}
private static string NormalizeRequired(string? value, string code)
{
var trimmed = value?.Trim();
return !string.IsNullOrWhiteSpace(trimmed)
? trimmed
: throw new CommerceException("Required commerce value is missing.", code);
}
private static string NormalizeProvider(string? provider)
{
var normalized = (provider ?? PaymentProviders.Manual)
.Trim()
.ToLowerInvariant()
.Replace("-", "_", StringComparison.Ordinal);
return normalized switch
{
"" => PaymentProviders.Manual,
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
"ali_pay" => PaymentProviders.Alipay,
PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized,
_ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider")
};
}
private static string NormalizeMethod(string? method)
{
var normalized = (method ?? "manual").Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized;
}
private static bool IsPaid(string status)
{
return string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) ||
string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) ||
string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateOrderNo()
{
Span<byte> bytes = stackalloc byte[4];
RandomNumberGenerator.Fill(bytes);
return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}";
}
private static string FormatCny(int cents)
{
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}
}