diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs new file mode 100644 index 0000000..cc7dbeb --- /dev/null +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -0,0 +1,136 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; + +namespace Tiku.Api.Contracts; + +public sealed class TenantCommerceQueryDto +{ + [StringLength(50)] + public string? Provider { get; set; } + + [StringLength(32)] + public string? Status { get; set; } + + [Range(1, 200)] + public int? Limit { get; set; } +} + +public sealed class UpsertPaymentAccountDto +{ + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + public TenantPaymentMode Mode { get; set; } = TenantPaymentMode.TenantCollect; + + [StringLength(200)] + public string? DisplayName { get; set; } + + public TenantPaymentAccountStatus Status { get; set; } = TenantPaymentAccountStatus.Disabled; + + public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + + public UpsertPaymentAccountCommand ToCommand() + { + return new UpsertPaymentAccountCommand(Provider, Mode, DisplayName, Status, ConfigPublic); + } +} + +public sealed class UpsertTenantSecretDto +{ + [Required] + [StringLength(80)] + public string Purpose { get; set; } = "payment"; + + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + [Required] + [StringLength(120)] + public string SecretKey { get; set; } = string.Empty; + + [StringLength(300)] + public string SecretRef { get; set; } = string.Empty; + + public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active; + public JsonElement SecretPayload { get; set; } = JsonDefaults.Object(); + public DateTimeOffset? ExpiresAt { get; set; } + + public UpsertTenantSecretCommand ToCommand() + { + return new UpsertTenantSecretCommand( + Purpose, + Provider, + SecretKey, + SecretRef, + Status, + SecretPayload, + ExpiresAt); + } +} + +public sealed class CreateCodeBatchDto +{ + [Required] + [StringLength(200)] + public string Name { get; set; } = string.Empty; + + [Range(1, 1000)] + public int TotalCount { get; set; } + + [Range(1, 3650)] + public int Days { get; set; } + + public Guid? RegionId { get; set; } + + [StringLength(50)] + public string? SaleType { get; set; } + + [StringLength(100)] + public string? Channel { get; set; } + + [Range(0, int.MaxValue)] + public int? DefaultUnitPriceCents { get; set; } + + [Range(0, int.MaxValue)] + public int? CostPriceCents { get; set; } + + [StringLength(1000)] + public string? Remark { get; set; } + + public CreateCodeBatchCommand ToCommand() + { + return new CreateCodeBatchCommand( + Name, + TotalCount, + Days, + RegionId, + SaleType, + Channel, + DefaultUnitPriceCents, + CostPriceCents, + Remark); + } +} + +public sealed class RedeemActivationCodeDto +{ + [Required] + [StringLength(100)] + public string Code { get; set; } = string.Empty; + + [Required] + public Guid UserId { get; set; } + + public Guid? RegionId { get; set; } + + public RedeemActivationCodeCommand ToCommand() + { + return new RedeemActivationCodeCommand(Code, UserId, RegionId); + } +} diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs new file mode 100644 index 0000000..13eb82a --- /dev/null +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -0,0 +1,136 @@ +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.TenantAdmin)] +[Produces("application/json")] +[Route("api/tenant-commerce")] +public sealed class TenantCommerceController( + ICommerceAdminService commerceAdminService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpGet("payment-accounts")] + [EndpointSummary("查询租户支付账号")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> PaymentAccounts( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPaymentAccountsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPut("payment-accounts")] + [EndpointSummary("新增或更新租户支付账号")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertPaymentAccount( + UpsertPaymentAccountDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpsertPaymentAccountAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPut("secrets")] + [EndpointSummary("写入或轮换租户密钥")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertSecret( + UpsertTenantSecretDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpsertTenantSecretAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("orders")] + [EndpointSummary("查询租户订单")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Orders( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetOrdersAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpGet("payments")] + [EndpointSummary("查询租户支付记录")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Payments( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPaymentsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("code-batches")] + [EndpointSummary("创建兑换码批次")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateCodeBatch( + CreateCodeBatchDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.CreateCodeBatchAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("activation-codes")] + [EndpointSummary("查询兑换码")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ActivationCodes( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetActivationCodesAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("activation-codes/redeem")] + [EndpointSummary("后台核销兑换码")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RedeemActivationCode( + RedeemActivationCodeDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.RedeemActivationCodeAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + private CommerceAdminActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new CommerceException("Tenant commerce actor was not resolved.", "tenant_admin_access_denied"); + } + + return new CommerceAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } + + private static CommerceAdminQuery ToQuery(TenantCommerceQueryDto query) + { + return new CommerceAdminQuery(query.Provider, query.Status, query.Limit); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 9d30b76..0948de3 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -335,9 +335,10 @@ public sealed class ExceptionHandlingMiddleware( 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, + "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, + "order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" => StatusCodes.Status404NotFound, "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, - "order_status_invalid" => StatusCodes.Status409Conflict, + "order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" => StatusCodes.Status409Conflict, _ => StatusCodes.Status400BadRequest }; } diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs new file mode 100644 index 0000000..bbfe62a --- /dev/null +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Commerce; + +public sealed record CommerceAdminActor(Guid TenantId, Guid UserId); + +public sealed record CommerceAdminQuery(string? Provider = null, string? Status = null, int? Limit = null); + +public sealed record UpsertPaymentAccountCommand( + string Provider, + TenantPaymentMode Mode, + string? DisplayName, + TenantPaymentAccountStatus Status, + JsonElement ConfigPublic); + +public sealed record TenantPaymentAccountItem( + Guid Id, + string Provider, + string Mode, + string? DisplayName, + string Status, + JsonElement ConfigPublic, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record UpsertTenantSecretCommand( + string Purpose, + string Provider, + string SecretKey, + string SecretRef, + TenantSecretStatus Status, + JsonElement SecretPayload, + DateTimeOffset? ExpiresAt); + +public sealed record TenantSecretItem( + Guid Id, + string Purpose, + string Provider, + string SecretKey, + string SecretRef, + string Status, + DateTimeOffset? RotatedAt, + DateTimeOffset? ExpiresAt, + DateTimeOffset UpdatedAt); + +public sealed record AdminOrderList(IReadOnlyCollection Items); + +public sealed record AdminPaymentList(IReadOnlyCollection Items); + +public sealed record CreateCodeBatchCommand( + string Name, + int TotalCount, + int Days, + Guid? RegionId, + string? SaleType, + string? Channel, + int? DefaultUnitPriceCents, + int? CostPriceCents, + string? Remark); + +public sealed record CodeBatchItem( + Guid Id, + string Name, + int TotalCount, + int Days, + Guid? RegionId, + string? SaleType, + string? Channel, + int DefaultUnitPriceCents, + int CostPriceCents, + DateTimeOffset? IssuedAt, + string? Remark, + DateTimeOffset CreatedAt); + +public sealed record ActivationCodeItem( + Guid Id, + Guid? BatchId, + string Code, + int Days, + bool IsUsed, + Guid? UsedBy, + DateTimeOffset? UsedAt, + string? SaleType, + string? SoldTo, + string? Remark, + DateTimeOffset CreatedAt); + +public sealed record ActivationCodeList(IReadOnlyCollection Items); + +public sealed record RedeemActivationCodeCommand(string Code, Guid UserId, Guid? RegionId); + +public interface ICommerceAdminService +{ + Task> GetPaymentAccountsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task UpsertPaymentAccountAsync( + CommerceAdminActor actor, + UpsertPaymentAccountCommand command, + CancellationToken cancellationToken = default); + + Task UpsertTenantSecretAsync( + CommerceAdminActor actor, + UpsertTenantSecretCommand command, + CancellationToken cancellationToken = default); + + Task GetOrdersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task GetPaymentsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task CreateCodeBatchAsync( + CommerceAdminActor actor, + CreateCodeBatchCommand command, + CancellationToken cancellationToken = default); + + Task GetActivationCodesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task RedeemActivationCodeAsync( + CommerceAdminActor actor, + RedeemActivationCodeCommand command, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs new file mode 100644 index 0000000..67f3ee3 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -0,0 +1,379 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdminService +{ + public async Task> GetPaymentAccountsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var accounts = dbContext.TenantPaymentAccounts.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + accounts = accounts.Where(item => item.Provider == provider); + } + + return await accounts + .OrderBy(item => item.Provider) + .Take(Math.Clamp(query.Limit ?? 50, 1, 100)) + .Select(item => ToPaymentAccountItem(item)) + .ToArrayAsync(cancellationToken); + } + + public async Task UpsertPaymentAccountAsync( + CommerceAdminActor actor, + UpsertPaymentAccountCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + AssertNoSecrets(command.ConfigPublic, "config_public"); + var provider = NormalizeProvider(command.Provider); + var account = await dbContext.TenantPaymentAccounts + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Provider == provider, cancellationToken); + if (account is null) + { + account = new TenantPaymentAccount + { + TenantId = actor.TenantId, + Provider = provider + }; + dbContext.TenantPaymentAccounts.Add(account); + } + + account.Mode = command.Mode; + account.DisplayName = command.DisplayName?.Trim(); + account.Status = command.Status; + account.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToPaymentAccountItem(account); + } + + public async Task UpsertTenantSecretAsync( + CommerceAdminActor actor, + UpsertTenantSecretCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var secretRef = string.IsNullOrWhiteSpace(command.SecretRef) + ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}" + : command.SecretRef.Trim(); + var provider = NormalizeProvider(command.Provider); + var secret = await dbContext.TenantSecrets + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); + if (secret is null) + { + secret = new TenantSecret + { + TenantId = actor.TenantId, + SecretRef = secretRef + }; + dbContext.TenantSecrets.Add(secret); + } + else + { + secret.RotatedAt = DateTimeOffset.UtcNow; + } + + secret.Purpose = command.Purpose.Trim(); + secret.Provider = provider; + secret.SecretKey = command.SecretKey.Trim(); + secret.Status = command.Status; + secret.SecretPayload = JsonObjectOrDefault(command.SecretPayload); + secret.ExpiresAt = command.ExpiresAt; + + await dbContext.SaveChangesAsync(cancellationToken); + return ToSecretItem(secret); + } + + public async Task GetOrdersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var orders = dbContext.Orders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + 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 ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new AdminOrderList(items.Select(ToOrderItem).ToArray()); + } + + public async Task GetPaymentsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var payments = from payment in dbContext.Payments.AsNoTracking() + join order in dbContext.Orders.AsNoTracking() + on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } + where payment.TenantId == actor.TenantId + select new { payment, order.OrderNo }; + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + payments = payments.Where(item => item.payment.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status)); + } + + var rows = await payments + .OrderByDescending(item => item.payment.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray()); + } + + public async Task CreateCodeBatchAsync( + CommerceAdminActor actor, + CreateCodeBatchCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.TotalCount is < 1 or > 1000) + { + throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count"); + } + + if (command.Days <= 0) + { + throw new CommerceException("Activation code days must be positive.", "invalid_activation_days"); + } + + 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 batch = new CodeBatch + { + TenantId = actor.TenantId, + RegionId = command.RegionId, + CreatedBy = actor.UserId, + Name = command.Name.Trim(), + SaleType = command.SaleType?.Trim(), + Channel = command.Channel?.Trim(), + DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0, + CostPriceCents = command.CostPriceCents ?? 0, + TotalCount = command.TotalCount, + Days = command.Days, + IssuedAt = DateTimeOffset.UtcNow, + Remark = command.Remark + }; + dbContext.CodeBatches.Add(batch); + for (var index = 0; index < command.TotalCount; index++) + { + dbContext.ActivationCodes.Add(new ActivationCode + { + TenantId = actor.TenantId, + BatchId = batch.Id, + Code = GenerateActivationCode(), + Days = command.Days, + SaleType = batch.SaleType, + UnitPriceCents = batch.DefaultUnitPriceCents, + Remark = batch.Remark + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return ToCodeBatchItem(batch); + } + + public async Task GetActivationCodesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var codes = dbContext.ActivationCodes.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase); + codes = codes.Where(item => item.IsUsed == used); + } + + var items = await codes + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray()); + } + + public async Task RedeemActivationCodeAsync( + CommerceAdminActor actor, + RedeemActivationCodeCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var code = await dbContext.ActivationCodes + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Code == command.Code.Trim(), + cancellationToken) + ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); + if (code.IsUsed) + { + throw new CommerceException("Activation code has already been used.", "activation_code_used"); + } + + var userIsMember = await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == actor.TenantId && + item.UserId == command.UserId && + item.Status == MembershipStatus.Active, + cancellationToken); + if (!userIsMember) + { + throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found"); + } + + code.IsUsed = true; + code.UsedBy = command.UserId; + code.UsedRegionId = command.RegionId; + code.UsedAt = DateTimeOffset.UtcNow; + dbContext.Entitlements.Add(new Entitlement + { + TenantId = actor.TenantId, + UserId = command.UserId, + EntitlementType = "svip", + SourceType = "activation_code", + SourceId = code.Id, + StartsAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days), + Status = EntitlementStatus.Active, + Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId }) + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToActivationCodeItem(code); + } + + private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) + { + var isAdmin = await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active && + (item.Role == TenantRole.PlatformAdmin || + item.Role == TenantRole.TenantOwner || + item.Role == TenantRole.TenantAdmin), + cancellationToken); + if (!isAdmin) + { + throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); + } + } + + private static TenantPaymentAccountItem ToPaymentAccountItem(TenantPaymentAccount item) => + new(item.Id, item.Provider, item.Mode.ToString(), item.DisplayName, item.Status.ToString(), item.ConfigPublic, item.CreatedAt, item.UpdatedAt); + + private static TenantSecretItem ToSecretItem(TenantSecret item) => + new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt); + + private static CodeBatchItem ToCodeBatchItem(CodeBatch item) => + new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt); + + private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) => + new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt); + + private static CommerceOrderItem ToOrderItem(Order order) => + new(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) => + new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload); + + private static OrderStatus ParseOrderStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Order status is invalid.", "invalid_order_status"); + + private static PaymentStatus ParsePaymentStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Payment status is invalid.", "invalid_payment_status"); + + private static string NormalizeEnum(string? value) => + string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); + + private static string NormalizeProvider(string? provider) + { + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + return normalized switch + { + "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, + "ali_pay" => PaymentProviders.Alipay, + "" => throw new CommerceException("Provider is required.", "provider_required"), + _ => normalized + }; + } + + private static JsonElement JsonObjectOrDefault(JsonElement element) => + element.ValueKind == JsonValueKind.Object + ? element.Clone() + : JsonSerializer.SerializeToElement(new { }); + + private static void AssertNoSecrets(JsonElement element, string path) + { + if (element.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in element.EnumerateObject()) + { + var key = property.Name.ToLowerInvariant(); + if (key is "secretref" or "secret_ref") + { + continue; + } + + if (key.Contains("secret", StringComparison.Ordinal) || + key.Contains("privatekey", StringComparison.Ordinal) || + key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret") + { + throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret"); + } + + AssertNoSecrets(property.Value, $"{path}.{property.Name}"); + } + } + + private static string GenerateActivationCode() + { + Span bytes = stackalloc byte[8]; + RandomNumberGenerator.Fill(bytes); + return $"TKU{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 9fc55c5..6cf8b84 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -64,6 +64,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/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs new file mode 100644 index 0000000..cd8a331 --- /dev/null +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Application.Auth; +using Tiku.Application.Commerce; +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 TenantCommerceEndpointTests +{ + [Fact] + public async Task Non_admin_cannot_access_tenant_commerce_operations() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.Student); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var response = await client.GetAsync("/api/tenant-commerce/orders"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Payment_account_public_config_rejects_secrets() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var response = await client.PutAsJsonAsync( + "/api/tenant-commerce/payment-accounts", + new UpsertPaymentAccountDto + { + Provider = "wechat_pay", + Status = TenantPaymentAccountStatus.Active, + ConfigPublic = JsonSerializer.SerializeToElement(new + { + merchantId = "mch", + apiV3Key = "must-not-be-public" + }) + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Admin_can_configure_payment_account_and_secret() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var secretResponse = await client.PutAsJsonAsync( + "/api/tenant-commerce/secrets", + new UpsertTenantSecretDto + { + Purpose = "payment", + Provider = "wechat_pay", + SecretKey = "default", + SecretRef = "tenant_secrets:payment:wechat_pay:default", + SecretPayload = JsonSerializer.SerializeToElement(new + { + privateKey = "pem", + apiV3Key = "v3" + }) + }); + var accountResponse = await client.PutAsJsonAsync( + "/api/tenant-commerce/payment-accounts", + new UpsertPaymentAccountDto + { + Provider = "wechat_pay", + Status = TenantPaymentAccountStatus.Active, + ConfigPublic = JsonSerializer.SerializeToElement(new + { + merchantId = "mch", + secretRef = "tenant_secrets:payment:wechat_pay:default" + }) + }); + var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay"); + + Assert.Equal(HttpStatusCode.OK, secretResponse.StatusCode); + Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode); + Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Admin_can_create_and_redeem_activation_code_batch() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var batchResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/code-batches", + new CreateCodeBatchDto + { + Name = "测试批次", + TotalCount = 2, + Days = 30 + }); + var codesResponse = await client.GetAsync("/api/tenant-commerce/activation-codes?status=unused&limit=5"); + var codes = await codesResponse.Content.ReadFromJsonAsync(); + var code = codes!.Items.First().Code; + var redeemResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/activation-codes/redeem", + new RedeemActivationCodeDto + { + Code = code, + UserId = seed.UserId + }); + + Assert.Equal(HttpStatusCode.OK, batchResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, codesResponse.StatusCode); + Assert.Equal(2, codes.Items.Count); + Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Contains(dbContext.Entitlements, item => + item.TenantId == seed.TenantId && + item.UserId == seed.UserId && + item.SourceType == "activation_code"); + } + + private static async Task SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13800000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Tenant Commerce" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Tenant Commerce User" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = role, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return new LoginSeed(tenantId, userId, 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 sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); + + private sealed class FakePaymentGateway : IPaymentProviderGateway + { + public Task CreatePaymentAsync( + string provider, + CreatePaymentProviderRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task ParsePaymentNotificationAsync( + string provider, + PaymentNotificationRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } +}