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); } public async Task GetPointTasksAsync( CommerceAdminActor actor, TenantPointQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var tasks = dbContext.PointActivityTasks.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status)); } var items = await tasks .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantPointTaskList(items); } public async Task UpsertPointTaskAsync( CommerceAdminActor actor, UpsertPointTaskCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); if (command.Points <= 0 || command.MaxClaimsPerUser <= 0) { throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task"); } var task = command.Id.HasValue ? await dbContext.PointActivityTasks.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, cancellationToken) : await dbContext.PointActivityTasks.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(), cancellationToken); if (task is null) { task = new PointActivityTask { TenantId = actor.TenantId }; dbContext.PointActivityTasks.Add(task); } task.TaskKey = command.TaskKey.Trim(); task.Title = command.Title.Trim(); task.Description = command.Description?.Trim(); task.TaskType = command.TaskType; task.Status = command.Status; task.Points = command.Points; task.MaxClaimsPerUser = command.MaxClaimsPerUser; task.StartsAt = command.StartsAt; task.EndsAt = command.EndsAt; task.SortOrder = command.SortOrder; task.Rules = JsonObjectOrDefault(command.Rules); task.Metadata = JsonObjectOrDefault(command.Metadata); await dbContext.SaveChangesAsync(cancellationToken); return task; } public async Task GetPointClaimsAsync( CommerceAdminActor actor, TenantPointQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var claims = dbContext.PointActivityClaims.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.UserId.HasValue) { claims = claims.Where(item => item.UserId == query.UserId.Value); } var items = await claims .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantPointClaimList(items); } public async Task GetPointExchangeItemsAsync( CommerceAdminActor actor, TenantPointQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var items = dbContext.PointExchangeItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status)); } if (query.RegionId.HasValue) { items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); } var result = await items .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantPointExchangeItemList(result); } public async Task UpsertPointExchangeItemAsync( CommerceAdminActor actor, UpsertPointExchangeItemCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); if (command.PointsCost <= 0) { throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item"); } var item = command.Id.HasValue ? await dbContext.PointExchangeItems.SingleOrDefaultAsync( entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value, cancellationToken) : await dbContext.PointExchangeItems.SingleOrDefaultAsync( entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(), cancellationToken); if (item is null) { item = new PointExchangeItem { TenantId = actor.TenantId }; dbContext.PointExchangeItems.Add(item); } item.RegionId = command.RegionId; item.ItemKey = command.ItemKey.Trim(); item.Name = command.Name.Trim(); item.Description = command.Description?.Trim(); item.ItemType = command.ItemType; item.Status = command.Status; item.PointsCost = command.PointsCost; item.Stock = command.Stock; item.Days = command.Days; item.SortOrder = command.SortOrder; item.StartsAt = command.StartsAt; item.EndsAt = command.EndsAt; item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload); item.Metadata = JsonObjectOrDefault(command.Metadata); await dbContext.SaveChangesAsync(cancellationToken); return item; } public async Task GetPointExchangeOrdersAsync( CommerceAdminActor actor, TenantPointQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var orders = dbContext.PointExchangeOrders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.UserId.HasValue) { orders = orders.Where(item => item.UserId == query.UserId.Value); } if (!string.IsNullOrWhiteSpace(query.Status)) { orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status)); } var result = await orders .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantPointExchangeOrderList(result); } public async Task UpdatePointExchangeOrderStatusAsync( CommerceAdminActor actor, UpdatePointExchangeOrderStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var order = await dbContext.PointExchangeOrders .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken) ?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found"); order.Status = command.Status; if (command.Status == PointExchangeOrderStatus.Completed) { order.CompletedAt ??= DateTimeOffset.UtcNow; order.CancelledAt = null; } else if (command.Status == PointExchangeOrderStatus.Cancelled) { order.CancelledAt ??= DateTimeOffset.UtcNow; } await dbContext.SaveChangesAsync(cancellationToken); return order; } public async Task GetCouponsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var coupons = dbContext.Coupons.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var items = await coupons .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantCouponList(items); } public async Task UpsertCouponAsync( CommerceAdminActor actor, UpsertCouponCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var coupon = command.Id.HasValue ? await dbContext.Coupons.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, cancellationToken) : await dbContext.Coupons.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), cancellationToken); if (coupon is null) { coupon = new Coupon { TenantId = actor.TenantId }; dbContext.Coupons.Add(coupon); } coupon.Code = command.Code.Trim(); coupon.PlanId = command.PlanId; coupon.DiscountType = command.DiscountType; coupon.DiscountValue = command.DiscountValue; coupon.ValidFrom = command.ValidFrom; coupon.ValidTo = command.ValidTo; coupon.MaxUses = command.MaxUses; coupon.Source = command.Source?.Trim(); coupon.Remark = command.Remark; await dbContext.SaveChangesAsync(cancellationToken); return coupon; } public async Task GetCouponRedemptionsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); } var items = await redemptions .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantCouponRedemptionList(items); } public async Task GetCouponReportAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var claimedCount = await redemptions.CountAsync(cancellationToken); var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); var discountApplied = await redemptions .Where(item => item.Status == CouponRedemptionStatus.Used) .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0; return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); } 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 PointActivityTaskStatus ParsePointTaskStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point task status is invalid.", "invalid_point_task_status"); private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status"); private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_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); }