805 lines
40 KiB
C#
805 lines
40 KiB
C#
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<PlatformTenantCapabilityList<PlatformCrmConfigItem>> GetConfigsAsync(
|
|
PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform crm configs list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformCrmConfigItem>(items);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformCrmConfigItem> UpsertConfigAsync(
|
|
PlatformCapabilityActor actor,
|
|
UpsertPlatformCrmConfigCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform crm config upsert", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformTenantCapabilityList<CrmWebhookQueueItem>> GetLeadsAsync(
|
|
PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform crm leads list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<CrmWebhookQueueItem>(items);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<CrmWebhookQueueItem> RetryLeadAsync(
|
|
PlatformCapabilityActor actor,
|
|
PlatformCrmLeadRetryCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform crm lead retry", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformTenantCapabilityList<CrmWebhookLog>> 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<TikuDbContext>();
|
|
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<CrmWebhookLog>(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<TResult> ExecuteAsync<TResult>(string reason,
|
|
Func<IServiceProvider, CancellationToken, Task<TResult>> 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<T>(string? value, T fallback) where T : struct, Enum
|
|
{
|
|
return Enum.TryParse<T>(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<PlatformTenantCapabilityList<PlatformSmsChannelItem>> GetChannelsAsync(PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms channels list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformSmsChannelItem>(rows.Select(value =>
|
|
ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray());
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformSmsChannelItem> UpsertChannelAsync(PlatformCapabilityActor actor,
|
|
UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms channel upsert", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformSmsChannelItem> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms channel disable", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformTenantCapabilityList<PlatformSmsTemplateItem>> GetTemplatesAsync(PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms templates list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformSmsTemplateItem>(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<PlatformSmsTemplateItem> UpsertTemplateAsync(PlatformCapabilityActor actor,
|
|
UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms template upsert", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformSmsTemplateItem> SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null,
|
|
"platform.sms.template.review_submitted", cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformSmsTemplateItem> DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled,
|
|
"platform.sms.template.disabled", cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformTenantCapabilityList<SmsSendLog>> GetLogsAsync(PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform sms logs list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<SmsSendLog>(await values.OrderByDescending(value => value.CreatedAt)
|
|
.Take(Limit(query.Limit)).ToArrayAsync(token));
|
|
}, cancellationToken);
|
|
}
|
|
|
|
private Task<PlatformSmsTemplateItem> ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId,
|
|
SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return ExecuteAsync(action, async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<TResult> ExecuteAsync<TResult>(string reason,
|
|
Func<IServiceProvider, CancellationToken, Task<TResult>> 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<string> 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<T>(string? value, T fallback) where T : struct, Enum
|
|
{
|
|
return Enum.TryParse<T>(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<IReadOnlyCollection<PlatformPaymentApp>> GetAppsAsync(PlatformCapabilityActor actor, string? status,
|
|
int limit, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync<IReadOnlyCollection<PlatformPaymentApp>>("platform payment apps list",
|
|
async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformPaymentApp> UpsertAppAsync(PlatformCapabilityActor actor,
|
|
UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform payment app upsert", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<IReadOnlyCollection<PlatformPaymentChannel>> GetChannelsAsync(PlatformCapabilityActor actor,
|
|
Guid? appId, string? status, int limit, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync<IReadOnlyCollection<PlatformPaymentChannel>>("platform payment channels list",
|
|
async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformPaymentChannel> UpsertChannelAsync(PlatformCapabilityActor actor,
|
|
UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform payment channel upsert", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<PlatformPaymentChannel> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform payment channel disable", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<IReadOnlyCollection<PlatformBillingPaymentEvent>> GetEventsAsync(PlatformCapabilityActor actor,
|
|
string? status, int limit, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync<IReadOnlyCollection<PlatformBillingPaymentEvent>>("platform payment events list",
|
|
async (services, token) =>
|
|
await services.GetRequiredService<TikuDbContext>().PlatformBillingPaymentEvents.AsNoTracking()
|
|
.OrderByDescending(value => value.CreatedAt)
|
|
.Take(Limit(limit))
|
|
.ToArrayAsync(token), cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformRebateSummary> GetRebateSummaryAsync(PlatformCapabilityActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform rebate summary", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<TResult> ExecuteAsync<TResult>(string reason,
|
|
Func<IServiceProvider, CancellationToken, Task<TResult>> 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<T>(string? value, T fallback) where T : struct, Enum
|
|
{
|
|
return Enum.TryParse<T>(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<PlatformTenantCapabilityList<TenantExternalProviderItem>> GetAppsAsync(PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform tenant payment apps list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<TenantExternalProviderItem>(items);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<TenantExternalProviderItem> 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<ITenantExternalProviderConfigService>()
|
|
.UpsertProviderAsync(tenantId, command, token);
|
|
services.GetRequiredService<TikuDbContext>().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<TikuDbContext>().SaveChangesAsync(token);
|
|
return item;
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformTenantCapabilityList<object>> GetEventsAsync(PlatformCapabilityActor actor,
|
|
PlatformCapabilityQuery query, CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync("platform tenant payment events list", async (services, token) =>
|
|
{
|
|
var db = services.GetRequiredService<TikuDbContext>();
|
|
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<object>()
|
|
.ToArrayAsync(token);
|
|
return new PlatformTenantCapabilityList<object>(items);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
private Task<TResult> ExecuteAsync<TResult>(string reason,
|
|
Func<IServiceProvider, CancellationToken, Task<TResult>> 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<T>(string? value, T fallback) where T : struct, Enum
|
|
{
|
|
return Enum.TryParse<T>(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed)
|
|
? parsed
|
|
: fallback;
|
|
}
|
|
} |