using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformCrmAdminService { public Task> GetConfigsAsync( PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform crm configs list", async (services, token) => { var db = services.GetRequiredService(); var values = from config in db.CrmConfigs.AsNoTracking() join tenant in db.Tenants.AsNoTracking() on config.TenantId equals tenant.Id select new { config, tenant.Name }; if (query.TenantId.HasValue) values = values.Where(value => value.config.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { var enabled = IsEnabledStatus(query.Status); values = values.Where(value => value.config.Enabled == enabled); } var items = await values .OrderByDescending(value => value.config.UpdatedAt) .Take(Limit(query.Limit)) .Select(value => ToCrmConfigItem(value.config, value.Name)) .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); } public Task UpsertConfigAsync( PlatformCapabilityActor actor, UpsertPlatformCrmConfigCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform crm config upsert", async (services, token) => { var db = services.GetRequiredService(); var tenantName = await db.Tenants .Where(tenant => tenant.Id == command.TenantId) .Select(tenant => tenant.Name) .SingleOrDefaultAsync(token) ?? throw Error("Tenant was not found.", "tenant_not_found"); var item = command.Id.HasValue ? await db.CrmConfigs.SingleOrDefaultAsync( value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) : await db.CrmConfigs.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, token); if (item is null) { item = new CrmConfig { TenantId = command.TenantId }; db.CrmConfigs.Add(item); } item.Enabled = command.Enabled; item.Url = Normalize(command.Url); item.SecretRef = Normalize(command.SecretRef); item.FormName = Normalize(command.FormName); item.ExamType = Normalize(command.ExamType); item.TimeoutSeconds = command.TimeoutSeconds; item.DelaySeconds = command.DelaySeconds; item.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None); item.AssignmentPool = command.AssignmentPool ?? JsonDefaults.Array(); item.AssignmentConfig = command.AssignmentConfig ?? JsonDefaults.Object(); item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, new { item.Enabled, item.Url, item.AssignmentMode }); await db.SaveChangesAsync(token); return ToCrmConfigItem(item, tenantName); }, cancellationToken); } public Task> GetLeadsAsync( PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform crm leads list", async (services, token) => { var db = services.GetRequiredService(); var values = db.CrmWebhookQueue.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending)); var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); } public Task RetryLeadAsync( PlatformCapabilityActor actor, PlatformCrmLeadRetryCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform crm lead retry", async (services, token) => { var db = services.GetRequiredService(); var item = await db.CrmWebhookQueue.SingleOrDefaultAsync(value => value.Id == command.QueueId, token) ?? throw Error("CRM queue item was not found.", "crm_queue_not_found"); if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying)) throw Error("Only failed CRM queue items can be retried.", "crm_queue_retry_invalid"); item.Status = CrmWebhookQueueStatus.Retrying; item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, new { command.Note }); await db.SaveChangesAsync(token); return item; }, cancellationToken); } public Task> GetLogsAsync( PlatformCapabilityActor actor, Guid? tenantId, Guid? queueId, int limit, CancellationToken cancellationToken = default) { return ExecuteAsync("platform crm logs list", async (services, token) => { var db = services.GetRequiredService(); var values = db.CrmWebhookLogs.AsNoTracking(); if (tenantId.HasValue) values = values.Where(value => value.TenantId == tenantId); if (queueId.HasValue) { var queueRecordId = await db.CrmWebhookQueue.AsNoTracking() .Where(value => value.Id == queueId.Value) .Select(value => value.RecordId) .SingleOrDefaultAsync(token); values = values.Where(value => value.RecordId == queueRecordId); } var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(limit)).ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); } private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) { return new PlatformCrmConfigItem(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, config.SecretRef, config.FormName, config.ExamType, config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, config.AssignmentConfig, config.UpdatedAt); } private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); } private static bool IsEnabledStatus(string value) { return value.Equals("active", StringComparison.OrdinalIgnoreCase) || value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常"; } private static PlatformCapabilityException Error(string message, string code) { return new PlatformCapabilityException(message, code); } private static int Limit(int value) { return Math.Clamp(value, 1, 500); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static T ParseEnum(string? value, T fallback) where T : struct, Enum { return Enum.TryParse(NormalizeEnum(value), true, out var parsed) ? parsed : fallback; } private static string? NormalizeEnum(string? value) { return value?.Trim().Replace("-", "_", StringComparison.Ordinal); } private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) { db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); } } internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformSmsAdminService { public Task> GetChannelsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms channels list", async (services, token) => { var db = services.GetRequiredService(); var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero); var sentCounts = await db.SmsSendLogs.AsNoTracking() .Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && value.ChannelId.HasValue) .GroupBy(value => value.ChannelId!.Value) .Select(group => new { ChannelId = group.Key, Count = group.Count() }) .ToDictionaryAsync(value => value.ChannelId, value => value.Count, token); var values = from channel in db.SmsChannels.AsNoTracking() join tenant in db.Tenants.AsNoTracking() on channel.TenantId equals tenant.Id select new { channel, tenant.Name }; if (query.TenantId.HasValue) values = values.Where(value => value.channel.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); var rows = await values.OrderBy(value => value.channel.Priority) .ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); return new PlatformTenantCapabilityList(rows.Select(value => ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray()); }, cancellationToken); } public Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms channel upsert", async (services, token) => { var db = services.GetRequiredService(); var tenantName = await TenantNameAsync(db, command.TenantId, token); var provider = NormalizeCode(command.Provider); var scene = NormalizeCode(command.Scene); var item = command.Id.HasValue ? await db.SmsChannels.SingleOrDefaultAsync( value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) : await db.SmsChannels.SingleOrDefaultAsync( value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, token); if (item is null) { item = new SmsChannel { TenantId = command.TenantId }; db.SmsChannels.Add(item); } item.Provider = provider; item.Name = command.Name.Trim(); item.Signature = command.Signature.Trim(); item.Scene = scene; item.Status = command.Status; item.SecretRef = Normalize(command.SecretRef); item.Priority = command.Priority ?? item.Priority; item.MonthlyQuota = Math.Max(0, command.MonthlyQuota ?? item.MonthlyQuota); item.ConfigPublic = command.ConfigPublic; item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, new { item.Provider, item.Scene, item.Status }); await db.SaveChangesAsync(token); return ToChannelItem(item, tenantName, 0); }, cancellationToken); } public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms channel disable", async (services, token) => { var db = services.GetRequiredService(); var item = await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); item.Status = TenantExternalProviderStatus.Disabled; item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, new { item.Provider, item.Scene }); await db.SaveChangesAsync(token); return ToChannelItem(item, await TenantNameAsync(db, item.TenantId, token), 0); }, cancellationToken); } public Task> GetTemplatesAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms templates list", async (services, token) => { var db = services.GetRequiredService(); var sentCounts = await db.SmsSendLogs.AsNoTracking() .Where(value => value.TemplateId.HasValue) .GroupBy(value => value.TemplateId!.Value) .Select(group => new { TemplateId = group.Key, Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) }) .ToDictionaryAsync(value => value.TemplateId, token); var values = from template in db.SmsTemplates.AsNoTracking() join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals new { channel.TenantId, ChannelId = channel.Id } join tenant in db.Tenants.AsNoTracking() on template.TenantId equals tenant.Id select new { template, channel.Name, TenantName = tenant.Name }; if (query.TenantId.HasValue) values = values.Where(value => value.template.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active)); var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); return new PlatformTenantCapabilityList(rows.Select(value => { var counts = sentCounts.GetValueOrDefault(value.template.Id); return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, counts?.Failed ?? 0); }).ToArray()); }, cancellationToken); } public Task UpsertTemplateAsync(PlatformCapabilityActor actor, UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms template upsert", async (services, token) => { var db = services.GetRequiredService(); var channel = await db.SmsChannels.AsNoTracking() .SingleOrDefaultAsync( value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token) ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); var item = command.Id.HasValue ? await db.SmsTemplates.SingleOrDefaultAsync( value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) : await db.SmsTemplates.SingleOrDefaultAsync( value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token); if (item is null) { item = new SmsTemplate { TenantId = command.TenantId }; db.SmsTemplates.Add(item); } item.ChannelId = command.ChannelId; item.Code = NormalizeCode(command.Code); item.Name = command.Name.Trim(); item.Type = command.Type; item.AuditStatus = command.AuditStatus; item.Status = command.Status; item.ProviderTemplateCode = Normalize(command.ProviderTemplateCode); item.Content = command.Content.Trim(); item.Remark = Normalize(command.Remark); item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status }); await db.SaveChangesAsync(token); return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channel.Name, 0, 0); }, cancellationToken); } public Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) { return ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, "platform.sms.template.review_submitted", cancellationToken); } public Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) { return ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, "platform.sms.template.disabled", cancellationToken); } public Task> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform sms logs list", async (services, token) => { var db = services.GetRequiredService(); var values = db.SmsSendLogs.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent)); return new PlatformTenantCapabilityList(await values.OrderByDescending(value => value.CreatedAt) .Take(Limit(query.Limit)).ToArrayAsync(token)); }, cancellationToken); } private Task ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, CancellationToken cancellationToken) { return ExecuteAsync(action, async (services, token) => { var db = services.GetRequiredService(); var item = await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == templateId, token) ?? throw Error("SMS template was not found.", "sms_template_not_found"); var channelName = await db.SmsChannels.AsNoTracking() .Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId) .Select(value => value.Name).SingleAsync(token); if (auditStatus.HasValue) { item.AuditStatus = auditStatus.Value; item.SubmittedAt = DateTimeOffset.UtcNow; } if (status.HasValue) { item.Status = status.Value; item.DisabledAt = DateTimeOffset.UtcNow; } item.UpdatedAt = DateTimeOffset.UtcNow; AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status }); await db.SaveChangesAsync(token); return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channelName, 0, 0); }, cancellationToken); } private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); } private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) { return new PlatformSmsChannelItem(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, channel.Signature, channel.Scene, channel.Status, channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, channel.Metadata, channel.UpdatedAt); } private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, int sent, int failed) { return new PlatformSmsTemplateItem(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, template.Code, template.Name, template.Type, template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, sent, failed, template.UpdatedAt); } private static async Task TenantNameAsync(TikuDbContext db, Guid tenantId, CancellationToken token) { return await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name) .SingleOrDefaultAsync(token) ?? throw Error("Tenant was not found.", "tenant_not_found"); } private static PlatformCapabilityException Error(string message, string code) { return new PlatformCapabilityException(message, code); } private static int Limit(int value) { return Math.Clamp(value, 1, 500); } private static string NormalizeCode(string value) { return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static T ParseEnum(string? value, T fallback) where T : struct, Enum { return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; } private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) { db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); } } internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) : IPlatformPaymentSettingsService { public Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) { return ExecuteAsync>("platform payment apps list", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformPaymentApps.AsNoTracking(); if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active)); return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token); }, cancellationToken); } public Task UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform payment app upsert", async (services, token) => { var db = services.GetRequiredService(); var code = NormalizeCode(command.AppCode); var item = command.Id.HasValue ? await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) : await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.AppCode == code, token); if (item is null) { item = new PlatformPaymentApp { AppCode = code }; db.PlatformPaymentApps.Add(item); } item.AppName = command.AppName.Trim(); item.Status = command.Status; item.SettlementMode = command.SettlementMode.Trim(); item.Description = Normalize(command.Description); item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, new { item.AppCode, item.Status }); await db.SaveChangesAsync(token); return item; }, cancellationToken); } public Task> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) { return ExecuteAsync>("platform payment channels list", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformPaymentChannels.AsNoTracking(); if (appId.HasValue) values = values.Where(value => value.AppId == appId); if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active)); return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)) .ToArrayAsync(token); }, cancellationToken); } public Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform payment channel upsert", async (services, token) => { var db = services.GetRequiredService(); if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) throw Error("Platform payment app was not found.", "platform_payment_app_not_found"); var provider = NormalizeCode(command.Provider); var item = command.Id.HasValue ? await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) : await db.PlatformPaymentChannels.SingleOrDefaultAsync( value => value.AppId == command.AppId && value.Provider == provider, token); if (item is null) { item = new PlatformPaymentChannel { AppId = command.AppId, Provider = provider }; db.PlatformPaymentChannels.Add(item); } item.Mode = command.Mode.Trim(); item.Status = command.Status; item.DisplayName = command.DisplayName.Trim(); item.SecretRef = Normalize(command.SecretRef); item.CallbackPath = Normalize(command.CallbackPath); item.Priority = command.Priority ?? item.Priority; item.ConfigPublic = command.ConfigPublic; item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, new { item.Provider, item.Status, item.SecretRef }); await db.SaveChangesAsync(token); return item; }, cancellationToken); } public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) { return ExecuteAsync("platform payment channel disable", async (services, token) => { var db = services.GetRequiredService(); var item = await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) ?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found"); item.Status = PlatformPaymentChannelStatus.Disabled; item.UpdatedAt = DateTimeOffset.UtcNow; AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, new { item.Provider }); await db.SaveChangesAsync(token); return item; }, cancellationToken); } public Task> GetEventsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) { return ExecuteAsync>("platform payment events list", async (services, token) => await services.GetRequiredService().PlatformBillingPaymentEvents.AsNoTracking() .OrderByDescending(value => value.CreatedAt) .Take(Limit(limit)) .ToArrayAsync(token), cancellationToken); } public Task GetRebateSummaryAsync(PlatformCapabilityActor actor, CancellationToken cancellationToken = default) { return ExecuteAsync("platform rebate summary", async (services, token) => { var db = services.GetRequiredService(); var settlements = db.CommissionSettlements.AsNoTracking(); var gross = await settlements.SumAsync(value => (int?)value.GrossAmountCents, token) ?? 0; var commission = await settlements.SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid) .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; var pending = await settlements .Where(value => value.Status == CommissionSettlementStatus.Approved || value.Status == CommissionSettlementStatus.PendingReview) .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; var exceptions = await settlements.CountAsync( value => value.Status == CommissionSettlementStatus.Rejected || value.Status == CommissionSettlementStatus.Cancelled, token); return new PlatformRebateSummary(gross, commission, pending, paid, exceptions); }, cancellationToken); } private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); } private static PlatformCapabilityException Error(string message, string code) { return new PlatformCapabilityException(message, code); } private static int Limit(int value) { return Math.Clamp(value, 1, 500); } private static string NormalizeCode(string value) { return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static T ParseEnum(string? value, T fallback) where T : struct, Enum { return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; } private static void AddPlatformAudit(TikuDbContext db, PlatformCapabilityActor actor, string action, string targetType, Guid targetId, object details) { db.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); } } internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformTenantPaymentAdminService { public Task> GetAppsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform tenant payment apps list", async (services, token) => { var db = services.GetRequiredService(); var values = db.TenantExternalProviders.AsNoTracking() .Where(value => value.Capability == TenantExternalProviderCapability.Payment); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority) .Take(Limit(query.Limit)) .Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, value.Metadata, value.CreatedAt, value.UpdatedAt)) .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); } public Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("platform tenant payment app upsert", async (services, token) => { if (command.Capability != TenantExternalProviderCapability.Payment) throw Error("Tenant payment capability is required.", "tenant_payment_capability_required"); var item = await services.GetRequiredService() .UpsertProviderAsync(tenantId, command, token); services.GetRequiredService().AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = "platform.tenant_payment.app.upserted", TargetType = "tenant_external_providers", TargetId = item.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { item.Provider, item.Status, item.SecretRef }) }); await services.GetRequiredService().SaveChangesAsync(token); return item; }, cancellationToken); } public Task> GetEventsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync("platform tenant payment events list", async (services, token) => { var db = services.GetRequiredService(); var values = db.PaymentEvents.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) .Select(value => new { value.Id, value.TenantId, value.PaymentId, value.Provider, value.EventType, value.EventId, value.SignatureValid, value.ProcessedAt, value.Error, value.CreatedAt }) .Cast() .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); } private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); } private static PlatformCapabilityException Error(string message, string code) { return new PlatformCapabilityException(message, code); } private static int Limit(int value) { return Math.Clamp(value, 1, 500); } private static T ParseEnum(string? value, T fallback) where T : struct, Enum { return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; } }