Files
tiku-backend.net/Tiku.Infrastructure/Commerce/CommerceService.cs

561 lines
20 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.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
public sealed class CommerceService(
TikuDbContext dbContext,
IPaymentProviderGateway paymentGateway) : ICommerceService
{
public async Task<CommerceOrderItem> CreateOrderAsync(
CommerceActor actor,
CreateCommerceOrderCommand command,
CancellationToken cancellationToken = default)
{
if (command.Quantity is < 1 or > 99)
{
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
}
await AssertActiveMemberAsync(actor, cancellationToken);
var plan = await dbContext.SvipPlans
.AsNoTracking()
.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.Id == command.PlanId &&
item.IsActive,
cancellationToken)
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
if (plan.CouponOnly && string.IsNullOrWhiteSpace(command.CouponCode))
{
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
}
if (command.RegionId.HasValue)
{
var regionExists = await dbContext.Regions
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
if (!regionExists)
{
throw new CommerceException("Region was not found.", "region_not_found");
}
}
var order = new Order
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PlanId = plan.Id,
RegionId = command.RegionId ?? plan.RegionId,
OrderNo = GenerateOrderNo(),
Status = OrderStatus.Pending,
ProductType = "svip",
ProductName = plan.Name,
AmountCents = checked(plan.PriceCents * command.Quantity),
PayMethod = NormalizeMethod(command.PayMethod),
PayProvider = NormalizeProvider(command.PayProvider),
Days = checked(plan.Days * command.Quantity),
RawPayload = JsonSerializer.SerializeToElement(new
{
source = "student_checkout",
command.Quantity,
command.CouponCode,
plan.PriceCents,
plan.OriginalPriceCents
})
};
dbContext.Orders.Add(order);
dbContext.OrderItems.Add(new OrderItem
{
TenantId = actor.TenantId,
OrderId = order.Id,
ItemType = "svip_plan",
ItemId = plan.Id,
Name = plan.Name,
Quantity = command.Quantity,
UnitAmountCents = plan.PriceCents,
TotalAmountCents = order.AmountCents,
Metadata = JsonSerializer.SerializeToElement(new
{
plan.Days,
plan.RegionId,
plan.VpProductId
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToOrderItem(order);
}
public async Task<CommerceOrderList> GetOrdersAsync(
CommerceActor actor,
CommerceOrderQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var orders = dbContext.Orders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
}
var items = await orders
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 20, 1, 100))
.ToArrayAsync(cancellationToken);
return new CommerceOrderList(items.Select(ToOrderItem).ToArray());
}
public async Task<CommerceOrderItem> GetOrderAsync(
CommerceActor actor,
string orderNo,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var order = await FindActorOrderAsync(actor, orderNo, cancellationToken);
return ToOrderItem(order);
}
public async Task<CommercePaymentItem> CreatePaymentAsync(
CommerceActor actor,
CreateCommercePaymentCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken);
if (order.Status != OrderStatus.Pending)
{
throw new CommerceException("Only pending orders can create payments.", "order_status_invalid");
}
var provider = NormalizeProvider(command.Provider);
var method = NormalizeMethod(command.Method);
var payment = await dbContext.Payments
.Where(item =>
item.TenantId == actor.TenantId &&
item.OrderId == order.Id &&
item.Provider == provider &&
item.Status == PaymentStatus.Pending)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (payment is null)
{
payment = new Payment
{
TenantId = actor.TenantId,
OrderId = order.Id,
Provider = provider,
Method = method,
Status = PaymentStatus.Pending,
AmountCents = order.AmountCents
};
dbContext.Payments.Add(payment);
}
var result = await paymentGateway.CreatePaymentAsync(
provider,
new CreatePaymentProviderRequest(
actor.TenantId,
order.OrderNo,
order.ProductName ?? order.OrderNo,
order.AmountCents,
method,
command.OpenId,
command.ReturnUrl,
command.QuitUrl,
$"/api/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}",
JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })),
cancellationToken);
payment.Method = result.Method;
payment.RawPayload = result.RawPayload;
if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo))
{
payment.ProviderTradeNo = result.ProviderTradeNo;
}
if (IsPaid(result.Status))
{
await MarkPaidAsync(
actor,
order,
payment,
result.ProviderTradeNo,
result.RawPayload,
"payment_paid",
result.ProviderTradeNo,
true,
null,
cancellationToken);
}
else
{
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = actor.TenantId,
PaymentId = payment.Id,
Provider = provider,
EventType = "payment_created",
Payload = result.RawPayload
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToPaymentItem(payment, order.OrderNo, result.ClientPayload);
}
public async Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
CommerceActor actor,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var now = DateTimeOffset.UtcNow;
var entitlement = await dbContext.Entitlements
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.EntitlementType == "svip" &&
item.Status == EntitlementStatus.Active &&
(item.ExpiresAt == null || item.ExpiresAt > now))
.OrderByDescending(item => item.ExpiresAt)
.FirstOrDefaultAsync(cancellationToken);
if (entitlement is null)
{
return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null);
}
return new CurrentEntitlementItem(
true,
entitlement.EntitlementType,
entitlement.StartsAt,
entitlement.ExpiresAt,
entitlement.Status.ToString(),
entitlement.ExpiresAt.HasValue
? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays))
: null);
}
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
Guid tenantId,
string provider,
IReadOnlyDictionary<string, string> headers,
string rawBody,
JsonElement body,
CancellationToken cancellationToken = default)
{
var normalizedProvider = NormalizeProvider(provider);
var notification = await paymentGateway.ParsePaymentNotificationAsync(
normalizedProvider,
new PaymentNotificationRequest(
tenantId,
normalizedProvider,
headers,
rawBody,
body),
cancellationToken);
if (!notification.SignatureValid)
{
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
}
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
item =>
item.Provider == normalizedProvider &&
item.EventId == notification.EventId &&
item.ProcessedAt != null,
cancellationToken);
if (alreadyProcessed)
{
return new PaymentNotificationProcessResult(
normalizedProvider,
notification.EventId,
notification.OrderNo,
"processed",
true);
}
var order = await dbContext.Orders
.SingleOrDefaultAsync(item =>
item.TenantId == tenantId &&
item.OrderNo == notification.OrderNo,
cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.UserId is null)
{
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
}
if (order.AmountCents != notification.AmountCents)
{
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = tenantId,
Provider = normalizedProvider,
EventType = notification.EventType,
EventId = notification.EventId,
SignatureValid = true,
Payload = notification.RawPayload,
Error = "payment_amount_mismatch"
});
await dbContext.SaveChangesAsync(cancellationToken);
throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch");
}
var payment = await dbContext.Payments
.Where(item =>
item.TenantId == tenantId &&
item.OrderId == order.Id &&
item.Provider == normalizedProvider)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (payment is null)
{
payment = new Payment
{
TenantId = tenantId,
OrderId = order.Id,
Provider = normalizedProvider,
Method = order.PayMethod,
Status = PaymentStatus.Pending,
AmountCents = order.AmountCents
};
dbContext.Payments.Add(payment);
}
if (notification.Paid && order.Status == OrderStatus.Pending)
{
await MarkPaidAsync(
new CommerceActor(tenantId, order.UserId.Value),
order,
payment,
notification.ProviderTradeNo,
notification.RawPayload,
notification.EventType,
notification.EventId,
notification.SignatureValid,
notification.PaidAt,
cancellationToken);
}
else
{
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = tenantId,
PaymentId = payment.Id,
Provider = normalizedProvider,
EventType = notification.EventType,
EventId = notification.EventId,
SignatureValid = notification.SignatureValid,
Payload = notification.RawPayload,
ProcessedAt = DateTimeOffset.UtcNow
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return new PaymentNotificationProcessResult(
normalizedProvider,
notification.EventId,
notification.OrderNo,
"processed",
false);
}
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 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 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 string NormalizeEnum(string? value) =>
string.Concat((value ?? string.Empty).Split(
['_', '-', ' '],
StringSplitOptions.RemoveEmptyEntries));
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) =>
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) =>
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}