diff --git a/Tiku.Api/Contracts/CommerceDtos.cs b/Tiku.Api/Contracts/CommerceDtos.cs new file mode 100644 index 0000000..07aa865 --- /dev/null +++ b/Tiku.Api/Contracts/CommerceDtos.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using Tiku.Application.Commerce; + +namespace Tiku.Api.Contracts; + +public sealed class CreateCommerceOrderDto +{ + [Required] + public Guid PlanId { get; set; } + + [Range(1, 99)] + public int Quantity { get; set; } = 1; + + [StringLength(50)] + public string? PayMethod { get; set; } + + [StringLength(50)] + public string? PayProvider { get; set; } + + public Guid? RegionId { get; set; } + + [StringLength(100)] + public string? CouponCode { get; set; } + + public CreateCommerceOrderCommand ToCommand() + { + return new CreateCommerceOrderCommand( + PlanId, + Quantity, + PayMethod, + PayProvider, + RegionId, + CouponCode); + } +} + +public sealed class CommerceOrderQueryDto +{ + [Range(1, 100)] + public int? Limit { get; set; } + + [StringLength(32)] + public string? Status { get; set; } +} + +public sealed class CreateCommercePaymentDto +{ + [Required] + [StringLength(100)] + public string OrderNo { get; set; } = string.Empty; + + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + [Required] + [StringLength(50)] + public string Method { get; set; } = string.Empty; + + [StringLength(255)] + public string? OpenId { get; set; } + + [StringLength(2048)] + public string? ReturnUrl { get; set; } + + [StringLength(2048)] + public string? QuitUrl { get; set; } + + public CreateCommercePaymentCommand ToCommand() + { + return new CreateCommercePaymentCommand( + OrderNo, + Provider, + Method, + OpenId, + ReturnUrl, + QuitUrl); + } +} diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs new file mode 100644 index 0000000..bc178bf --- /dev/null +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Commerce; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[Produces("application/json")] +[Route("api/commerce")] +public sealed class CommerceController( + ICommerceService commerceService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpPost("orders")] + [EndpointSummary("创建学生端订单")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateOrder( + CreateCommerceOrderDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceService.CreateOrderAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("orders")] + [EndpointSummary("查询当前用户订单")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Orders( + [FromQuery] CommerceOrderQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceService.GetOrdersAsync( + ResolveActor(), + new CommerceOrderQuery(query.Limit, query.Status), + cancellationToken)); + } + + [HttpGet("orders/{orderNo}")] + [EndpointSummary("查询当前用户订单详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Order( + string orderNo, + CancellationToken cancellationToken) + { + return Ok(await commerceService.GetOrderAsync( + ResolveActor(), + orderNo, + cancellationToken)); + } + + [HttpPost("payments")] + [EndpointSummary("创建订单支付")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreatePayment( + CreateCommercePaymentDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceService.CreatePaymentAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("entitlements/current")] + [EndpointSummary("查询当前用户权益")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CurrentEntitlement( + CancellationToken cancellationToken) + { + return Ok(await commerceService.GetCurrentEntitlementAsync( + ResolveActor(), + cancellationToken)); + } + + private CommerceActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new CommerceException("Current commerce actor was not resolved.", "commerce_access_denied"); + } + + return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 865197d..9d30b76 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Controllers; using Tiku.Application.Assets; using Tiku.Application.Auth; +using Tiku.Application.Commerce; using Tiku.Application.Content; using Tiku.Application.Storage; using Tiku.Infrastructure.Content; @@ -172,6 +173,26 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is CommerceException commerceException) + { + await WriteProblemAsync( + context, + commerceException.Message, + CommerceStatusCode(commerceException.Code), + commerceException.Code); + return; + } + + if (exception is PaymentProviderException paymentProviderException) + { + await WriteProblemAsync( + context, + paymentProviderException.Message, + CommerceStatusCode(paymentProviderException.Code), + paymentProviderException.Code); + return; + } + if (exception is ObjectStorageException storageException) { await WriteProblemAsync( @@ -308,4 +329,16 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } + + private static int CommerceStatusCode(string code) + { + return code switch + { + "commerce_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + "order_not_found" or "svip_plan_not_found" or "region_not_found" => StatusCodes.Status404NotFound, + "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, + "order_status_invalid" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Commerce/CommerceModels.cs b/Tiku.Application/Commerce/CommerceModels.cs new file mode 100644 index 0000000..7a11563 --- /dev/null +++ b/Tiku.Application/Commerce/CommerceModels.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace Tiku.Application.Commerce; + +public sealed record CommerceActor(Guid TenantId, Guid UserId); + +public sealed record CreateCommerceOrderCommand( + Guid PlanId, + int Quantity, + string? PayMethod, + string? PayProvider, + Guid? RegionId, + string? CouponCode); + +public sealed record CommerceOrderQuery(int? Limit = null, string? Status = null); + +public sealed record CreateCommercePaymentCommand( + string OrderNo, + string Provider, + string Method, + string? OpenId, + string? ReturnUrl, + string? QuitUrl); + +public sealed record CommerceOrderItem( + Guid Id, + string OrderNo, + string Status, + Guid? PlanId, + Guid? RegionId, + string? ProductType, + string? ProductName, + int AmountCents, + string Price, + string? PayMethod, + string? PayProvider, + string? TradeNo, + int? Days, + DateTimeOffset? PaidAt, + DateTimeOffset CreatedAt, + JsonElement RawPayload); + +public sealed record CommerceOrderList(IReadOnlyCollection Items); + +public sealed record CommercePaymentItem( + Guid Id, + Guid OrderId, + string OrderNo, + string Provider, + string? Method, + string Status, + int AmountCents, + string Price, + string? ProviderTradeNo, + DateTimeOffset? PaidAt, + JsonElement ClientPayload, + JsonElement RawPayload); + +public sealed record CurrentEntitlementItem( + bool IsActive, + string EntitlementType, + DateTimeOffset? StartsAt, + DateTimeOffset? ExpiresAt, + string Status, + int? DaysLeft); + +public interface ICommerceService +{ + Task CreateOrderAsync( + CommerceActor actor, + CreateCommerceOrderCommand command, + CancellationToken cancellationToken = default); + + Task GetOrdersAsync( + CommerceActor actor, + CommerceOrderQuery query, + CancellationToken cancellationToken = default); + + Task GetOrderAsync( + CommerceActor actor, + string orderNo, + CancellationToken cancellationToken = default); + + Task CreatePaymentAsync( + CommerceActor actor, + CreateCommercePaymentCommand command, + CancellationToken cancellationToken = default); + + Task GetCurrentEntitlementAsync( + CommerceActor actor, + CancellationToken cancellationToken = default); +} + +public sealed class CommerceException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs new file mode 100644 index 0000000..e99ce93 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/CommerceService.cs @@ -0,0 +1,419 @@ +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 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 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 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 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, 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 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); + } + + private async Task MarkPaidAsync( + CommerceActor actor, + Order order, + Payment payment, + string? providerTradeNo, + JsonElement rawPayload, + CancellationToken cancellationToken) + { + var paidAt = 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 = "payment_paid", + EventId = payment.ProviderTradeNo, + SignatureValid = true, + 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 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(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 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); +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index ca6b278..9fc55c5 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -63,6 +63,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 850be9e..2fbba9f 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; +using Tiku.Application.Commerce; using Tiku.Application.Auth; using Tiku.Application.Storage; using Tiku.Domain.Identity; @@ -12,7 +13,8 @@ namespace Tiku.IntegrationTests.Api; public sealed class ApiTestFactory( IWechatOAuthClient? wechatOAuthClient = null, - IObjectStorageService? objectStorageService = null) : WebApplicationFactory + IObjectStorageService? objectStorageService = null, + IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory { private readonly string databaseName = Guid.NewGuid().ToString(); @@ -42,6 +44,11 @@ public sealed class ApiTestFactory( { services.AddSingleton(objectStorageService); } + + if (paymentProviderGateway is not null) + { + services.AddSingleton(paymentProviderGateway); + } }); } diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs new file mode 100644 index 0000000..e5f3321 --- /dev/null +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -0,0 +1,318 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Tiku.Api.Contracts; +using Tiku.Api.Options; +using Tiku.Application.Auth; +using Tiku.Application.Commerce; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class CommerceEndpointTests +{ + private static readonly JwtOptions JwtOptions = new() + { + Issuer = "tiku-backend", + Audience = "tiku-api", + SigningKey = "development-only-tiku-signing-key-change-before-production" + }; + + [Fact] + public async Task Anonymous_commerce_request_returns_401() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/commerce/orders"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Non_member_cannot_create_order() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId); + var planId = Guid.NewGuid(); + await factory.SeedAsync( + new SvipPlan + { + Id = planId, + TenantId = tenantId, + Name = "月卡", + PriceCents = 999, + Days = 39, + IsActive = true + }); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + CreateToken([ + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()), + new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + ])); + + var response = await client.PostAsJsonAsync( + "/api/commerce/orders", + new CreateCommerceOrderDto + { + PlanId = planId, + Quantity = 1 + }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Student_can_create_and_query_svip_order() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var createResponse = await client.PostAsJsonAsync( + "/api/commerce/orders", + new CreateCommerceOrderDto + { + PlanId = seed.PlanId, + Quantity = 2, + PayMethod = "manual", + PayProvider = "manual" + }); + var created = await createResponse.Content.ReadFromJsonAsync(); + var listResponse = await client.GetAsync("/api/commerce/orders?limit=5"); + var list = await listResponse.Content.ReadFromJsonAsync(); + var detailResponse = await client.GetAsync($"/api/commerce/orders/{created!.OrderNo}"); + + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.Equal(1998, created.AmountCents); + Assert.Equal(78, created.Days); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + Assert.Contains(list!.Items, item => item.OrderNo == created.OrderNo); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + } + + [Fact] + public async Task Paid_provider_result_marks_order_paid_and_grants_entitlement() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway("paid")); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var order = await CreateOrderAsync(client, seed.PlanId); + + var paymentResponse = await client.PostAsJsonAsync( + "/api/commerce/payments", + new CreateCommercePaymentDto + { + OrderNo = order.OrderNo, + Provider = "manual", + Method = "manual" + }); + var payment = await paymentResponse.Content.ReadFromJsonAsync(); + var entitlementResponse = await client.GetAsync("/api/commerce/entitlements/current"); + var entitlement = await entitlementResponse.Content.ReadFromJsonAsync(); + var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}"); + var paidOrder = await orderResponse.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode); + Assert.Equal("Paid", payment!.Status); + Assert.Equal("Paid", paidOrder!.Status); + Assert.Equal(HttpStatusCode.OK, entitlementResponse.StatusCode); + Assert.True(entitlement!.IsActive); + Assert.Equal("svip", entitlement.EntitlementType); + } + + [Fact] + public async Task Pending_payment_can_be_requested_idempotently() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var order = await CreateOrderAsync(client, seed.PlanId); + var request = new CreateCommercePaymentDto + { + OrderNo = order.OrderNo, + Provider = "manual", + Method = "manual" + }; + + var first = await client.PostAsJsonAsync("/api/commerce/payments", request); + var second = await client.PostAsJsonAsync("/api/commerce/payments", request); + var firstPayment = await first.Content.ReadFromJsonAsync(); + var secondPayment = await second.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(firstPayment!.Id, secondPayment!.Id); + } + + private static async Task CreateOrderAsync(HttpClient client, Guid planId) + { + var response = await client.PostAsJsonAsync( + "/api/commerce/orders", + new CreateCommerceOrderDto + { + PlanId = planId, + Quantity = 1, + PayMethod = "manual", + PayProvider = "manual" + }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private static async Task SeedLoginUserAsync( + ApiTestFactory factory, + bool includeMembership = true) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var planId = Guid.NewGuid(); + var phone = "13800000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + var entities = new List + { + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Commerce Tenant" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Commerce User" + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }, + new SvipPlan + { + Id = planId, + TenantId = tenantId, + Name = "月卡", + PriceCents = 999, + Days = 39, + IsActive = true + }, + new TenantPaymentAccount + { + TenantId = tenantId, + Provider = "manual", + Status = TenantPaymentAccountStatus.Active + } + }; + if (includeMembership) + { + entities.Add(new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + } + + await factory.SeedAsync(entities.ToArray()); + return new LoginSeed(tenantId, userId, planId, phone); + } + + private static async Task LoginAsync(HttpClient client, LoginSeed seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + loginResponse.EnsureSuccessStatusCode(); + using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } + + private static string CreateToken(IEnumerable claims) + { + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + JwtOptions.Issuer, + JwtOptions.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string Phone); + + private sealed class FakePaymentGateway(string status = "pending") : IPaymentProviderGateway + { + public Task CreatePaymentAsync( + string provider, + CreatePaymentProviderRequest request, + CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.SerializeToElement(new + { + request.OrderNo, + request.AmountCents, + status + }); + return Task.FromResult(new CreatePaymentProviderResult( + provider, + request.Method, + status, + status == "paid" ? $"trade-{request.OrderNo}" : null, + payload, + payload)); + } + + public Task ParsePaymentNotificationAsync( + string provider, + PaymentNotificationRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } +}