feat: add commerce order and payment checkout

This commit is contained in:
xiong
2026-07-26 19:34:00 +08:00
parent 5503d7a644
commit 494039973d
8 changed files with 1045 additions and 1 deletions

View File

@@ -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);
}
}

View File

@@ -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<CommerceOrderItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceOrderItem>> CreateOrder(
CreateCommerceOrderDto request,
CancellationToken cancellationToken)
{
return Ok(await commerceService.CreateOrderAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("orders")]
[EndpointSummary("查询当前用户订单")]
[ProducesResponseType<CommerceOrderList>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceOrderList>> Orders(
[FromQuery] CommerceOrderQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await commerceService.GetOrdersAsync(
ResolveActor(),
new CommerceOrderQuery(query.Limit, query.Status),
cancellationToken));
}
[HttpGet("orders/{orderNo}")]
[EndpointSummary("查询当前用户订单详情")]
[ProducesResponseType<CommerceOrderItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceOrderItem>> Order(
string orderNo,
CancellationToken cancellationToken)
{
return Ok(await commerceService.GetOrderAsync(
ResolveActor(),
orderNo,
cancellationToken));
}
[HttpPost("payments")]
[EndpointSummary("创建订单支付")]
[ProducesResponseType<CommercePaymentItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommercePaymentItem>> CreatePayment(
CreateCommercePaymentDto request,
CancellationToken cancellationToken)
{
return Ok(await commerceService.CreatePaymentAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("entitlements/current")]
[EndpointSummary("查询当前用户权益")]
[ProducesResponseType<CurrentEntitlementItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CurrentEntitlementItem>> 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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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<CommerceOrderItem> 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<CommerceOrderItem> CreateOrderAsync(
CommerceActor actor,
CreateCommerceOrderCommand command,
CancellationToken cancellationToken = default);
Task<CommerceOrderList> GetOrdersAsync(
CommerceActor actor,
CommerceOrderQuery query,
CancellationToken cancellationToken = default);
Task<CommerceOrderItem> GetOrderAsync(
CommerceActor actor,
string orderNo,
CancellationToken cancellationToken = default);
Task<CommercePaymentItem> CreatePaymentAsync(
CommerceActor actor,
CreateCommercePaymentCommand command,
CancellationToken cancellationToken = default);
Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
CommerceActor actor,
CancellationToken cancellationToken = default);
}
public sealed class CommerceException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -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<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, 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);
}
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<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);
}

View File

@@ -63,6 +63,7 @@ public static class DependencyInjection
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<ICommerceService, CommerceService>();
services.AddScoped<ITenantSecretService, TenantSecretService>();
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();

View File

@@ -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<Program>
IObjectStorageService? objectStorageService = null,
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
{
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);
}
});
}

View File

@@ -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<CommerceOrderItem>();
var listResponse = await client.GetAsync("/api/commerce/orders?limit=5");
var list = await listResponse.Content.ReadFromJsonAsync<CommerceOrderList>();
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<CommercePaymentItem>();
var entitlementResponse = await client.GetAsync("/api/commerce/entitlements/current");
var entitlement = await entitlementResponse.Content.ReadFromJsonAsync<CurrentEntitlementItem>();
var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}");
var paidOrder = await orderResponse.Content.ReadFromJsonAsync<CommerceOrderItem>();
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<CommercePaymentItem>();
var secondPayment = await second.Content.ReadFromJsonAsync<CommercePaymentItem>();
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.Equal(firstPayment!.Id, secondPayment!.Id);
}
private static async Task<CommerceOrderItem> 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<CommerceOrderItem>())!;
}
private static async Task<LoginSeed> 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<object>
{
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<Claim> 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<CreatePaymentProviderResult> 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<PaymentNotificationResult> ParsePaymentNotificationAsync(
string provider,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
}
}