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