forked from xiongyuxing/tiku-backend.net
feat(platform): add CRM SMS and payment settings
This commit is contained in:
175
Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs
Normal file
175
Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public class PlatformCapabilityQueryDto
|
||||
{
|
||||
public Guid? TenantId { get; set; }
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
[Range(1, 500)]
|
||||
public int Limit { get; set; } = 100;
|
||||
|
||||
public PlatformCapabilityQuery ToQuery() => new(TenantId, Status, Limit);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformCrmConfigDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
[StringLength(2048)]
|
||||
public string? Url { get; set; }
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
[StringLength(200)]
|
||||
public string? FormName { get; set; }
|
||||
[StringLength(100)]
|
||||
public string? ExamType { get; set; }
|
||||
[Range(1, 120)]
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
[Range(0, 86400)]
|
||||
public int? DelaySeconds { get; set; }
|
||||
[StringLength(50)]
|
||||
public string? AssignmentMode { get; set; }
|
||||
public JsonElement? AssignmentPool { get; set; }
|
||||
public JsonElement? AssignmentConfig { get; set; }
|
||||
|
||||
public UpsertPlatformCrmConfigCommand ToCommand() =>
|
||||
new(Id, TenantId, Enabled, Url, SecretRef, FormName, ExamType, TimeoutSeconds, DelaySeconds, AssignmentMode, AssignmentPool, AssignmentConfig);
|
||||
}
|
||||
|
||||
public sealed class RetryPlatformCrmLeadDto
|
||||
{
|
||||
[Required]
|
||||
public Guid QueueId { get; set; }
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
public PlatformCrmLeadRetryCommand ToCommand() => new(QueueId, Note);
|
||||
}
|
||||
|
||||
public sealed class PlatformCrmLogQueryDto : PlatformCapabilityQueryDto
|
||||
{
|
||||
public Guid? QueueId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformSmsChannelDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
[Required, StringLength(80)]
|
||||
public string Provider { get; set; } = "generic";
|
||||
[Required, StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
[Required, StringLength(100)]
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
[Required, StringLength(100)]
|
||||
public string Scene { get; set; } = "login";
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? MonthlyQuota { get; set; }
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSmsChannelCommand ToCommand() =>
|
||||
new(Id, TenantId, Provider, Name, Signature, Scene, Status, SecretRef, Priority, MonthlyQuota, ConfigPublic, Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformSmsTemplateDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
[Required]
|
||||
public Guid ChannelId { get; set; }
|
||||
[Required, StringLength(120)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public SmsTemplateType Type { get; set; } = SmsTemplateType.Notification;
|
||||
public SmsTemplateAuditStatus AuditStatus { get; set; } = SmsTemplateAuditStatus.Draft;
|
||||
public SmsTemplateStatus Status { get; set; } = SmsTemplateStatus.Active;
|
||||
[StringLength(120)]
|
||||
public string? ProviderTemplateCode { get; set; }
|
||||
[Required, StringLength(1000)]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
[StringLength(500)]
|
||||
public string? Remark { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSmsTemplateCommand ToCommand() =>
|
||||
new(Id, TenantId, ChannelId, Code, Name, Type, AuditStatus, Status, ProviderTemplateCode, Content, Remark, Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformPaymentAppDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required, StringLength(100)]
|
||||
public string AppCode { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)]
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
public PlatformPaymentAppStatus Status { get; set; } = PlatformPaymentAppStatus.Disabled;
|
||||
[StringLength(100)]
|
||||
public string SettlementMode { get; set; } = "PlatformCollect";
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformPaymentAppCommand ToCommand() =>
|
||||
new(Id, AppCode, AppName, Status, SettlementMode, Description, Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformPaymentChannelDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required]
|
||||
public Guid AppId { get; set; }
|
||||
[Required, StringLength(80)]
|
||||
public string Provider { get; set; } = "manual";
|
||||
[StringLength(100)]
|
||||
public string Mode { get; set; } = "PlatformCollect";
|
||||
public PlatformPaymentChannelStatus Status { get; set; } = PlatformPaymentChannelStatus.Disabled;
|
||||
[Required, StringLength(200)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
[StringLength(500)]
|
||||
public string? CallbackPath { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformPaymentChannelCommand ToCommand() =>
|
||||
new(Id, AppId, Provider, Mode, Status, DisplayName, SecretRef, CallbackPath, Priority, ConfigPublic, Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformTenantPaymentAppDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
[Required, StringLength(80)]
|
||||
public string Provider { get; set; } = "manual";
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
[StringLength(200)]
|
||||
public string? DisplayName { get; set; }
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantExternalProviderCommand ToCommand() =>
|
||||
new(TenantExternalProviderCapability.Payment, Provider, Status, DisplayName, SecretRef, Priority, ConfigPublic, Metadata);
|
||||
}
|
||||
56
Tiku.Api/Controllers/PlatformPaymentSettingsController.cs
Normal file
56
Tiku.Api/Controllers/PlatformPaymentSettingsController.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/payment-settings")]
|
||||
public sealed class PlatformPaymentSettingsController(
|
||||
IPlatformPaymentSettingsService paymentSettingsService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("apps")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformPaymentApp>> Apps(string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetAppsAsync(Actor(), status, limit, cancellationToken);
|
||||
|
||||
[HttpPut("apps")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentApp> UpsertApp(UpsertPlatformPaymentAppDto request, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.UpsertAppAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformPaymentChannel>> Channels(Guid? appId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetChannelsAsync(Actor(), appId, status, limit, cancellationToken);
|
||||
|
||||
[HttpPut("channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentChannel> UpsertChannel(UpsertPlatformPaymentChannelDto request, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("channels/{id:guid}/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentChannel> DisableChannel(Guid id, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.DisableChannelAsync(Actor(), id, cancellationToken);
|
||||
|
||||
[HttpGet("events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingPaymentEvent>> Events(string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetEventsAsync(Actor(), status, limit, cancellationToken);
|
||||
|
||||
[HttpGet("rebates/summary")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<PlatformRebateSummary> RebateSummary(CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.GetRebateSummaryAsync(Actor(), cancellationToken);
|
||||
|
||||
private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
129
Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs
Normal file
129
Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/crm")]
|
||||
public sealed class PlatformAdminCrmController(
|
||||
IPlatformCrmAdminService crmService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("configs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformCrmRead)]
|
||||
public Task<PlatformTenantCapabilityList<PlatformCrmConfigItem>> Configs([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
crmService.GetConfigsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
[HttpPut("configs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformCrmWrite)]
|
||||
public Task<PlatformCrmConfigItem> UpsertConfig(UpsertPlatformCrmConfigDto request, CancellationToken cancellationToken) =>
|
||||
crmService.UpsertConfigAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("leads")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformCrmRead)]
|
||||
public Task<PlatformTenantCapabilityList<CrmWebhookQueueItem>> Leads([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
crmService.GetLeadsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
[HttpPost("leads/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformCrmWrite)]
|
||||
public Task<CrmWebhookQueueItem> RetryLead(RetryPlatformCrmLeadDto request, CancellationToken cancellationToken) =>
|
||||
crmService.RetryLeadAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformCrmRead)]
|
||||
public Task<PlatformTenantCapabilityList<CrmWebhookLog>> Logs([FromQuery] PlatformCrmLogQueryDto query, CancellationToken cancellationToken) =>
|
||||
crmService.GetLogsAsync(Actor(), query.TenantId, query.QueueId, query.Limit, cancellationToken);
|
||||
|
||||
private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/sms")]
|
||||
public sealed class PlatformAdminSmsController(
|
||||
IPlatformSmsAdminService smsService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsRead)]
|
||||
public Task<PlatformTenantCapabilityList<PlatformSmsChannelItem>> Channels([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
smsService.GetChannelsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
[HttpPut("channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsWrite)]
|
||||
public Task<PlatformSmsChannelItem> UpsertChannel(UpsertPlatformSmsChannelDto request, CancellationToken cancellationToken) =>
|
||||
smsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("channels/{id:guid}/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsWrite)]
|
||||
public Task<PlatformSmsChannelItem> DisableChannel(Guid id, CancellationToken cancellationToken) =>
|
||||
smsService.DisableChannelAsync(Actor(), id, cancellationToken);
|
||||
|
||||
[HttpGet("templates")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsRead)]
|
||||
public Task<PlatformTenantCapabilityList<PlatformSmsTemplateItem>> Templates([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
smsService.GetTemplatesAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
[HttpPut("templates")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsWrite)]
|
||||
public Task<PlatformSmsTemplateItem> UpsertTemplate(UpsertPlatformSmsTemplateDto request, CancellationToken cancellationToken) =>
|
||||
smsService.UpsertTemplateAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("templates/{id:guid}/submit-review")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsWrite)]
|
||||
public Task<PlatformSmsTemplateItem> SubmitTemplateReview(Guid id, CancellationToken cancellationToken) =>
|
||||
smsService.SubmitTemplateReviewAsync(Actor(), id, cancellationToken);
|
||||
|
||||
[HttpPost("templates/{id:guid}/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsWrite)]
|
||||
public Task<PlatformSmsTemplateItem> DisableTemplate(Guid id, CancellationToken cancellationToken) =>
|
||||
smsService.DisableTemplateAsync(Actor(), id, cancellationToken);
|
||||
|
||||
[HttpGet("logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSmsRead)]
|
||||
public Task<PlatformTenantCapabilityList<SmsSendLog>> Logs([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
smsService.GetLogsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/payments")]
|
||||
public sealed class PlatformAdminTenantPaymentSettingsController(
|
||||
IPlatformTenantPaymentAdminService paymentService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("apps")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<PlatformTenantCapabilityList<TenantExternalProviderItem>> Apps([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
paymentService.GetAppsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
[HttpPut("apps")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<TenantExternalProviderItem> UpsertApp(UpsertPlatformTenantPaymentAppDto request, CancellationToken cancellationToken) =>
|
||||
paymentService.UpsertAppAsync(Actor(), request.TenantId, request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<PlatformTenantCapabilityList<object>> Events([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) =>
|
||||
paymentService.GetEventsAsync(Actor(), query.ToQuery(), cancellationToken);
|
||||
|
||||
private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
@@ -355,6 +355,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is PlatformCapabilityException platformCapabilityException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
platformCapabilityException.Message,
|
||||
StatusCodes.Status400BadRequest,
|
||||
platformCapabilityException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ObjectStorageException storageException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
|
||||
@@ -14,7 +14,23 @@ const platformApi = window.GongxuePlatformApi;
|
||||
const STORAGE_KEY = 'gongxue-platform-spec-v1.1';
|
||||
const TENANT_PORTAL_ORIGIN = 'http://127.0.0.1:4174/';
|
||||
const HISTORY_APP_KEY = 'gongxuePlatform';
|
||||
const API_CONNECTED_PAGES = new Set(['overview', 'tenants', 'tenantDetail', 'billingProfile', 'staff', 'audit', 'alerts']);
|
||||
const API_CONNECTED_PAGES = new Set([
|
||||
'overview',
|
||||
'tenants',
|
||||
'tenantDetail',
|
||||
'billingProfile',
|
||||
'subscriptions',
|
||||
'billing',
|
||||
'usage',
|
||||
'dunning',
|
||||
'bank',
|
||||
'crm',
|
||||
'sms',
|
||||
'paymentSettings',
|
||||
'staff',
|
||||
'audit',
|
||||
'alerts',
|
||||
]);
|
||||
|
||||
const pageMeta = {
|
||||
overview: { id: 'P-01', title: '平台经营工作台', permission: 'platform:overview:read' },
|
||||
@@ -26,9 +42,12 @@ const pageMeta = {
|
||||
usage: { id: 'P-07', title: '用量与超额计费', permission: 'platform:usage:read' },
|
||||
dunning: { id: 'P-08', title: '收款、逾期与催缴', permission: 'platform:billing:read' },
|
||||
bank: { id: 'P-09', title: '公共题库目录与授权', permission: 'platform:question_bank:read' },
|
||||
staff: { id: 'P-10', title: '平台员工与权限', permission: 'platform:staff:read' },
|
||||
audit: { id: 'P-11', title: '平台审计日志', permission: 'platform:audit:read' },
|
||||
alerts: { id: 'P-12', title: '审计告警与通知', permission: 'platform:audit:alert' },
|
||||
crm: { id: 'P-10', title: 'CRM 接入与线索流转', permission: 'platform:crm:read' },
|
||||
sms: { id: 'P-11', title: 'SMS 短信服务', permission: 'platform:sms:read' },
|
||||
paymentSettings: { id: 'P-12', title: '支付设置', permission: 'platform:payment:read' },
|
||||
staff: { id: 'P-13', title: '平台员工与权限', permission: 'platform:staff:read' },
|
||||
audit: { id: 'P-14', title: '平台审计日志', permission: 'platform:audit:read' },
|
||||
alerts: { id: 'P-15', title: '审计告警与通知', permission: 'platform:audit:alert' },
|
||||
};
|
||||
|
||||
const navGroups = [
|
||||
@@ -36,16 +55,17 @@ const navGroups = [
|
||||
['租户管理', [['tenants', 'building-2', '租户列表']]],
|
||||
['SaaS 账务', [['subscriptions', 'badge-check', '套餐与订阅'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]],
|
||||
['平台资产', [['bank', 'database-zap', '公共题库']]],
|
||||
['租户能力', [['crm', 'handshake', 'CRM 接入'], ['sms', 'message-square-text', '短信服务'], ['paymentSettings', 'credit-card', '支付设置']]],
|
||||
['安全治理', [['staff', 'user-cog', '员工与权限'], ['audit', 'scroll-text', '审计日志'], ['alerts', 'shield-alert', '告警与通知']]],
|
||||
];
|
||||
|
||||
const roleDefinitions = {
|
||||
super: { label: '超级管理员', permissions: ['*'] },
|
||||
tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read'] },
|
||||
finance: { label: '平台财务', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:audit:read'] },
|
||||
tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] },
|
||||
finance: { label: '平台财务', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write', 'platform:audit:read'] },
|
||||
audit: { label: '安全审计', permissions: ['platform:overview:read', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] },
|
||||
bank_ops: { label: '题库运营', permissions: ['platform:overview:read', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] },
|
||||
readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:audit:read'] },
|
||||
readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:crm:read', 'platform:sms:read', 'platform:payment:read', 'platform:audit:read'] },
|
||||
};
|
||||
|
||||
const seed = {
|
||||
@@ -109,6 +129,36 @@ const seed = {
|
||||
{ tenantId: 'tenant_qihang_gd', bankId: 'QB-EN', current: 'v2026.5', target: 'v2026.6', progress: 72, conflicts: 3, status: '同步中' },
|
||||
{ tenantId: 'tenant_mingtu_hn', bankId: 'QB-EN', current: 'v2026.3', target: 'v2026.6', progress: 38, conflicts: 12, status: '同步失败' },
|
||||
],
|
||||
crmConfigs: [
|
||||
{ id: 'CRM-GX-TJ', tenantId: 'tenant_gongxue_tj', provider: '标准 CRM', endpoint: 'https://crm.gongxue.cn/open/leads', assignmentMode: '按地区轮转', pool: '天津升本线索池', enabled: true, status: '正常', timeout: 8, retry: 3, updatedAt: '2026-07-23 09:12' },
|
||||
{ id: 'CRM-QH-GD', tenantId: 'tenant_qihang_gd', provider: '自建 CRM', endpoint: 'https://crm.qihangzsb.cn/api/leads', assignmentMode: '按校区容量', pool: '广东转化队列', enabled: true, status: '异常', timeout: 10, retry: 5, updatedAt: '2026-07-22 18:40' },
|
||||
{ id: 'CRM-BS-SD', tenantId: 'tenant_bosi_sd', provider: '手工队列', endpoint: '-', assignmentMode: '手工分配', pool: '试用线索池', enabled: false, status: '未启用', timeout: 8, retry: 0, updatedAt: '2026-07-18 11:30' },
|
||||
],
|
||||
crmLeads: [
|
||||
{ id: 'LEAD-260723-018', tenantId: 'tenant_qihang_gd', source: '公开课报名', student: '陈同学', phone: '139****8201', subject: '广东专升本英语', owner: '广州一校区', status: '推送失败', attempts: 4, lastError: 'CRM 422: campus required', createdAt: '2026-07-23 09:48' },
|
||||
{ id: 'LEAD-260723-016', tenantId: 'tenant_gongxue_tj', source: '题库体验页', student: '王同学', phone: '138****2098', subject: '高等数学', owner: '天津转化组', status: '已推送', attempts: 1, lastError: '', createdAt: '2026-07-23 09:26' },
|
||||
{ id: 'LEAD-260722-044', tenantId: 'tenant_mingtu_hn', source: '学生端留资', student: '李同学', phone: '135****6172', subject: '河南专升本语文', owner: '默认队列', status: '待推送', attempts: 0, lastError: '', createdAt: '2026-07-22 21:05' },
|
||||
],
|
||||
smsChannels: [
|
||||
{ id: 'SMS-ALI-GX', tenantId: 'tenant_gongxue_tj', provider: '阿里云短信', signature: '恭学教育', scene: 'login,notice,dunning', status: '正常', enabled: true, secretRef: 'tenant/gongxue/sms/aliyun', quota: 50000, used: 21860, updatedAt: '2026-07-22 16:10' },
|
||||
{ id: 'SMS-TENCENT-QH', tenantId: 'tenant_qihang_gd', provider: '腾讯云短信', signature: '启航专升本', scene: 'login,marketing', status: '模板待审', enabled: true, secretRef: 'tenant/qihang/sms/tencent', quota: 80000, used: 64220, updatedAt: '2026-07-21 19:30' },
|
||||
{ id: 'SMS-GENERIC-BS', tenantId: 'tenant_bosi_sd', provider: '通用短信网关', signature: '博思升本', scene: 'login', status: '未启用', enabled: false, secretRef: 'redacted', quota: 10000, used: 0, updatedAt: '2026-07-18 10:00' },
|
||||
],
|
||||
smsTemplates: [
|
||||
{ id: 'TPL-LOGIN-001', channelId: 'SMS-ALI-GX', code: 'student_login_otp', name: '学生登录验证码', type: '登录验证', status: '已通过', lastSend: '2026-07-23 09:58', successRate: '99.2%' },
|
||||
{ id: 'TPL-DUN-001', channelId: 'SMS-ALI-GX', code: 'invoice_due_notice', name: '租户账单到期提醒', type: '平台催缴', status: '已通过', lastSend: '2026-07-23 09:20', successRate: '98.7%' },
|
||||
{ id: 'TPL-MKT-009', channelId: 'SMS-TENCENT-QH', code: 'open_class_reminder', name: '公开课提醒', type: '营销触达', status: '审核中', lastSend: '-', successRate: '-' },
|
||||
],
|
||||
paymentApps: [
|
||||
{ id: 'PAY-GX-STUDENT', tenantId: 'tenant_gongxue_tj', appName: '恭学学生端收款', provider: 'wechat_pay', mode: 'production', status: '正常', settlement: 'T+1 至租户商户号', secretRef: 'tenant/gongxue/pay/wechat', updatedAt: '2026-07-22 15:08' },
|
||||
{ id: 'PAY-QH-ALI', tenantId: 'tenant_qihang_gd', appName: '启航课程收款', provider: 'alipay', mode: 'production', status: '证书即将过期', settlement: 'T+1 至租户支付宝商户', secretRef: 'tenant/qihang/pay/alipay', updatedAt: '2026-07-20 13:40' },
|
||||
{ id: 'PAY-BS-MANUAL', tenantId: 'tenant_bosi_sd', appName: '试用线下收款', provider: 'manual', mode: 'sandbox', status: '未启用', settlement: '人工确认', secretRef: 'redacted', updatedAt: '2026-07-18 10:20' },
|
||||
],
|
||||
paymentEvents: [
|
||||
{ id: 'PEVT-260723-031', appId: 'PAY-GX-STUDENT', orderNo: 'ORD-260723-2048', amount: 398, status: '支付成功', channel: '微信 JSAPI', notified: '已回调', updatedAt: '2026-07-23 09:56' },
|
||||
{ id: 'PEVT-260723-029', appId: 'PAY-QH-ALI', orderNo: 'ORD-260723-2021', amount: 1280, status: '回调失败', channel: '支付宝 WAP', notified: '重试中', updatedAt: '2026-07-23 09:49' },
|
||||
{ id: 'PEVT-260722-188', appId: 'PAY-BS-MANUAL', orderNo: 'ORD-260722-1098', amount: 99, status: '待确认', channel: '线下转账', notified: '无', updatedAt: '2026-07-22 20:18' },
|
||||
],
|
||||
staff: [
|
||||
{ id: 'STAFF-01', authUserId: 'auth-chenhao', username: 'chenhao', name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' },
|
||||
{ id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question_bank:read'], status: '正常', lastActive: '8分钟前' },
|
||||
@@ -167,6 +217,12 @@ function normalizeData(saved) {
|
||||
});
|
||||
normalized.usage = normalized.usage.map(item => ({ overage: [], estimated: 0, source: 'Worker 自动采集', ...item }));
|
||||
normalized.grants = normalized.grants.map(item => ({ allowedPlanCodes: [], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: [], localEdit: false, ...item }));
|
||||
normalized.crmConfigs = normalized.crmConfigs.map(item => ({ enabled: false, status: '未启用', retry: 0, ...item }));
|
||||
normalized.crmLeads = normalized.crmLeads.map(item => ({ attempts: 0, lastError: '', ...item }));
|
||||
normalized.smsChannels = normalized.smsChannels.map(item => ({ enabled: false, quota: 0, used: 0, ...item }));
|
||||
normalized.smsTemplates = normalized.smsTemplates.map(item => ({ lastSend: '-', successRate: '-', ...item }));
|
||||
normalized.paymentApps = normalized.paymentApps.map(item => ({ mode: 'sandbox', status: '未启用', ...item }));
|
||||
normalized.paymentEvents = normalized.paymentEvents.map(item => ({ notified: '无', ...item }));
|
||||
normalized.staff = normalized.staff.map(item => ({ permissions: [], status: '正常', lastActive: '尚未登录', ...item }));
|
||||
normalized.alerts = normalized.alerts.map(item => ({ evidence: [], note: '', status: 'open', ...item }));
|
||||
normalized.tasks = normalized.tasks.map(item => ({ status: 'failed', error: '', resultTarget: '任务中心', updatedAt: item.createdAt || todayTime(), ...item }));
|
||||
@@ -204,6 +260,7 @@ if (platformApi?.isEnabled()) {
|
||||
const state = {
|
||||
view: 'overview', previousView: '', selectedTenant: data.tenants[0]?.id || '', tenantFilter: 'all', tenantPlan: 'all', tenantSearch: '', globalSearch: '',
|
||||
billingTab: 'invoices', bankTab: 'catalog', bankIncludeTenant: false, selectedBank: data.banks[0]?.id || '',
|
||||
crmTab: 'configs', smsTab: 'channels', paymentTab: 'apps',
|
||||
invoiceSearch: '', invoiceStatus: 'all', bankSearch: '', staffSearch: '', usagePeriod: '2026-07', auditRange: '24h', auditSeverity: 'all', auditSearch: '', alertStatus: 'all', selectedAlert: '', selectedInvoice: '',
|
||||
role: 'super', scenario: 'normal', response: 'success', modal: '', modalData: {}, submitting: '', reading: '', contractResults: {}, history: [],
|
||||
apiConnection: platformApi?.isEnabled() ? 'connecting' : 'mock', apiLoadedAt: '', apiLoadedOperations: 0, apiFailedOperations: 0,
|
||||
@@ -239,6 +296,21 @@ function backendStatusLabel() {
|
||||
}
|
||||
return '后端不可用 · 已保留静态演示';
|
||||
}
|
||||
function backendErrorText(error, fallback = '平台接口请求失败') {
|
||||
if (!error) return fallback;
|
||||
const pieces = [error.message || fallback];
|
||||
if (error.code) pieces.push(`code=${error.code}`);
|
||||
if (error.requestId) pieces.push(`requestId=${error.requestId}`);
|
||||
return pieces.join(' · ');
|
||||
}
|
||||
function apiItems(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.items)) return payload.items;
|
||||
return [];
|
||||
}
|
||||
function apiItem(payload) {
|
||||
return payload?.item || payload || {};
|
||||
}
|
||||
function updateBackendStatus() {
|
||||
const status = document.querySelector('.top-status > span');
|
||||
if (!status) return;
|
||||
@@ -303,7 +375,7 @@ async function hydrateFromBackend({ silent = false } = {}) {
|
||||
state.apiConnection = 'degraded';
|
||||
state.apiFailedOperations = 1;
|
||||
render();
|
||||
if (!silent) showToast(`${error.message || '后端连接失败'} · 已保留静态演示`, 'error');
|
||||
if (!silent) showToast(`${backendErrorText(error, '后端连接失败')} · 已保留静态演示`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +399,7 @@ async function hydrateTenantDetail(tenantId, { force = false } = {}) {
|
||||
loadedTenantDetails.add(tenantId);
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast(`${error.message || '租户详情加载失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error');
|
||||
showToast(backendErrorText(error, '租户详情加载失败'), 'error');
|
||||
}
|
||||
}
|
||||
function icon(name) { return `<i data-lucide="${name}"></i>`; }
|
||||
@@ -389,6 +461,22 @@ function rowActionAccessibleName(control) {
|
||||
const person = data.staff.find(item => item.id === control.dataset.staffId);
|
||||
return `编辑员工 ${recordLabel(person?.name, person?.id || control.dataset.staffId)}`;
|
||||
}
|
||||
if (action === 'edit-crm-config') {
|
||||
const config = data.crmConfigs.find(item => item.id === control.dataset.crmId);
|
||||
return `编辑 CRM 接入 ${recordLabel(tenantById(config?.tenantId)?.name, config?.id || control.dataset.crmId)}`;
|
||||
}
|
||||
if (action === 'retry-crm-lead') {
|
||||
const lead = data.crmLeads.find(item => item.id === control.dataset.leadId);
|
||||
return `重试 CRM 线索 ${recordLabel(lead?.student, lead?.id || control.dataset.leadId)}`;
|
||||
}
|
||||
if (action === 'edit-sms-channel') {
|
||||
const channel = data.smsChannels.find(item => item.id === control.dataset.smsId);
|
||||
return `编辑短信渠道 ${recordLabel(channel?.signature, channel?.id || control.dataset.smsId)}`;
|
||||
}
|
||||
if (action === 'edit-payment-app') {
|
||||
const paymentApp = data.paymentApps.find(item => item.id === control.dataset.paymentId);
|
||||
return `编辑支付应用 ${recordLabel(paymentApp?.appName, paymentApp?.id || control.dataset.paymentId)}`;
|
||||
}
|
||||
if (action === 'edit-audit-channel') {
|
||||
const channel = data.auditChannels.find(item => item.id === control.dataset.channelId);
|
||||
return `编辑审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
|
||||
@@ -415,6 +503,18 @@ function switchAccessibleName(control) {
|
||||
const person = data.staff.find(item => item.id === control.dataset.staffId);
|
||||
return `${enabled ? '停用员工' : '启用员工'} ${recordLabel(person?.name, person?.id || control.dataset.staffId)}${enabled ? '并撤销会话' : ''}`;
|
||||
}
|
||||
if (action === 'toggle-crm-config') {
|
||||
const config = data.crmConfigs.find(item => item.id === control.dataset.crmId);
|
||||
return `${enabled ? '停用' : '启用'} CRM 接入 ${recordLabel(tenantById(config?.tenantId)?.name, config?.id || control.dataset.crmId)}`;
|
||||
}
|
||||
if (action === 'toggle-sms-channel') {
|
||||
const channel = data.smsChannels.find(item => item.id === control.dataset.smsId);
|
||||
return `${enabled ? '停用' : '启用'}短信渠道 ${recordLabel(channel?.signature, channel?.id || control.dataset.smsId)}`;
|
||||
}
|
||||
if (action === 'toggle-payment-app') {
|
||||
const paymentApp = data.paymentApps.find(item => item.id === control.dataset.paymentId);
|
||||
return `${enabled ? '停用' : '启用'}支付应用 ${recordLabel(paymentApp?.appName, paymentApp?.id || control.dataset.paymentId)}`;
|
||||
}
|
||||
if (action === 'toggle-audit-channel') {
|
||||
const channel = data.auditChannels.find(item => item.id === control.dataset.channelId);
|
||||
return `${enabled ? '停用' : '启用'}审计通知渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`;
|
||||
@@ -500,7 +600,7 @@ function runContractRead({ key, actionId, operationId, title, apply, suppliedReq
|
||||
Promise.resolve(typeof liveRequest === 'function' ? liveRequest() : platformApi.request(operationId))
|
||||
.then(payload => {
|
||||
state.reading = '';
|
||||
const requestIdValue = payload?.meta?.requestId || id;
|
||||
const requestIdValue = payload?.meta?.requestId || payload?.requestId || payload?.traceId || id;
|
||||
const detail = apply?.(payload) || '查询结果已刷新';
|
||||
state.contractResults[key] = { status: 'success', title: `${title}完成`, detail, requestId: requestIdValue };
|
||||
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'success', operationId, requestId: requestIdValue });
|
||||
@@ -509,7 +609,7 @@ function runContractRead({ key, actionId, operationId, title, apply, suppliedReq
|
||||
.catch(error => {
|
||||
state.reading = '';
|
||||
const requestIdValue = error.requestId || id;
|
||||
state.contractResults[key] = { status: 'error', title: `${title}失败`, detail: error.message || '平台接口请求失败', requestId: requestIdValue };
|
||||
state.contractResults[key] = { status: 'error', title: `${title}失败`, detail: backendErrorText(error), requestId: requestIdValue };
|
||||
if (actionId) window.recordPlatformSpecResult?.(actionId, { result: 'failed', operationId, requestId: requestIdValue });
|
||||
render();
|
||||
});
|
||||
@@ -735,6 +835,35 @@ function renderBank() {
|
||||
${state.bankTab === 'catalog' ? `<div class="bank-layout"><aside class="bank-tree"><header><strong>公共题库目录</strong><span>${bankRows.length} 个</span></header>${bankRows.map(bank => `<button class="${bank.id === selected.id ? 'is-active' : ''}" data-bank-select="${bank.id}">${icon('library-big')}<span><strong>${escapeHtml(bank.name)}</strong><small>${formatNumber(bank.questions)} 题 · ${bank.source}</small></span>${icon('chevron-right')}</button>`).join('') || '<p class="tree-empty">没有匹配题库</p>'}${state.bankIncludeTenant ? `<button>${icon('building-2')}<span><strong>启航租户本地库</strong><small>68,420 题 · 只读来源查看</small></span>${icon('chevron-right')}</button>` : ''}</aside><section class="bank-content"><header><div><span>${escapeHtml(selected.catalog)}</span><h2>${escapeHtml(selected.name)}</h2><p>当前版本 ${selected.version} · 来源 ${selected.source}</p></div><button class="button" data-action="preview-bank-sync">采用与同步</button></header><div class="bank-stats"><div><span>题目</span><strong>${formatNumber(selected.questions)}</strong></div><div><span>授权规则</span><strong>${data.grants.filter(grant => grant.bankId === selected.id).length}</strong></div><div><span>采用租户</span><strong>${data.bankSync.filter(sync => sync.bankId === selected.id).length}</strong></div><div><span>当前状态</span><strong>${selected.status}</strong></div></div><section class="version-card"><div><span class="status success">${selected.status}</span><h3>${selected.version}</h3><p>公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。</p><small>更新时间 2026-07-23 09:00 CST</small></div><div><button class="button" data-action="preview-bank-sync">查看同步</button><button class="button primary" data-action="new-grant">授权租户</button></div></section></section></div>` : state.bankTab === 'grants' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>规则</th><th>题库</th><th>授权范围</th><th>套餐 / 租户</th><th>地区 / 科目</th><th>有效期</th><th>本地改编</th><th>状态</th><th></th></tr></thead><tbody>${data.grants.filter(grant => !query || [grant.id, bankById(grant.bankId)?.name, grant.scope, ...grant.allowedRegionIds, ...grant.allowedSubjectIds].some(value => String(value || '').toLowerCase().includes(query))).map(grant => `<tr><td><code>${grant.id}</code></td><td><strong>${escapeHtml(bankById(grant.bankId)?.name || '-')}</strong></td><td>${grant.scope}</td><td>${grant.allowedPlanCodes.length ? grant.allowedPlanCodes.map(code => planByCode(code)?.name || code).join('、') : grant.allowedTenantIds.map(id => tenantById(id)?.name || id).join('、') || '-'}</td><td>${[...grant.allowedRegionIds, ...grant.allowedSubjectIds].join(' / ') || '不限'}</td><td>${grant.startsAt} 至 ${grant.expiresAt || '长期'}</td><td>${grant.localEdit ? '允许' : '只读采用'}</td><td>${statusBadge(grant.status)}</td><td><div class="row-actions"><button class="row-action" data-action="edit-grant" data-grant-id="${grant.id}">${icon('pencil')}</button><button class="switch ${grant.status === '启用' ? 'is-on' : ''}" data-action="toggle-grant" data-grant-id="${grant.id}"><i></i></button></div></td></tr>`).join('')}</tbody></table></section>` : `<section class="table-panel"><div class="table-head"><div><h2>租户采用与同步状态</h2><span>源版本、任务状态与冲突均来自专属查询</span></div><button class="button" data-action="refresh-bank-sync" ${state.reading === 'bank-sync' ? 'disabled' : ''}>${state.reading === 'bank-sync' ? `${icon('loader-circle')}刷新中` : `${icon('refresh-cw')}刷新同步状态`}</button></div><table class="platform-table"><thead><tr><th>租户</th><th>题库</th><th>当前版本</th><th>目标版本</th><th>同步进度</th><th>冲突</th><th>状态</th><th></th></tr></thead><tbody>${data.bankSync.filter(sync => !query || [tenantById(sync.tenantId)?.name, bankById(sync.bankId)?.name, sync.current, sync.target, sync.status].some(value => String(value || '').toLowerCase().includes(query))).map(sync => `<tr><td>${escapeHtml(tenantById(sync.tenantId)?.name || '-')}</td><td>${escapeHtml(bankById(sync.bankId)?.name || '-')}</td><td>${sync.current}</td><td>${sync.target}</td><td><div class="usage-cell"><span>${sync.progress}%</span><i><b style="width:${sync.progress}%"></b></i></div></td><td>${sync.conflicts}</td><td>${statusBadge(sync.status)}</td><td><button class="row-action" data-action="sync-detail" data-sync-key="${sync.tenantId}:${sync.bankId}">${icon('chevron-right')}</button></td></tr>`).join('')}</tbody></table></section>`}</div>`;
|
||||
}
|
||||
|
||||
function renderCrm() {
|
||||
const activeConfigs = data.crmConfigs.filter(item => item.enabled).length;
|
||||
const failedLeads = data.crmLeads.filter(item => item.status === '推送失败').length;
|
||||
const notice = platformApi?.isEnabled() ? '已接入平台 CRM API,可集中维护租户线索推送配置、失败队列和同步日志。' : '集中管理租户 CRM 接入、线索队列与分配策略。';
|
||||
return `<div class="page-shell">${pageHead('CRM 接入与线索流转', notice, `${canWrite('platform:crm:write') ? `<button class="button primary" data-action="new-crm-config">${icon('plus')}接入配置</button>` : ''}`)}
|
||||
<section class="usage-kpis"><div><span>已启用接入</span><strong>${activeConfigs}</strong><small>共 ${data.crmConfigs.length} 家租户配置</small><i><b style="width:${Math.min(100, activeConfigs / Math.max(1, data.crmConfigs.length) * 100)}%"></b></i></div><div><span>待推送线索</span><strong>${data.crmLeads.filter(item => item.status === '待推送').length}</strong><small>来自学生端留资与活动报名</small><i><b style="width:36%"></b></i></div><div><span>推送失败</span><strong>${failedLeads}</strong><small>进入重试或死信处理</small><i class="orange"><b style="width:${failedLeads ? 45 : 0}%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:crm:write') ? '可维护' : '只读'}</strong><small><code>platform:crm:read/write</code></small><i><b style="width:${hasPermission('platform:crm:write') ? 100 : 50}%"></b></i></div></section>
|
||||
<nav class="billing-tabs"><button class="${state.crmTab === 'configs' ? 'is-active' : ''}" data-crm-tab="configs">接入配置</button><button class="${state.crmTab === 'leads' ? 'is-active' : ''}" data-crm-tab="leads">线索队列</button><button class="${state.crmTab === 'rules' ? 'is-active' : ''}" data-crm-tab="rules">分配策略</button></nav>
|
||||
${state.crmTab === 'configs' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>Provider</th><th>Endpoint</th><th>分配方式</th><th>线索池</th><th>超时 / 重试</th><th>状态</th><th></th></tr></thead><tbody>${data.crmConfigs.map(config => `<tr><td><strong>${escapeHtml(tenantById(config.tenantId)?.name || '-')}</strong><small>${escapeHtml(config.id)}</small></td><td>${escapeHtml(config.provider)}</td><td><code>${escapeHtml(config.endpoint)}</code></td><td>${escapeHtml(config.assignmentMode)}</td><td>${escapeHtml(config.pool)}</td><td>${config.timeout}s / ${config.retry} 次</td><td>${statusBadge(config.status)}</td><td><div class="row-actions">${canWrite('platform:crm:write') ? `<button class="row-action" data-action="edit-crm-config" data-crm-id="${config.id}">${icon('pencil')}</button><button class="switch ${config.enabled ? 'is-on' : ''}" data-action="toggle-crm-config" data-crm-id="${config.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.crmTab === 'leads' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>线索</th><th>租户</th><th>来源 / 科目</th><th>负责人</th><th>尝试次数</th><th>状态</th><th>错误摘要</th><th></th></tr></thead><tbody>${data.crmLeads.map(lead => `<tr><td><strong>${escapeHtml(lead.student)}</strong><small>${lead.id} · ${lead.phone}</small></td><td>${escapeHtml(tenantById(lead.tenantId)?.name || '-')}</td><td>${escapeHtml(lead.source)}<small>${escapeHtml(lead.subject)}</small></td><td>${escapeHtml(lead.owner)}</td><td>${lead.attempts}</td><td>${statusBadge(lead.status)}</td><td>${escapeHtml(lead.lastError || '-')}</td><td>${lead.status === '推送失败' && canWrite('platform:crm:write') ? `<button class="row-action" data-action="retry-crm-lead" data-lead-id="${lead.id}">${icon('rotate-ccw')}</button>` : ''}</td></tr>`).join('')}</tbody></table></section>` : `<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台默认策略</h2><span>租户可在套餐授权后覆盖</span></div></div>${[['按地区轮转', '地区、校区、课程标签优先,适合多校区机构'], ['按校区容量', '按顾问容量与当日跟进上限分配'], ['手工分配', '只进入队列,由租户管理员手工认领']].map(([name, desc]) => `<article class="role-row"><span>${icon('route')}</span><div><strong>${name}</strong><small>${desc}</small></div><b>启用</b><button class="button" data-action="crm-rule-detail">查看</button></article>`).join('')}</section><section class="panel"><div class="panel-head"><div><h2>线索同步边界</h2><span>平台统一监控推送质量和异常重试</span></div></div><div class="policy-timeline"><p><b>接入配置</b><span>维护 endpoint、secret 引用、超时和重试策略</span></p><p><b>线索队列</b><span>集中查看待推送、失败和重试中的线索</span></p><p><b>同步日志</b><span>保留请求结果、错误摘要和审计线索</span></p></div></section></div>`}</div>`;
|
||||
}
|
||||
|
||||
function renderSms() {
|
||||
const sent = data.smsChannels.reduce((sum, item) => sum + item.used, 0);
|
||||
const quota = data.smsChannels.reduce((sum, item) => sum + item.quota, 0);
|
||||
const notice = platformApi?.isEnabled() ? '已接入平台 SMS API,渠道、模板和发送日志来自后端真实数据。' : '集中管理租户短信渠道、模板审核状态和发送记录。';
|
||||
return `<div class="page-shell">${pageHead('SMS 短信服务', notice, `${canWrite('platform:sms:write') ? `<button class="button primary" data-action="new-sms-channel">${icon('plus')}短信渠道</button>` : ''}`)}
|
||||
<section class="usage-kpis"><div><span>启用渠道</span><strong>${data.smsChannels.filter(item => item.enabled).length}</strong><small>租户自有短信服务</small><i><b style="width:66%"></b></i></div><div><span>本月发送</span><strong>${formatNumber(sent)}</strong><small>总额度 ${formatNumber(quota)}</small><i><b style="width:${quota ? Math.min(100, sent / quota * 100) : 0}%"></b></i></div><div><span>模板通过</span><strong>${data.smsTemplates.filter(item => item.status === '已通过').length}</strong><small>${data.smsTemplates.length} 个模板</small><i><b style="width:67%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:sms:write') ? '可维护' : '只读'}</strong><small><code>platform:sms:read/write</code></small><i><b style="width:${hasPermission('platform:sms:write') ? 100 : 50}%"></b></i></div></section>
|
||||
<nav class="billing-tabs"><button class="${state.smsTab === 'channels' ? 'is-active' : ''}" data-sms-tab="channels">短信渠道</button><button class="${state.smsTab === 'templates' ? 'is-active' : ''}" data-sms-tab="templates">短信模板</button><button class="${state.smsTab === 'logs' ? 'is-active' : ''}" data-sms-tab="logs">发送日志</button></nav>
|
||||
${state.smsTab === 'channels' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>Provider</th><th>签名</th><th>场景</th><th>Secret Ref</th><th>额度使用</th><th>状态</th><th></th></tr></thead><tbody>${data.smsChannels.map(channel => `<tr><td><strong>${escapeHtml(tenantById(channel.tenantId)?.name || '-')}</strong><small>${channel.id}</small></td><td>${escapeHtml(channel.provider)}</td><td>${escapeHtml(channel.signature)}</td><td>${escapeHtml(channel.scene)}</td><td><code>${escapeHtml(channel.secretRef)}</code></td><td><div class="usage-cell"><span>${formatNumber(channel.used)} / ${formatNumber(channel.quota)}</span><i><b style="width:${channel.quota ? Math.min(100, channel.used / channel.quota * 100) : 0}%"></b></i></div></td><td>${statusBadge(channel.status)}</td><td><div class="row-actions">${canWrite('platform:sms:write') ? `<button class="row-action" data-action="edit-sms-channel" data-sms-id="${channel.id}">${icon('pencil')}</button><button class="switch ${channel.enabled ? 'is-on' : ''}" data-action="toggle-sms-channel" data-sms-id="${channel.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.smsTab === 'templates' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>模板</th><th>渠道</th><th>代码</th><th>类型</th><th>最近发送</th><th>成功率</th><th>状态</th></tr></thead><tbody>${data.smsTemplates.map(template => { const channel = data.smsChannels.find(item => item.id === template.channelId); return `<tr><td><strong>${escapeHtml(template.name)}</strong><small>${template.id}</small></td><td>${escapeHtml(channel?.signature || '-')}</td><td><code>${escapeHtml(template.code)}</code></td><td>${escapeHtml(template.type)}</td><td>${escapeHtml(template.lastSend)}</td><td>${escapeHtml(template.successRate)}</td><td>${statusBadge(template.status)}</td></tr>`; }).join('')}</tbody></table></section>` : `<section class="table-panel"><table class="platform-table"><thead><tr><th>事件</th><th>租户</th><th>模板</th><th>手机号</th><th>Provider</th><th>时间</th><th>状态</th></tr></thead><tbody>${data.smsTemplates.slice(0, 3).map((template, index) => { const channel = data.smsChannels.find(item => item.id === template.channelId); return `<tr><td><code>SMSLOG-260723-00${index + 1}</code></td><td>${escapeHtml(tenantById(channel?.tenantId)?.name || '-')}</td><td>${escapeHtml(template.name)}</td><td>13${index + 6}****${8200 + index}</td><td>${escapeHtml(channel?.provider || '-')}</td><td>${escapeHtml(template.lastSend || '-')}</td><td>${statusBadge(template.status === '已通过' ? '发送成功' : '待发送')}</td></tr>`; }).join('')}</tbody></table></section>`}</div>`;
|
||||
}
|
||||
|
||||
function renderPaymentSettings() {
|
||||
const activeApps = data.paymentApps.filter(item => item.status === '正常').length;
|
||||
const notice = platformApi?.isEnabled() ? '已接入平台支付设置 API,可维护平台收款应用、租户代管应用和支付事件。' : '集中管理平台收款、租户支付代管和回调健康。';
|
||||
return `<div class="page-shell">${pageHead('支付设置', notice, `${canWrite('platform:payment:write') ? `<button class="button primary" data-action="new-payment-app">${icon('plus')}支付应用</button>` : ''}`)}
|
||||
<section class="usage-kpis"><div><span>正常应用</span><strong>${activeApps}</strong><small>共 ${data.paymentApps.length} 个收款应用</small><i><b style="width:${Math.min(100, activeApps / Math.max(1, data.paymentApps.length) * 100)}%"></b></i></div><div><span>本日支付事件</span><strong>${data.paymentEvents.length}</strong><small>含成功、回调失败与人工确认</small><i><b style="width:52%"></b></i></div><div><span>回调异常</span><strong>${data.paymentEvents.filter(item => item.status === '回调失败').length}</strong><small>需要财务或运维介入</small><i class="orange"><b style="width:34%"></b></i></div><div><span>权限</span><strong>${hasPermission('platform:payment:write') ? '可维护' : '只读'}</strong><small><code>platform:payment:read/write</code></small><i><b style="width:${hasPermission('platform:payment:write') ? 100 : 50}%"></b></i></div></section>
|
||||
<nav class="billing-tabs"><button class="${state.paymentTab === 'apps' ? 'is-active' : ''}" data-payment-tab="apps">支付应用</button><button class="${state.paymentTab === 'events' ? 'is-active' : ''}" data-payment-tab="events">支付事件</button><button class="${state.paymentTab === 'settlement' ? 'is-active' : ''}" data-payment-tab="settlement">结算与风控</button></nav>
|
||||
${state.paymentTab === 'apps' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>租户</th><th>应用</th><th>Provider</th><th>模式</th><th>结算</th><th>Secret Ref</th><th>状态</th><th></th></tr></thead><tbody>${data.paymentApps.map(appItem => `<tr><td><strong>${escapeHtml(tenantById(appItem.tenantId)?.name || '-')}</strong><small>${appItem.id}</small></td><td>${escapeHtml(appItem.appName)}</td><td><code>${escapeHtml(appItem.provider)}</code></td><td>${escapeHtml(appItem.mode)}</td><td>${escapeHtml(appItem.settlement)}</td><td><code>${escapeHtml(appItem.secretRef)}</code></td><td>${statusBadge(appItem.status)}</td><td><div class="row-actions">${canWrite('platform:payment:write') ? `<button class="row-action" data-action="edit-payment-app" data-payment-id="${appItem.id}">${icon('pencil')}</button><button class="switch ${appItem.status !== '未启用' ? 'is-on' : ''}" data-action="toggle-payment-app" data-payment-id="${appItem.id}"><i></i></button>` : ''}</div></td></tr>`).join('')}</tbody></table></section>` : state.paymentTab === 'events' ? `<section class="table-panel"><table class="platform-table"><thead><tr><th>事件</th><th>应用</th><th>订单号</th><th>金额</th><th>渠道</th><th>回调</th><th>时间</th><th>状态</th></tr></thead><tbody>${data.paymentEvents.map(event => { const appItem = data.paymentApps.find(item => item.id === event.appId); return `<tr><td><code>${event.id}</code></td><td>${escapeHtml(appItem?.appName || '-')}<small>${escapeHtml(tenantById(appItem?.tenantId)?.name || '-')}</small></td><td><code>${escapeHtml(event.orderNo)}</code></td><td><strong>${money(event.amount)}</strong></td><td>${escapeHtml(event.channel)}</td><td>${escapeHtml(event.notified)}</td><td>${escapeHtml(event.updatedAt)}</td><td>${statusBadge(event.status)}</td></tr>`; }).join('')}</tbody></table></section>` : `<div class="staff-layout"><section class="panel"><div class="panel-head"><div><h2>平台风控规则</h2><span>配置证书、回调、人工确认等支付安全策略</span></div></div>${[['证书过期提醒', '支付证书 14 天内过期生成高危告警'], ['回调失败重试', '按 1m/5m/30m/2h 退避重试并记录审计'], ['人工确认保护', '线下收款必须二次确认并保留流水号脱敏值']].map(([name, desc]) => `<article class="role-row"><span>${icon('shield-check')}</span><div><strong>${name}</strong><small>${desc}</small></div><b>启用</b><button class="button" data-action="payment-rule-detail">查看</button></article>`).join('')}</section><section class="panel"><div class="panel-head"><div><h2>支付能力边界</h2><span>区分平台收款和租户收款代管</span></div></div><div class="policy-timeline"><p><b>平台收款应用</b><span>用于套餐、模块和增值服务收款</span></p><p><b>租户代管应用</b><span>维护学生端收款渠道和回调地址</span></p><p><b>支付事件</b><span>只读跟踪交易、退款、回调和人工确认状态</span></p></div></section></div>`}</div>`;
|
||||
}
|
||||
|
||||
function renderStaff() {
|
||||
const query = state.staffSearch.trim().toLowerCase();
|
||||
const staffRows = data.staff.filter(person => !query || [person.name, person.username, person.email, person.phone, person.role, person.status].some(value => String(value || '').toLowerCase().includes(query)));
|
||||
@@ -764,7 +893,7 @@ function renderAlerts() {
|
||||
<aside class="channel-panel"><section class="panel"><div class="panel-head"><div><h2>通知渠道</h2><span>${data.auditChannels.filter(channel => channel.enabled).length} 个启用</span></div></div>${data.auditChannels.map(channel => `<article class="channel-row"><span>${icon(channel.provider === '邮件' ? 'mail' : channel.provider === '短信' ? 'message-square' : 'webhook')}</span><div><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.endpoint)} · ≥${channel.minSeverity} · ${channel.maxAttempts} 次</small></div><b class="status ${tone(channel.lastStatus)}">${channel.lastStatus}</b><div class="row-actions"><button class="row-action" data-action="edit-audit-channel" data-channel-id="${channel.id}">${icon('pencil')}</button><button class="switch ${channel.enabled ? 'is-on' : ''}" data-action="toggle-audit-channel" data-channel-id="${channel.id}"><i></i></button></div></article>`).join('')}</section><section class="panel failure-panel"><div class="panel-head"><div><h2>通知失败事件</h2><span>最近24小时</span></div></div><div>${data.notificationEvents.filter(event => event.status === '失败').map(event => `<article><strong>${escapeHtml(event.subject)}</strong><p>${escapeHtml(event.error)} · 已尝试 ${event.attempts} 次</p><div class="inline-actions"><button class="button" data-notification-detail="${event.id}">查看详情</button><button class="button" data-action="retry-notification" data-event-id="${event.id}">立即重试</button></div></article>`).join('') || '<p>没有通知失败事件。</p>'}</div></section></aside></div></div>`;
|
||||
}
|
||||
|
||||
const templates = { overview: renderOverview, tenants: renderTenants, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, subscriptions: renderSubscriptions, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, staff: renderStaff, audit: renderAudit, alerts: renderAlerts };
|
||||
const templates = { overview: renderOverview, tenants: renderTenants, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, subscriptions: renderSubscriptions, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, crm: renderCrm, sms: renderSms, paymentSettings: renderPaymentSettings, staff: renderStaff, audit: renderAudit, alerts: renderAlerts };
|
||||
|
||||
function render() {
|
||||
renderNav();
|
||||
@@ -836,13 +965,13 @@ function closeModal(options = {}) {
|
||||
return true;
|
||||
}
|
||||
function navigationSnapshot() {
|
||||
return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, bankIncludeTenant: state.bankIncludeTenant, bankSearch: state.bankSearch, selectedBank: state.selectedBank, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY };
|
||||
return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, crmTab: state.crmTab, smsTab: state.smsTab, paymentTab: state.paymentTab, bankIncludeTenant: state.bankIncludeTenant, bankSearch: state.bankSearch, selectedBank: state.selectedBank, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY };
|
||||
}
|
||||
function syncNavigationState() { if (history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: navigationSnapshot() }, '', location.href); }
|
||||
function navigate(view, options = {}) {
|
||||
if (!pageMeta[view]) return;
|
||||
if (platformApi?.isEnabled() && !API_CONNECTED_PAGES.has(view)) {
|
||||
showToast('该模块尚未接入真实后端,本阶段不会回退到 Mock 数据', 'error');
|
||||
showToast('该模块当前不可用,请先完成后端配置后再访问', 'error');
|
||||
return false;
|
||||
}
|
||||
const perform = () => {
|
||||
@@ -941,8 +1070,8 @@ async function simulateWrite({ key, permission, validate, apply, success, close
|
||||
} catch (error) {
|
||||
const id = error.requestId || requestId();
|
||||
state.submitting = '';
|
||||
showToast(`${error.message || '提交失败'} · ${id}`, 'error');
|
||||
if (error.status === 400) showFormErrors({ server: `${error.message || '服务端字段校验失败'}(${id})` });
|
||||
showToast(backendErrorText(error, '提交失败'), 'error');
|
||||
if (error.status === 400) showFormErrors({ server: backendErrorText(error, '服务端字段校验失败') });
|
||||
if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = submitMarkup; refreshIcons(); }
|
||||
}
|
||||
};
|
||||
@@ -961,7 +1090,7 @@ async function executeBackendMutation(operationId, input, apply, { refresh = tru
|
||||
if (refresh) hydrateFromBackend({ silent: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast(`${error.message || '提交失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error');
|
||||
showToast(backendErrorText(error, '提交失败'), 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1044,27 +1173,40 @@ const modalTemplates = {
|
||||
const grant = data.grants.find(item => item.id === state.modalData.grantId) || {};
|
||||
return modalFrame(grant.id ? '编辑公共题库授权' : '新建公共题库授权', '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID。', `<form id="grantForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${grant.id || ''}" /><div class="form-grid"><label><span>来源题库 *</span><select name="bankId">${data.banks.map(bank => `<option value="${bank.id}" ${bank.id === (grant.bankId || state.selectedBank) ? 'selected' : ''}>${bank.name}</option>`).join('')}</select></label><label><span>授权范围 *</span><select name="scope"><option ${grant.scope === '全部活跃租户' ? 'selected' : ''}>全部活跃租户</option><option ${grant.scope === '指定套餐' ? 'selected' : ''}>指定套餐</option><option ${grant.scope === '指定租户' ? 'selected' : ''}>指定租户</option><option ${grant.scope === '套餐 + 租户' ? 'selected' : ''}>套餐 + 租户</option></select></label><label><span>允许套餐</span><select name="planCode"><option value="">不限制套餐</option>${planOptions(grant.allowedPlanCodes?.[0] || '')}</select></label><label><span>允许租户</span><select name="tenantId"><option value="">不指定租户</option>${tenantOptions(grant.allowedTenantIds?.[0] || '')}</select></label>${formField('地区 ID(UUID)', 'regionIds', grant.allowedRegionIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}${formField('科目 ID(UUID)', 'subjectIds', grant.allowedSubjectIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}<label><span>开始日期 *</span><input name="startsAt" type="date" value="${grant.startsAt || '2026-07-23'}" /></label><label><span>到期日期</span><input name="expiresAt" type="date" value="${grant.expiresAt || ''}" /></label><label class="check-row"><input name="localEdit" type="checkbox" ${grant.localEdit ? 'checked' : ''} /> 允许租户本地改编</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="grantForm">保存授权</button>`, { id: 'P-09 · 授权规则', wide: true });
|
||||
},
|
||||
crmConfig() {
|
||||
const config = data.crmConfigs.find(item => item.id === state.modalData.crmId) || {};
|
||||
return modalFrame(config.id ? '编辑 CRM 接入' : '新建 CRM 接入', '平台代管租户 CRM webhook 接入;Secret 只保存引用,不提交明文密钥。', `<form id="crmConfigForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${config.id || ''}" /><input type="hidden" name="backendId" value="${config.backendId || config.id || ''}" /><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(config.tenantId || state.selectedTenant)}</select></label>${formField('Webhook Endpoint *', 'endpoint', config.endpoint || '', 'required placeholder="https://..."')}${formField('Secret Ref', 'secretRef', config.secretRef || '', 'placeholder="tenant_secret:crm:..."')}${formField('表单/线索池', 'pool', config.pool || '')}${formField('考试类型', 'examType', config.examType || '')}${formField('超时(秒)', 'timeout', config.timeout || 10, 'type="number" min="1" max="120"')}${formField('重试次数', 'retry', config.retry || 3, 'type="number" min="0" max="10"')}<label><span>分配方式</span><select name="assignmentMode"><option value="round_robin">按地区轮转</option><option value="direct">手工分配</option><option value="referrer">来源归属</option><option value="none">不分配</option></select></label><label class="check-row"><input name="enabled" type="checkbox" ${config.enabled !== false ? 'checked' : ''} /> 启用接入</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="crmConfigForm">保存接入</button>`, { id: 'P-10 · CRM 接入', wide: true });
|
||||
},
|
||||
smsChannel() {
|
||||
const channel = data.smsChannels.find(item => item.id === state.modalData.smsId) || {};
|
||||
return modalFrame(channel.id ? '编辑短信渠道' : '新建短信渠道', '渠道密钥只保存 Secret Ref;模板和发送日志会保留历史。', `<form id="smsChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><input type="hidden" name="backendId" value="${channel.backendId || channel.id || ''}" /><div class="form-grid"><label><span>租户 *</span><select name="tenantId">${tenantOptions(channel.tenantId || state.selectedTenant)}</select></label>${formField('Provider *', 'provider', channel.provider || 'aliyun', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}${formField('短信签名 *', 'signature', channel.signature || '', 'required')}${formField('场景 *', 'scene', channel.scene || 'login', 'required')}${formField('Secret Ref', 'secretRef', channel.secretRef || '')}${formField('月额度', 'quota', channel.quota || 0, 'type="number" min="0"')}<label><span>状态</span><select name="status"><option ${channel.status === '正常' ? 'selected' : ''}>正常</option><option ${channel.status === '测试中' ? 'selected' : ''}>测试中</option><option ${channel.status === '未启用' ? 'selected' : ''}>未启用</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="smsChannelForm">保存渠道</button>`, { id: 'P-11 · 短信渠道', wide: true });
|
||||
},
|
||||
paymentApp() {
|
||||
const appItem = data.paymentApps.find(item => item.id === state.modalData.paymentId) || {};
|
||||
const isTenant = appItem.appScope === 'tenant' || appItem.tenantId;
|
||||
return modalFrame(appItem.id ? '编辑支付应用' : '新建平台支付应用', '平台自有收款用于租户购买套餐/模块;租户代管应用可选择具体租户。', `<form id="paymentAppForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${appItem.id || ''}" /><input type="hidden" name="backendId" value="${appItem.backendId || appItem.id || ''}" /><div class="form-grid"><label><span>类型</span><select name="appScope"><option value="platform" ${!isTenant ? 'selected' : ''}>平台自有收款</option><option value="tenant" ${isTenant ? 'selected' : ''}>租户支付代管</option></select></label><label><span>租户</span><select name="tenantId"><option value="">平台</option>${tenantOptions(appItem.tenantId || state.selectedTenant)}</select></label>${formField('应用代码', 'appCode', appItem.appCode || '')}${formField('应用名称 *', 'appName', appItem.appName || '', 'required')}${formField('Provider', 'provider', appItem.provider === 'platform' ? 'manual' : (appItem.provider || 'manual'))}${formField('Secret Ref', 'secretRef', appItem.secretRef || '')}${formField('结算模式', 'settlement', appItem.settlement || 'PlatformCollect')}<label><span>状态</span><select name="status"><option ${appItem.status === '正常' ? 'selected' : ''}>正常</option><option ${appItem.status === '测试中' ? 'selected' : ''}>测试中</option><option ${appItem.status === '未启用' ? 'selected' : ''}>未启用</option></select></label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="paymentAppForm">保存应用</button>`, { id: 'P-12 · 支付设置', wide: true });
|
||||
},
|
||||
staff() {
|
||||
const person = data.staff.find(item => item.id === state.modalData.staffId) || {};
|
||||
const presetRole = roleDefinitions[state.modalData.roleKey]?.label || '';
|
||||
const selectedRole = person.role || presetRole || '租户运营';
|
||||
const editablePhone = /^1\d{10}$/.test(person.phone || '') ? person.phone : '';
|
||||
const permissionList = ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops', 'platform:staff:read', 'platform:staff:write', 'platform:staff:status', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'];
|
||||
const permissionList = ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write', 'platform:payment:read', 'platform:payment:write', 'platform:staff:read', 'platform:staff:write', 'platform:staff:status', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'];
|
||||
const presetPermissions = roleDefinitions[state.modalData.roleKey]?.permissions || [];
|
||||
return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', 'Auth 用户必须存在;按最小权限分组授权。', `<form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${person.id || ''}" /><div class="form-grid">${formField('Auth User ID *', 'authUserId', person.authUserId || '', 'required')}${formField('用户名 *', 'username', person.username || '', 'required')}${formField('姓名 *', 'name', person.name || '', 'required')}${formField('邮箱 *', 'email', person.email || '', 'required type="email"')}${formField('手机号', 'phone', editablePhone)}<label><span>员工角色</span><select name="role">${['租户运营', '平台财务', '安全审计', '题库运营', '超级管理员'].map(role => `<option ${selectedRole === role ? 'selected' : ''}>${role}</option>`).join('')}</select></label></div><fieldset><legend>平台权限</legend><div class="permission-grid">${permissionList.map(permission => { const selected = person.permissions?.includes('*') || person.permissions?.includes(permission) || presetPermissions.includes('*') || presetPermissions.includes(permission); return `<label><input type="checkbox" name="permissions" value="${permission}" ${selected ? 'checked' : ''} />${permission}</label>`; }).join('')}</div><button type="button" class="link" data-action="select-permission-group">选择当前角色推荐权限</button><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button>`, { id: 'P-10 · 员工权限', wide: true });
|
||||
return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', 'Auth 用户必须存在;按最小权限分组授权。', `<form id="staffForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${person.id || ''}" /><div class="form-grid">${formField('Auth User ID *', 'authUserId', person.authUserId || '', 'required')}${formField('用户名 *', 'username', person.username || '', 'required')}${formField('姓名 *', 'name', person.name || '', 'required')}${formField('邮箱 *', 'email', person.email || '', 'required type="email"')}${formField('手机号', 'phone', editablePhone)}<label><span>员工角色</span><select name="role">${['租户运营', '平台财务', '安全审计', '题库运营', '超级管理员'].map(role => `<option ${selectedRole === role ? 'selected' : ''}>${role}</option>`).join('')}</select></label></div><fieldset><legend>平台权限</legend><div class="permission-grid">${permissionList.map(permission => { const selected = person.permissions?.includes('*') || person.permissions?.includes(permission) || presetPermissions.includes('*') || presetPermissions.includes(permission); return `<label><input type="checkbox" name="permissions" value="${permission}" ${selected ? 'checked' : ''} />${permission}</label>`; }).join('')}</div><button type="button" class="link" data-action="select-permission-group">选择当前角色推荐权限</button><button type="button" class="link" data-action="clear-staff-form">清空整个表单</button></fieldset></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="staffForm">保存员工</button>`, { id: 'P-13 · 员工权限', wide: true });
|
||||
},
|
||||
auditDetail() {
|
||||
const log = data.auditLogs.find(item => item.id === state.modalData.auditId);
|
||||
return modalFrame('审计事件详情', `${log.id} · 敏感字段已脱敏。`, `<dl class="modal-detail"><div><dt>时间</dt><dd>${escapeHtml(log.time)}</dd></div><div><dt>操作者</dt><dd>${escapeHtml(log.actor)} · ${escapeHtml(log.role)}</dd></div><div><dt>动作</dt><dd><code>${escapeHtml(log.action)}</code></dd></div><div><dt>目标</dt><dd>${escapeHtml(log.targetType)} · <code>${escapeHtml(log.targetId)}</code></dd></div><div><dt>来源 IP</dt><dd><code>${escapeHtml(log.ip)}</code></dd></div><div class="wide"><dt>详情</dt><dd>${escapeHtml(log.details)}</dd></div></dl>`, `<button class="button" data-copy="${log.id}">${icon('copy')}复制 ID</button><button class="button" data-action="jump-audit-target" data-audit-id="${log.id}">跳转目标对象</button><button class="button primary" data-action="close-modal">完成</button>`, { id: 'P-11 · 只读详情', wide: true });
|
||||
return modalFrame('审计事件详情', `${log.id} · 敏感字段已脱敏。`, `<dl class="modal-detail"><div><dt>时间</dt><dd>${escapeHtml(log.time)}</dd></div><div><dt>操作者</dt><dd>${escapeHtml(log.actor)} · ${escapeHtml(log.role)}</dd></div><div><dt>动作</dt><dd><code>${escapeHtml(log.action)}</code></dd></div><div><dt>目标</dt><dd>${escapeHtml(log.targetType)} · <code>${escapeHtml(log.targetId)}</code></dd></div><div><dt>来源 IP</dt><dd><code>${escapeHtml(log.ip)}</code></dd></div><div class="wide"><dt>详情</dt><dd>${escapeHtml(log.details)}</dd></div></dl>`, `<button class="button" data-copy="${log.id}">${icon('copy')}复制 ID</button><button class="button" data-action="jump-audit-target" data-audit-id="${log.id}">跳转目标对象</button><button class="button primary" data-action="close-modal">完成</button>`, { id: 'P-14 · 只读详情', wide: true });
|
||||
},
|
||||
alertDetail() {
|
||||
const alert = data.alerts.find(item => item.id === (state.modalData.alertId || state.selectedAlert));
|
||||
return modalFrame('告警证据与处理', `${alert.id} · ${alert.title}`, `<div class="alert-evidence"><div class="evidence-head"><span class="alert-level ${tone(alert.severity)}">${alert.severity}</span><div><strong>${escapeHtml(alert.summary)}</strong><small>${alert.owner} · ${alert.updatedAt}</small></div>${statusBadge(alertStatusLabel(alert.status))}</div><ul>${alert.evidence.map(item => `<li>${icon('file-search')}${escapeHtml(item)}</li>`).join('')}</ul><label><span>处理备注</span><textarea id="alertNote" rows="3" ${['resolved', 'ignored'].includes(alert.status) ? 'disabled' : ''}>${escapeHtml(alert.note)}</textarea></label></div>`, `<button class="button" data-copy="${alert.id}">${icon('copy')}复制 ID</button>${['open', 'acknowledged'].includes(alert.status) ? `<button class="button" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="ignored">忽略</button><button class="button primary" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="${alert.status === 'open' ? 'acknowledged' : 'resolved'}">${alert.status === 'open' ? '确认告警' : '解决告警'}</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-12 · 状态机', wide: true });
|
||||
return modalFrame('告警证据与处理', `${alert.id} · ${alert.title}`, `<div class="alert-evidence"><div class="evidence-head"><span class="alert-level ${tone(alert.severity)}">${alert.severity}</span><div><strong>${escapeHtml(alert.summary)}</strong><small>${alert.owner} · ${alert.updatedAt}</small></div>${statusBadge(alertStatusLabel(alert.status))}</div><ul>${alert.evidence.map(item => `<li>${icon('file-search')}${escapeHtml(item)}</li>`).join('')}</ul><label><span>处理备注</span><textarea id="alertNote" rows="3" ${['resolved', 'ignored'].includes(alert.status) ? 'disabled' : ''}>${escapeHtml(alert.note)}</textarea></label></div>`, `<button class="button" data-copy="${alert.id}">${icon('copy')}复制 ID</button>${['open', 'acknowledged'].includes(alert.status) ? `<button class="button" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="ignored">忽略</button><button class="button primary" data-action="transition-alert" data-alert-id="${alert.id}" data-next-status="${alert.status === 'open' ? 'acknowledged' : 'resolved'}">${alert.status === 'open' ? '确认告警' : '解决告警'}</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-15 · 状态机', wide: true });
|
||||
},
|
||||
notificationDetail() {
|
||||
const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === state.modalData.eventId);
|
||||
const channel = event ? notificationChannelName(event.channelId) : '-';
|
||||
return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, `<dl class="modal-detail"><div><dt>主题</dt><dd>${escapeHtml(event?.subject || '-')}</dd></div><div><dt>通知渠道</dt><dd>${escapeHtml(channel)}</dd></div><div><dt>状态</dt><dd>${statusBadge(event?.status || '-')}</dd></div><div><dt>尝试次数</dt><dd>${event?.attempts || 0}</dd></div><div><dt>更新时间</dt><dd>${escapeHtml(event?.updatedAt || '-')}</dd></div><div class="wide"><dt>错误摘要</dt><dd>${escapeHtml(event?.error || '无')}</dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminAuditController_events</code><br />GET /api/platform-admin/audit-notification-events</dd></div></dl>`, `<button class="button" data-copy="${event?.id || ''}">${icon('copy')}复制事件 ID</button>${event?.status === '失败' ? `<button class="button primary" data-action="retry-notification" data-event-id="${event.id}">重试通知</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-12 · 通知事件', wide: true });
|
||||
return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, `<dl class="modal-detail"><div><dt>主题</dt><dd>${escapeHtml(event?.subject || '-')}</dd></div><div><dt>通知渠道</dt><dd>${escapeHtml(channel)}</dd></div><div><dt>状态</dt><dd>${statusBadge(event?.status || '-')}</dd></div><div><dt>尝试次数</dt><dd>${event?.attempts || 0}</dd></div><div><dt>更新时间</dt><dd>${escapeHtml(event?.updatedAt || '-')}</dd></div><div class="wide"><dt>错误摘要</dt><dd>${escapeHtml(event?.error || '无')}</dd></div><div class="wide"><dt>API 契约</dt><dd><code>PlatformAdminAuditController_events</code><br />GET /api/platform-admin/audit-notification-events</dd></div></dl>`, `<button class="button" data-copy="${event?.id || ''}">${icon('copy')}复制事件 ID</button>${event?.status === '失败' ? `<button class="button primary" data-action="retry-notification" data-event-id="${event.id}">重试通知</button>` : '<button class="button primary" data-action="close-modal">关闭</button>'}`, { id: 'P-15 · 通知事件', wide: true });
|
||||
},
|
||||
syncDetail() {
|
||||
const [tenantId, bankId] = String(state.modalData.syncKey || '').split(':');
|
||||
@@ -1074,7 +1216,7 @@ const modalTemplates = {
|
||||
auditChannel() {
|
||||
const channel = data.auditChannels.find(item => item.id === state.modalData.channelId) || {};
|
||||
const redactedHint = platformApi?.isEnabled() && channel.endpointRedacted ? ' 后端不会回显完整 URL;编辑时必须重新输入完整 HTTPS Webhook。' : '';
|
||||
return modalFrame(channel.id ? '编辑审计通知渠道' : '新建审计通知渠道', `secret 只写;状态和严重级别过滤由平台后端执行。${redactedHint}`, `<form id="auditChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><div class="form-grid">${formField('渠道代码 *', 'code', channel.code || '', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}<label><span>Provider</span><select name="provider">${notificationProviderOptions(channel.provider)}</select></label>${formField('HTTPS Webhook *', 'endpoint', channelEndpointFormValue(channel), 'required placeholder="https://..."')}${formField('Secret(只写)', 'secret', '', 'type="password" autocomplete="new-password"')}<label><span>最低级别</span><select name="minSeverity">${['中', '高', '严重'].map(value => `<option ${channel.minSeverity === value ? 'selected' : ''}>${value}</option>`).join('')}</select></label>${formField('状态过滤', 'statusFilter', channel.statusFilter || 'open,acknowledged')}${formField('超时(秒)', 'timeout', channel.timeout || 8, 'type="number" min="1" max="60"')}${formField('最大尝试', 'maxAttempts', channel.maxAttempts || 3, 'type="number" min="1" max="10"')}<label class="check-row"><input name="enabled" type="checkbox" ${channel.enabled !== false ? 'checked' : ''} /> 启用渠道</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="auditChannelForm">保存渠道</button>`, { id: 'P-12 · 通知渠道', wide: true });
|
||||
return modalFrame(channel.id ? '编辑审计通知渠道' : '新建审计通知渠道', `secret 只写;状态和严重级别过滤由平台后端执行。${redactedHint}`, `<form id="auditChannelForm" class="modal-form"><div class="form-error-summary" hidden tabindex="-1"></div><input type="hidden" name="id" value="${channel.id || ''}" /><div class="form-grid">${formField('渠道代码 *', 'code', channel.code || '', 'required')}${formField('渠道名称 *', 'name', channel.name || '', 'required')}<label><span>Provider</span><select name="provider">${notificationProviderOptions(channel.provider)}</select></label>${formField('HTTPS Webhook *', 'endpoint', channelEndpointFormValue(channel), 'required placeholder="https://..."')}${formField('Secret(只写)', 'secret', '', 'type="password" autocomplete="new-password"')}<label><span>最低级别</span><select name="minSeverity">${['中', '高', '严重'].map(value => `<option ${channel.minSeverity === value ? 'selected' : ''}>${value}</option>`).join('')}</select></label>${formField('状态过滤', 'statusFilter', channel.statusFilter || 'open,acknowledged')}${formField('超时(秒)', 'timeout', channel.timeout || 8, 'type="number" min="1" max="60"')}${formField('最大尝试', 'maxAttempts', channel.maxAttempts || 3, 'type="number" min="1" max="10"')}<label class="check-row"><input name="enabled" type="checkbox" ${channel.enabled !== false ? 'checked' : ''} /> 启用渠道</label></div></form>`, `<button class="button" data-action="close-modal">取消</button><button class="button primary" type="submit" form="auditChannelForm">保存渠道</button>`, { id: 'P-15 · 通知渠道', wide: true });
|
||||
},
|
||||
confirmAction() {
|
||||
const { title, description, impact, confirmLabel = '确认操作', confirmActionName = 'execute-confirmed', tone: confirmTone = 'danger' } = state.modalData;
|
||||
@@ -1119,7 +1261,7 @@ function validateNotificationChannel(values) {
|
||||
document.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'staffForm', 'auditChannelForm']);
|
||||
const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']);
|
||||
if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return;
|
||||
if (form.id === 'createTenantForm') {
|
||||
const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount'));
|
||||
@@ -1161,6 +1303,20 @@ document.addEventListener('submit', event => {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID,多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID,多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}。`); }, success: '公共题库授权范围已保存' });
|
||||
}
|
||||
if (form.id === 'crmConfigForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
values.enabled = Boolean(new FormData(form).get('enabled'));
|
||||
simulateWrite({ key: 'crm-config', permission: 'platform:crm:write', operationId: 'PlatformAdminCrmController_upsertConfig', body: () => values, validate: () => { const errors = {}; if (!values.tenantId) errors.tenantId = '租户不能为空'; if (!values.endpoint) errors.endpoint = 'Webhook Endpoint 不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? platformApi.adapters.crmConfig(payload) : { ...values, id: values.id || entityId('CRM'), enabled: values.enabled, status: values.enabled ? '正常' : '未启用' }; const index = data.crmConfigs.findIndex(row => row.id === item.id); if (index >= 0) data.crmConfigs[index] = item; else data.crmConfigs.unshift(item); addAudit('crm.config.upsert', 'crm_config', item.id, tenantById(item.tenantId)?.name || item.id, '中', '保存 CRM 接入配置。'); }, success: 'CRM 接入配置已保存' });
|
||||
}
|
||||
if (form.id === 'smsChannelForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
simulateWrite({ key: 'sms-channel', permission: 'platform:sms:write', operationId: 'PlatformAdminSmsController_upsertChannel', body: () => values, validate: () => { const errors = {}; if (!values.tenantId) errors.tenantId = '租户不能为空'; if (!values.provider) errors.provider = 'Provider 不能为空'; if (!values.signature) errors.signature = '短信签名不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? platformApi.adapters.smsChannel(payload) : { ...values, id: values.id || entityId('SMS'), enabled: values.status === '正常', used: 0 }; const index = data.smsChannels.findIndex(row => row.id === item.id); if (index >= 0) data.smsChannels[index] = item; else data.smsChannels.unshift(item); addAudit('sms.channel.upsert', 'sms_channel', item.id, tenantById(item.tenantId)?.name || item.id, '中', '保存短信渠道配置。'); }, success: '短信渠道已保存' });
|
||||
}
|
||||
if (form.id === 'paymentAppForm') {
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
const operationId = values.appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp';
|
||||
simulateWrite({ key: 'payment-app', permission: 'platform:payment:write', operationId, body: () => values, validate: () => { const errors = {}; if (values.appScope === 'tenant' && !values.tenantId) errors.tenantId = '租户代管必须选择租户'; if (!values.appName) errors.appName = '应用名称不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? (values.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...values, id: values.id || entityId('PAYAPP'), status: values.status }; const index = data.paymentApps.findIndex(row => row.id === item.id); if (index >= 0) data.paymentApps[index] = item; else data.paymentApps.unshift(item); addAudit('payment.app.upsert', 'payment_app', item.id, item.appName, '高', '保存支付应用配置。'); }, success: '支付应用已保存' });
|
||||
}
|
||||
if (form.id === 'staffForm') {
|
||||
const values = Object.fromEntries(new FormData(form)); const permissions = new FormData(form).getAll('permissions');
|
||||
simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' });
|
||||
@@ -1187,6 +1343,9 @@ document.addEventListener('click', event => {
|
||||
const tf = event.target.closest('[data-tenant-filter]'); if (tf) { state.tenantFilter = tf.dataset.tenantFilter; syncNavigationState(); render(); return; }
|
||||
const billingTab = event.target.closest('[data-billing-tab]'); if (billingTab) { state.billingTab = billingTab.dataset.billingTab; syncNavigationState(); render(); return; }
|
||||
const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); return; }
|
||||
const crmTab = event.target.closest('[data-crm-tab]'); if (crmTab) { state.crmTab = crmTab.dataset.crmTab; syncNavigationState(); render(); return; }
|
||||
const smsTab = event.target.closest('[data-sms-tab]'); if (smsTab) { state.smsTab = smsTab.dataset.smsTab; syncNavigationState(); render(); return; }
|
||||
const paymentTab = event.target.closest('[data-payment-tab]'); if (paymentTab) { state.paymentTab = paymentTab.dataset.paymentTab; syncNavigationState(); render(); return; }
|
||||
const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; syncNavigationState(); render(); return; }
|
||||
const auditSeverity = event.target.closest('[data-audit-severity]'); if (auditSeverity) { state.auditSeverity = auditSeverity.dataset.auditSeverity; syncNavigationState(); render(); return; }
|
||||
const alertStatus = event.target.closest('[data-alert-status]'); if (alertStatus) { state.alertStatus = alertStatus.dataset.alertStatus; syncNavigationState(); render(); return; }
|
||||
@@ -1221,16 +1380,16 @@ document.addEventListener('click', event => {
|
||||
if (action === 'subscriptions-to-usage') { navigate('usage'); return; }
|
||||
if (action === 'toggle-auto-renew') { if (requireDesktop('变更订阅自动续费')) return; if (platformApi?.isEnabled()) { showToast('后端暂未提供订阅更新接口,真实模式禁止仅改本地状态', 'error'); return; } const item = data.subscriptions.find(subscription => subscription.id === actionElement.dataset.subscriptionId); if (!canWrite('platform:billing:write')) return; item.autoRenew = !item.autoRenew; addAudit('subscription.auto_renew.update', 'subscription', item.id, item.id, '中', `自动续费更新为 ${item.autoRenew ? '开启' : '关闭'}。`); saveData(); render(); showToast('自动续费设置已更新'); return; }
|
||||
if (action === 'create-invoice') { if (requireDesktop('手工创建账单')) return; openModal('createInvoice'); return; }
|
||||
if (action === 'apply-invoice-query') { runContractRead({ key: 'invoice-query', actionId: 'act.p-06.01', operationId: 'PlatformAdminBillingController_invoices', title: '账单查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminBillingController_invoices', { query: { tenantId: state.selectedTenant || undefined, status: ({ 草稿: 'draft', 待付款: 'issued', 已支付: 'paid', 已作废: 'void', 已逾期: 'overdue' })[state.invoiceStatus], limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.invoices = payload.items.map(platformApi.adapters.invoice); return `${data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!state.invoiceSearch.trim() || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(state.invoiceSearch.trim().toLowerCase())))).length} 张匹配账单`; } }); return; }
|
||||
if (action === 'apply-invoice-query') { runContractRead({ key: 'invoice-query', actionId: 'act.p-06.01', operationId: 'PlatformAdminBillingController_invoices', title: '账单查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminBillingController_invoices', { query: { tenantId: state.selectedTenant || undefined, status: ({ 草稿: 'draft', 待付款: 'issued', 已支付: 'paid', 已作废: 'void', 已逾期: 'overdue' })[state.invoiceStatus], limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.invoices = items.map(platformApi.adapters.invoice); return `${data.invoices.filter(invoice => (!state.selectedTenant || invoice.tenantId === state.selectedTenant) && (state.invoiceStatus === 'all' || invoice.status === state.invoiceStatus) && (!state.invoiceSearch.trim() || [invoice.id, invoice.type, invoice.period, invoice.note, tenantById(invoice.tenantId)?.name].some(value => String(value || '').toLowerCase().includes(state.invoiceSearch.trim().toLowerCase())))).length} 张匹配账单`; } }); return; }
|
||||
if (action === 'preview-invoice-candidates') { if (requireDesktop('批量生成账单')) return; openModal('invoiceCandidates'); return; }
|
||||
if (action === 'run-invoice-dry-run' && platformApi?.isEnabled()) { const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`Dry-run 完成 · 可生成 ${result.items?.length || 0} 条,跳过 ${result.skippedCount || 0} 条`); }, { refresh: false }); return; }
|
||||
if (action === 'run-invoice-dry-run' && platformApi?.isEnabled()) { const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: true }, payload => { const result = apiItem(payload); showToast(`Dry-run 完成 · 可生成 ${result.items?.length || 0} 条,跳过 ${result.skippedCount || 0} 条`); }, { refresh: false }); return; }
|
||||
if (action === 'run-invoice-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条订阅候选,未写入账单`); return; }
|
||||
if (action === 'generate-subscription-invoice' && platformApi?.isEnabled()) { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); if (!subscription) { showToast('订阅不存在或不可用', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscription', { tenantId: subscription.tenantId, subscriptionId: subscription.id, status: 'issued' }, payload => { closeModal({ force: true }); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'success', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: payload?.meta?.requestId || '' }); showToast('订阅账单已由后端生成'); }); return; }
|
||||
if (action === 'generate-subscription-invoice' && platformApi?.isEnabled()) { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); if (!subscription) { showToast('订阅不存在或不可用', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscription', { tenantId: subscription.tenantId, subscriptionId: subscription.id, status: 'issued' }, payload => { closeModal({ force: true }); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'success', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: payload?.meta?.requestId || payload?.traceId || '' }); showToast('订阅账单已由后端生成'); }); return; }
|
||||
if (action === 'generate-subscription-invoice') { if (requireDesktop('为单个订阅生成账单')) return; const subscription = data.subscriptions.find(item => item.id === actionElement.dataset.subscriptionId); const suppliedRequestId = actionElement.dataset.contractRequestId || requestId(); if (!subscription || data.invoices.some(invoice => invoice.note.includes(subscription.id))) { state.contractResults['invoice-query'] = { status: 'error', title: '单订阅生成失败', detail: '该订阅已存在账单或订阅不可用', requestId: suppliedRequestId }; addAudit('invoice.from_subscription.failed', 'subscription', subscription?.id || '-', subscription?.id || '-', '中', `幂等冲突。Request ID:${suppliedRequestId}。`, '失败'); window.recordPlatformSpecResult?.('act.p-06.04', { result: 'failed', operationId: 'PlatformAdminBillingController_fromSubscription', requestId: suppliedRequestId }); render(); return; } const task = addContractTask({ actionId: 'act.p-06.04', operationId: 'PlatformAdminBillingController_fromSubscription', label: `为订阅 ${subscription.id} 生成账单`, resultTarget: '服务费账单', suppliedRequestId, execute: taskItem => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); tenantById(subscription.tenantId).balance += invoice.balance; taskItem.invoiceId = invoice.id; addAudit('invoice.from_subscription.create', 'invoice', invoice.id, invoice.id, '高', `通过任务 ${taskItem.id} 为订阅 ${subscription.id} 生成单张账单。`); } }); showToast(`单订阅账单任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'confirm-generate-candidates' && platformApi?.isEnabled()) { if (requireDesktop('批量生成账单')) return; const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!subscriptionIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); const result = payload?.item || {}; showToast(`后端已生成 ${result.createdCount || 0} 张账单,跳过 ${result.skippedCount || 0} 条`); }); return; }
|
||||
if (action === 'confirm-generate-candidates' && platformApi?.isEnabled()) { if (requireDesktop('批量生成账单')) return; const subscriptionIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!subscriptionIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromSubscriptionsBatch', { subscriptionIds, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); const result = apiItem(payload); showToast(`后端已生成 ${result.createdCount || 0} 张账单,跳过 ${result.skippedCount || 0} 条`); }); return; }
|
||||
if (action === 'confirm-generate-candidates') { if (requireDesktop('批量生成账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条订阅账单候选', 'error'); return; } const candidates = data.subscriptions.filter(subscription => selectedIds.includes(subscription.id) && !data.invoices.some(invoice => invoice.note.includes(subscription.id))); closeModal(); const task = addTask(`批量生成 ${candidates.length} 张订阅账单`, '服务费账单', () => { candidates.forEach(subscription => { const invoice = { id: entityId('INV'), tenantId: subscription.tenantId, type: '订阅服务费', period: '下一订阅账期', total: subscription.amount, paid: 0, balance: subscription.amount, due: subscription.expires, status: '待付款', note: `订阅 ${subscription.id}` }; data.invoices.unshift(invoice); const tenant = tenantById(subscription.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `批量生成 ${candidates.length} 张订阅账单。`); }); showToast(`任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'confirm-payment') { if (requireDesktop('确认账单收款')) return; openModal('payment', { invoiceId: actionElement.dataset.invoiceId }); return; }
|
||||
if (action === 'preview-overdue' && platformApi?.isEnabled()) { if (requireDesktop('批量处理逾期账单')) return; executeBackendMutation('PlatformAdminBillingController_processOverdue', { dryRun: true, limit: 100, channel: 'internal' }, payload => { openModal('overduePreview'); showToast(`后端 Dry-run:${payload?.item?.processed || payload?.item?.items?.length || 0} 条候选`); }, { refresh: false }); return; }
|
||||
if (action === 'preview-overdue' && platformApi?.isEnabled()) { if (requireDesktop('批量处理逾期账单')) return; executeBackendMutation('PlatformAdminBillingController_processOverdue', { dryRun: true, limit: 100, channel: 'internal' }, payload => { const result = apiItem(payload); openModal('overduePreview'); showToast(`后端 Dry-run:${result.processed || result.items?.length || 0} 条候选`); }, { refresh: false }); return; }
|
||||
if (action === 'preview-overdue') { if (requireDesktop('批量处理逾期账单')) return; openModal('overduePreview'); return; }
|
||||
if (action === 'confirm-overdue-process' && platformApi?.isEnabled()) { showToast('后端逾期处理接口不支持指定 invoiceIds,真实模式禁止按当前勾选直接执行', 'error'); return; }
|
||||
if (action === 'confirm-overdue-process') { if (requireDesktop('批量处理逾期账单')) return; const selectedIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedIds.length) { showToast('请至少选择一条逾期账单候选', 'error'); return; } const overdue = data.invoices.filter(invoice => invoice.status === '已逾期' && selectedIds.includes(invoice.id)); closeModal(); const task = addTask(`处理 ${overdue.length} 张逾期账单`, '收款与催缴', () => { overdue.forEach(invoice => data.reminders.unshift({ id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: 3, channel: '邮件 + 短信', status: '发送中', date: todayTime(), message: `服务费账单 ${invoice.id} 已逾期,请尽快处理。` })); addAudit('invoice.overdue.process', 'invoice_batch', task.id, task.id, '高', `生成 ${overdue.length} 条催缴记录。`); }); showToast(`催缴任务 ${task.id} 已创建`); return; }
|
||||
@@ -1238,27 +1397,36 @@ document.addEventListener('click', event => {
|
||||
if (action === 'generate-reminder') { if (requireDesktop('生成账单催缴')) return; const invoice = invoiceById(actionElement.dataset.invoiceId); const reminder = { id: entityId('REM'), invoiceId: invoice.id, tenantId: invoice.tenantId, level: invoice.status === '已逾期' ? 3 : 1, channel: '邮件 + 短信', status: '已加入队列', date: todayTime(), message: invoice.status === '已逾期' ? `账单已逾期,请尽快支付 ${money(invoice.balance)}。` : `账单即将到期,应付 ${money(invoice.balance)}。` }; data.reminders.unshift(reminder); addAudit('invoice.reminder.create', 'reminder', reminder.id, reminder.id, '中', `为 ${invoice.id} 创建 L${reminder.level} 催缴。`); saveData(); render(); showToast('催缴已加入发送队列'); return; }
|
||||
if (action === 'record-usage') { if (requireDesktop('人工记录平台用量')) return; openModal('recordUsage'); return; }
|
||||
if (action === 'preview-overage') { if (requireDesktop('批量生成超额账单')) return; openModal('overagePreview'); return; }
|
||||
if (action === 'run-overage-dry-run' && platformApi?.isEnabled()) { const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: true }, payload => { const result = payload?.item || {}; showToast(`超额 Dry-run 完成 · 可生成 ${result.items?.length || 0} 条`); }, { refresh: false }); return; }
|
||||
if (action === 'run-overage-dry-run' && platformApi?.isEnabled()) { const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: true }, payload => { const result = apiItem(payload); showToast(`超额 Dry-run 完成 · 可生成 ${result.items?.length || 0} 条`); }, { refresh: false }); return; }
|
||||
if (action === 'run-overage-dry-run') { const count = overlayRoot.querySelectorAll('[name="candidate"]:checked').length; showToast(`Dry-run 已校验 ${count} 条超额候选,未写入账单`); return; }
|
||||
if (action === 'confirm-overage-invoices' && platformApi?.isEnabled()) { if (requireDesktop('批量生成超额账单')) return; const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); if (!tenantIds.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); showToast(`后端已生成 ${payload?.item?.createdCount || 0} 张超额账单`); }); return; }
|
||||
if (action === 'confirm-overage-invoices' && platformApi?.isEnabled()) { if (requireDesktop('批量生成超额账单')) return; const tenantIds = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value.split(':')[0]); if (!tenantIds.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } executeBackendMutation('PlatformAdminBillingController_fromUsage', { tenantIds, period: state.usagePeriod, dryRun: false, status: 'issued' }, payload => { closeModal({ force: true }); showToast(`后端已生成 ${apiItem(payload).createdCount || 0} 张超额账单`); }); return; }
|
||||
if (action === 'confirm-overage-invoices') { if (requireDesktop('批量生成超额账单')) return; const selectedKeys = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedKeys.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } const candidates = data.usage.filter(item => item.period === state.usagePeriod && selectedKeys.includes(`${item.tenantId}:${item.period}`) && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); closeModal(); const task = addTask(`生成 ${candidates.length} 张超额账单`, '用量与超额计费', () => { candidates.forEach(item => { const invoice = { id: entityId('INV'), tenantId: item.tenantId, type: '用量超额费', period: item.period, total: item.estimated, paid: 0, balance: item.estimated, due: '2026-08-15', status: '待付款', note: `超额 ${item.period}` }; data.invoices.unshift(invoice); const tenant = tenantById(item.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('usage.invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `生成 ${candidates.length} 张用量超额账单。`); }); showToast(`超额账单任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'new-dunning-channel') { if (requireDesktop('新建催缴渠道')) return; openModal('dunningChannel'); return; }
|
||||
if (action === 'edit-dunning-channel') { if (requireDesktop('编辑催缴渠道')) return; openModal('dunningChannel', { channelId: actionElement.dataset.channelId }); return; }
|
||||
if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; }
|
||||
if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; }
|
||||
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.banks = payload.items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; }
|
||||
if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.banks = items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; }
|
||||
if (action === 'edit-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; }
|
||||
if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; }
|
||||
if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { if (Array.isArray(payload?.items)) data.bankSync = payload.items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; }
|
||||
if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.bankSync = items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.bankSync = items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; }
|
||||
if (action === 'sync-detail') { openModal('syncDetail', { syncKey: actionElement.dataset.syncKey }); return; }
|
||||
if (action === 'new-crm-config' || action === 'edit-crm-config') { if (!canWrite('platform:crm:write')) return; openModal('crmConfig', { crmId: actionElement.dataset.crmId || '' }); return; }
|
||||
if (action === 'crm-rule-detail') { showToast('CRM 分配策略保存在接入配置的 assignmentConfig 中'); return; }
|
||||
if (action === 'toggle-crm-config') { if (!canWrite('platform:crm:write')) return; const config = data.crmConfigs.find(item => item.id === actionElement.dataset.crmId); if (!config) return; const nextEnabled = !config.enabled; const apply = payload => { const next = payload ? platformApi.adapters.crmConfig(payload) : { ...config, enabled: nextEnabled, status: nextEnabled ? '正常' : '未启用' }; Object.assign(config, next); addAudit('crm.config.status', 'crm_config', config.id, tenantById(config.tenantId)?.name || config.id, '中', `CRM 接入${config.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('CRM 接入状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_upsertConfig', { ...config, enabled: nextEnabled }, apply); else apply(); return; }
|
||||
if (action === 'retry-crm-lead') { if (!canWrite('platform:crm:write')) return; const lead = data.crmLeads.find(item => item.id === actionElement.dataset.leadId); if (!lead) return; const apply = payload => { const next = payload ? platformApi.adapters.crmLead(payload) : { ...lead, status: '重试中', attempts: lead.attempts + 1, lastError: '' }; Object.assign(lead, next); addAudit('crm.lead.retry', 'crm_lead', lead.id, lead.student, '中', `CRM 线索 ${lead.id} 已重试推送。`); saveData(); render(); showToast('线索已进入重试队列'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_retryLead', lead, apply); else apply(); return; }
|
||||
if (action === 'new-sms-channel' || action === 'edit-sms-channel') { if (!canWrite('platform:sms:write')) return; openModal('smsChannel', { smsId: actionElement.dataset.smsId || '' }); return; }
|
||||
if (action === 'toggle-sms-channel') { if (!canWrite('platform:sms:write')) return; const channel = data.smsChannels.find(item => item.id === actionElement.dataset.smsId); if (!channel) return; const nextStatus = channel.enabled ? '未启用' : '正常'; const apply = payload => { const next = payload ? platformApi.adapters.smsChannel(payload) : { ...channel, enabled: nextStatus === '正常', status: nextStatus }; Object.assign(channel, next); addAudit('sms.channel.status', 'sms_channel', channel.id, tenantById(channel.tenantId)?.name || channel.id, '中', `短信渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('短信渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminSmsController_upsertChannel', { ...channel, status: nextStatus }, apply); else apply(); return; }
|
||||
if (action === 'new-payment-app' || action === 'edit-payment-app') { if (!canWrite('platform:payment:write')) return; openModal('paymentApp', { paymentId: actionElement.dataset.paymentId || '' }); return; }
|
||||
if (action === 'payment-rule-detail') { showToast('支付风控规则用于证书提醒、回调重试和人工确认保护'); return; }
|
||||
if (action === 'toggle-payment-app') { if (!canWrite('platform:payment:write')) return; const paymentApp = data.paymentApps.find(item => item.id === actionElement.dataset.paymentId); if (!paymentApp) return; const disabling = paymentApp.status !== '未启用'; const nextStatus = disabling ? '未启用' : '正常'; const operationId = paymentApp.appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp'; const apply = payload => { const next = payload ? (paymentApp.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...paymentApp, status: nextStatus }; Object.assign(paymentApp, next); addAudit('payment.app.status', 'payment_app', paymentApp.id, paymentApp.appName, '高', `支付应用${disabling ? '停用' : '启用'}。`); saveData(); render(); showToast('支付应用状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation(operationId, { ...paymentApp, status: nextStatus }, apply); else apply(); return; }
|
||||
if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleKey: actionElement.dataset.roleKey || '' }); return; }
|
||||
if (action === 'edit-staff') { if (requireDesktop('编辑平台员工与授权')) return; openModal('staff', { staffId: actionElement.dataset.staffId }); return; }
|
||||
if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; if (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; }
|
||||
if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; }
|
||||
if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; }
|
||||
if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); form.elements.role.value = '租户运营'; state.modalData.dirty = true; form.querySelector('[name="authUserId"]')?.focus(); return; }
|
||||
if (action === 'apply-staff-search') { render(); return; }
|
||||
if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(`${error.message || '审计导出失败'}${error.requestId ? ` · ${error.requestId}` : ''}`, 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(backendErrorText(error, '审计导出失败'), 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; }
|
||||
if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; }
|
||||
if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || log.targetType === 'grant') navigate('bank'); else navigate('alerts'); return; }
|
||||
if (action === 'transition-alert') { if (!canWrite('platform:audit:alert')) return; const alert = data.alerts.find(item => item.id === actionElement.dataset.alertId); const next = actionElement.dataset.nextStatus; if (!alert) return; if (!['open', 'acknowledged'].includes(alert.status)) { showToast('已结束告警禁止重新打开或再次处理', 'error'); return; } if (alert.status === 'open' && !['acknowledged', 'resolved', 'ignored'].includes(next)) return; if (alert.status === 'acknowledged' && !['resolved', 'ignored'].includes(next)) return; const note = document.querySelector('#alertNote')?.value.trim() || ''; const apply = () => { alert.status = next; alert.note = note; alert.updatedAt = '刚刚'; addAudit('audit.alert.status.update', 'audit_alert', alert.id, alert.title, '高', `告警状态更新为 ${alertStatusLabel(next)}${note ? `,备注:${note}` : ''}。`); clearDirty('modal'); saveData(); closeModal({ force: true }); render(); showToast(`告警已${alertStatusLabel(next)}`); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_updateAlert', { alertId: alert.id, status: next, note }, apply); else apply(); return; }
|
||||
|
||||
@@ -66,11 +66,21 @@
|
||||
return String(
|
||||
payload?.meta?.requestId
|
||||
|| payload?.requestId
|
||||
|| payload?.traceId
|
||||
|| payload?.extensions?.traceId
|
||||
|| response?.headers?.get?.('x-request-id')
|
||||
|| '',
|
||||
);
|
||||
}
|
||||
|
||||
function responseErrorCode(payload) {
|
||||
return String(payload?.code || payload?.extensions?.code || payload?.errorCode || 'PLATFORM_API_REQUEST_FAILED');
|
||||
}
|
||||
|
||||
function responseErrorMessage(payload, status) {
|
||||
return String(payload?.message || payload?.title || payload?.error || `请求失败(HTTP ${status})`);
|
||||
}
|
||||
|
||||
async function request(operationId, options = {}) {
|
||||
const operation = operationCatalog[operationId];
|
||||
if (!operation) {
|
||||
@@ -150,10 +160,10 @@
|
||||
if (trace.length > 120) trace.splice(0, trace.length - 120);
|
||||
if (!response.ok) {
|
||||
throw new PlatformApiError(
|
||||
String(payload.message || payload.error || `请求失败(HTTP ${response.status})`),
|
||||
responseErrorMessage(payload, response.status),
|
||||
{
|
||||
status: response.status,
|
||||
code: String(payload.code || 'PLATFORM_API_REQUEST_FAILED'),
|
||||
code: responseErrorCode(payload),
|
||||
requestId,
|
||||
operationId,
|
||||
details: payload,
|
||||
@@ -192,6 +202,13 @@
|
||||
providerToApi: { Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' },
|
||||
providerFromApi: { generic: 'Webhook', dingtalk: '钉钉', feishu: '飞书', wecom: '企微' },
|
||||
eventStatusFromApi: { pending: '待处理', processing: '发送中', sent: '成功', retrying: '重试中', failed: '失败', discarded: '已丢弃' },
|
||||
crmStatusFromApi: { pending: '待推送', processing: '推送中', retrying: '重试中', sent: '已推送', failed: '推送失败', discarded: '已忽略' },
|
||||
providerStatusFromApi: { active: '正常', disabled: '未启用', testing: '测试中' },
|
||||
providerStatusToApi: { 正常: 'active', 未启用: 'disabled', 测试中: 'testing' },
|
||||
smsTemplateStatusFromApi: { active: '正常', disabled: '未启用' },
|
||||
smsAuditStatusFromApi: { draft: '草稿', pending_review: '审核中', approved: '已通过', rejected: '已拒绝' },
|
||||
paymentStatusFromApi: { active: '正常', disabled: '未启用', testing: '测试中' },
|
||||
paymentStatusToApi: { 正常: 'active', 未启用: 'disabled', 测试中: 'testing' },
|
||||
};
|
||||
|
||||
function mapValue(map, value, fallback = value) {
|
||||
@@ -365,14 +382,10 @@
|
||||
},
|
||||
PlatformAdminBillingController_confirmPayment(input) {
|
||||
return {
|
||||
tenantId: input.tenantId,
|
||||
invoiceId: input.invoiceId,
|
||||
amountCents: yuanToCents(input.amount),
|
||||
paymentNo: input.paymentNo || undefined,
|
||||
provider: input.provider || 'manual',
|
||||
method: input.method || 'manual',
|
||||
paidAt: input.paidAt ? new Date(input.paidAt).toISOString() : undefined,
|
||||
paymentId: input.paymentId || input.backendPaymentId || input.invoiceId,
|
||||
providerTradeNo: input.tradeNo || input.providerTradeNo || undefined,
|
||||
paidAt: input.paidAt ? new Date(input.paidAt).toISOString() : undefined,
|
||||
reason: input.reason || input.paymentNo || 'platform_manual_payment_confirm',
|
||||
};
|
||||
},
|
||||
PlatformAdminBillingController_recordUsage(input) {
|
||||
@@ -434,6 +447,95 @@
|
||||
note: input.note || undefined,
|
||||
};
|
||||
},
|
||||
PlatformAdminCrmController_upsertConfig(input) {
|
||||
return {
|
||||
id: input.backendId || input.id || undefined,
|
||||
tenantId: input.tenantId,
|
||||
enabled: input.enabled !== false && input.status !== '未启用',
|
||||
url: input.endpoint || input.url || undefined,
|
||||
secretRef: input.secretRef || undefined,
|
||||
formName: input.formName || input.pool || undefined,
|
||||
examType: input.examType || input.subject || undefined,
|
||||
timeoutSeconds: Number(input.timeout || input.timeoutSeconds || 10),
|
||||
delaySeconds: Number(input.delaySeconds || 0),
|
||||
assignmentMode: input.assignmentMode || 'round_robin',
|
||||
assignmentPool: splitList(input.pool),
|
||||
assignmentConfig: { retry: Number(input.retry || 3), source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformAdminCrmController_retryLead(input) {
|
||||
return { queueId: input.backendId || input.queueId || input.id, note: input.note || 'platform_console_retry' };
|
||||
},
|
||||
PlatformAdminSmsController_upsertChannel(input) {
|
||||
return {
|
||||
id: input.backendId || input.id || undefined,
|
||||
tenantId: input.tenantId,
|
||||
provider: input.provider || 'generic',
|
||||
name: input.name || `${input.provider || 'generic'} 短信渠道`,
|
||||
signature: input.signature || '',
|
||||
scene: input.scene || 'login',
|
||||
status: mapValue(dictionaries.providerStatusToApi, input.status, input.status || 'active'),
|
||||
secretRef: input.secretRef || undefined,
|
||||
priority: Number(input.priority || 100),
|
||||
monthlyQuota: Number(input.quota || input.monthlyQuota || 0),
|
||||
configPublic: input.configPublic || { redacted: true },
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformAdminSmsController_upsertTemplate(input) {
|
||||
return {
|
||||
id: input.backendId || input.id || undefined,
|
||||
tenantId: input.tenantId,
|
||||
channelId: input.channelId,
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
type: input.type || 'notification',
|
||||
auditStatus: input.auditStatus || 'draft',
|
||||
status: input.status || 'active',
|
||||
providerTemplateCode: input.providerTemplateCode || undefined,
|
||||
content: input.content || '',
|
||||
remark: input.remark || undefined,
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformAdminPaymentSettingsController_upsertApp(input) {
|
||||
return {
|
||||
id: input.backendId || input.id || undefined,
|
||||
appCode: input.appCode || input.code || String(input.appName || '').toLowerCase().replaceAll(' ', '_'),
|
||||
appName: input.appName,
|
||||
status: mapValue(dictionaries.paymentStatusToApi, input.status, input.status || 'active'),
|
||||
settlementMode: input.settlement || input.settlementMode || 'PlatformCollect',
|
||||
description: input.description || undefined,
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformAdminPaymentSettingsController_upsertChannel(input) {
|
||||
return {
|
||||
id: input.backendId || input.id || undefined,
|
||||
appId: input.appId,
|
||||
provider: input.provider || 'manual',
|
||||
mode: input.mode || 'PlatformCollect',
|
||||
status: mapValue(dictionaries.paymentStatusToApi, input.status, input.status || 'active'),
|
||||
displayName: input.displayName || input.appName || input.provider || '支付渠道',
|
||||
secretRef: input.secretRef || undefined,
|
||||
callbackPath: input.callbackPath || undefined,
|
||||
priority: Number(input.priority || 100),
|
||||
configPublic: input.configPublic || { redacted: true },
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
PlatformAdminTenantPaymentSettingsController_upsertApp(input) {
|
||||
return {
|
||||
tenantId: input.tenantId,
|
||||
provider: input.provider || 'manual',
|
||||
status: mapValue(dictionaries.providerStatusToApi, input.status, input.status || 'active'),
|
||||
displayName: input.appName || input.displayName || undefined,
|
||||
secretRef: input.secretRef || undefined,
|
||||
priority: Number(input.priority || 100),
|
||||
configPublic: input.configPublic || { mode: input.mode || 'TenantCollect', redacted: true },
|
||||
metadata: { source: 'platform_console' },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function notificationBody(input) {
|
||||
@@ -456,10 +558,20 @@
|
||||
}
|
||||
bodyBuilders.PlatformAdminAuditController_upsertChannel = notificationBody;
|
||||
bodyBuilders.PlatformAdminDunningChannelsController_upsertChannel = input => ({
|
||||
...notificationBody(input),
|
||||
channelId: input.id || input.channelId || undefined,
|
||||
channelCode: input.code || input.channelCode,
|
||||
name: input.name,
|
||||
description: input.description || undefined,
|
||||
enabled: input.enabled !== false,
|
||||
provider: mapValue(dictionaries.providerToApi, input.provider, input.provider || 'generic'),
|
||||
webhookUrl: input.endpoint || input.webhookUrl,
|
||||
secretRef: input.secretRef || undefined,
|
||||
minReminderLevel: Number(input.minReminderLevel || 1),
|
||||
reminderTypes: input.reminderTypes || ['overdue', 'final_notice'],
|
||||
reminderChannels: input.reminderChannels || ['internal'],
|
||||
tenantIds: input.tenantIds || [],
|
||||
timeoutSeconds: Number(input.timeout || input.timeoutSeconds || 10),
|
||||
metadata: { ...(input.metadata || {}), secretRedacted: Boolean(input.secretRef) },
|
||||
});
|
||||
|
||||
function buildBody(operationId, input = {}) {
|
||||
@@ -487,6 +599,42 @@
|
||||
return (items || []).find(item => item.id === id) || {};
|
||||
}
|
||||
|
||||
function itemOf(payload) {
|
||||
return payload?.item || payload || {};
|
||||
}
|
||||
|
||||
function itemsOf(payload, key = '') {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.items)) return payload.items;
|
||||
if (key && Array.isArray(payload?.[key])) return payload[key];
|
||||
return [];
|
||||
}
|
||||
|
||||
function lowerEnum(value) {
|
||||
return String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
||||
}
|
||||
|
||||
function catalogPlans(payload) {
|
||||
const offerings = itemsOf(payload, 'offerings');
|
||||
const offeringsById = new Map(offerings.map(item => [item.id, item]));
|
||||
return itemsOf(payload, 'versions')
|
||||
.map(version => {
|
||||
const offering = offeringsById.get(version.offeringId) || {};
|
||||
return {
|
||||
...version,
|
||||
code: offering.code || version.offeringCode,
|
||||
name: offering.name || version.offeringName,
|
||||
description: offering.description || version.description,
|
||||
status: lowerEnum(version.status),
|
||||
billingCycle: lowerEnum(version.billingCycle),
|
||||
baseAmountCents: version.amountCents ?? version.baseAmountCents,
|
||||
includedQuotas: version.limits,
|
||||
offeringType: lowerEnum(offering.type || version.offeringType),
|
||||
};
|
||||
})
|
||||
.filter(item => item.offeringType !== 'add_on' && item.status !== 'retired');
|
||||
}
|
||||
|
||||
const adapters = {
|
||||
plan(item, current = {}) {
|
||||
const quotas = item.includedQuotas && typeof item.includedQuotas === 'object'
|
||||
@@ -535,27 +683,30 @@
|
||||
return {
|
||||
id: item.subscriptionId || item.id,
|
||||
tenantId: item.tenantId,
|
||||
planCode: item.planCode || '',
|
||||
status: mapValue(dictionaries.subscriptionStatusFromApi, item.status, item.status || '-'),
|
||||
cycle: ({ yearly: '年度', monthly: '月度', trial: '试用' })[item.billingCycle] || item.billingCycle || '-',
|
||||
amount: centsToYuan(item.amountCents),
|
||||
planCode: item.planCode || item.baseOfferingVersionId || '',
|
||||
status: mapValue(dictionaries.subscriptionStatusFromApi, lowerEnum(item.status), item.status || '-'),
|
||||
cycle: ({ yearly: '年度', monthly: '月度', quarterly: '季度', trial: '试用' })[lowerEnum(item.billingCycle)] || item.billingCycle || '-',
|
||||
amount: centsToYuan(item.amountCents ?? item.totalAmountCents),
|
||||
start: dateOnly(item.startsAt),
|
||||
expires: dateOnly(item.expiresAt),
|
||||
expires: dateOnly(item.expiresAt || item.currentPeriodEnd),
|
||||
autoRenew: Boolean(item.metadata?.autoRenew),
|
||||
};
|
||||
},
|
||||
invoice(item) {
|
||||
const status = lowerEnum(item.status);
|
||||
const totalCents = item.totalCents ?? item.totalAmountCents;
|
||||
const paidCents = item.paidCents ?? (status === 'paid' ? totalCents : 0);
|
||||
return {
|
||||
id: item.invoiceNo || item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'),
|
||||
period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate),
|
||||
total: centsToYuan(item.totalCents),
|
||||
paid: centsToYuan(item.paidCents),
|
||||
balance: centsToYuan(item.balanceCents),
|
||||
total: centsToYuan(totalCents),
|
||||
paid: centsToYuan(paidCents),
|
||||
balance: centsToYuan(item.balanceCents ?? Math.max(0, Number(totalCents || 0) - Number(paidCents || 0))),
|
||||
due: dateOnly(item.dueDate),
|
||||
status: mapValue(dictionaries.invoiceStatusFromApi, item.status, item.status || '-'),
|
||||
status: mapValue(dictionaries.invoiceStatusFromApi, status, item.status || '-'),
|
||||
note: item.note || '',
|
||||
};
|
||||
},
|
||||
@@ -709,6 +860,131 @@
|
||||
error: item.lastError || '',
|
||||
};
|
||||
},
|
||||
crmConfig(item) {
|
||||
const assignmentPool = Array.isArray(item.assignmentPool) ? item.assignmentPool.join('、') : jsonSummary(item.assignmentPool);
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
provider: item.url ? 'Webhook' : 'generic',
|
||||
endpoint: item.url || '',
|
||||
assignmentMode: ({ round_robin: '按地区轮转', direct: '手工分配', referrer: '来源归属', none: '不分配' })[lowerEnum(item.assignmentMode)] || item.assignmentMode || '-',
|
||||
pool: assignmentPool || item.formName || '-',
|
||||
timeout: Number(item.timeoutSeconds || 0),
|
||||
retry: Number(item.assignmentConfig?.retry || 0),
|
||||
enabled: item.enabled !== false,
|
||||
status: item.enabled === false ? '未启用' : '正常',
|
||||
secretRef: item.secretRef || '',
|
||||
formName: item.formName || '',
|
||||
examType: item.examType || '',
|
||||
};
|
||||
},
|
||||
crmLead(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
student: item.payload?.student || item.leadId || item.recordId || 'CRM 线索',
|
||||
phone: item.payload?.phoneMasked || '-',
|
||||
source: item.source || '-',
|
||||
subject: item.payload?.subject || item.provider || '-',
|
||||
owner: item.payload?.owner || '-',
|
||||
attempts: Number(item.attempts || 0),
|
||||
status: mapValue(dictionaries.crmStatusFromApi, lowerEnum(item.status), item.status || '-'),
|
||||
lastError: item.lastError || '',
|
||||
};
|
||||
},
|
||||
smsChannel(item) {
|
||||
const status = mapValue(dictionaries.providerStatusFromApi, lowerEnum(item.status), item.status || '-');
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
provider: item.provider || '-',
|
||||
name: item.name || item.displayName || '',
|
||||
signature: item.signature || item.name || '-',
|
||||
scene: item.scene || '-',
|
||||
secretRef: item.secretRef || '',
|
||||
quota: Number(item.monthlyQuota || 0),
|
||||
used: Number(item.sentThisMonth || 0),
|
||||
status,
|
||||
enabled: status === '正常',
|
||||
priority: Number(item.priority || 100),
|
||||
};
|
||||
},
|
||||
smsTemplate(item) {
|
||||
const sent = Number(item.sentCount || 0);
|
||||
const failed = Number(item.failedCount || 0);
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
channelId: item.channelId,
|
||||
name: item.name || '-',
|
||||
code: item.code || '-',
|
||||
type: lowerEnum(item.type),
|
||||
status: mapValue(dictionaries.smsAuditStatusFromApi, lowerEnum(item.auditStatus), item.auditStatus || '-'),
|
||||
templateStatus: mapValue(dictionaries.smsTemplateStatusFromApi, lowerEnum(item.status), item.status || '-'),
|
||||
lastSend: dateTime(item.updatedAt),
|
||||
successRate: sent + failed ? `${Math.round(sent / (sent + failed) * 100)}%` : '-',
|
||||
content: item.content || '',
|
||||
};
|
||||
},
|
||||
smsLog(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
tenantId: item.tenantId,
|
||||
templateId: item.templateId,
|
||||
channelId: item.channelId,
|
||||
phone: item.phoneMasked || '-',
|
||||
provider: item.provider || '-',
|
||||
updatedAt: dateTime(item.sentAt || item.createdAt),
|
||||
status: lowerEnum(item.status) === 'sent' ? '发送成功' : lowerEnum(item.status) === 'failed' ? '发送失败' : '待发送',
|
||||
};
|
||||
},
|
||||
platformPaymentApp(item) {
|
||||
const status = mapValue(dictionaries.paymentStatusFromApi, lowerEnum(item.status), item.status || '-');
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: '',
|
||||
appCode: item.appCode || '',
|
||||
appName: item.appName || '-',
|
||||
provider: 'platform',
|
||||
mode: '平台收款',
|
||||
settlement: item.settlementMode || 'PlatformCollect',
|
||||
secretRef: '',
|
||||
status,
|
||||
appScope: 'platform',
|
||||
};
|
||||
},
|
||||
tenantPaymentApp(item) {
|
||||
const status = mapValue(dictionaries.providerStatusFromApi, lowerEnum(item.status), item.status || '-');
|
||||
return {
|
||||
id: item.id,
|
||||
backendId: item.id,
|
||||
tenantId: item.tenantId || '',
|
||||
appName: item.displayName || item.provider || '-',
|
||||
provider: item.provider || '-',
|
||||
mode: item.configPublic?.mode || 'TenantCollect',
|
||||
settlement: '租户收款',
|
||||
secretRef: item.secretRef || '',
|
||||
status,
|
||||
appScope: 'tenant',
|
||||
};
|
||||
},
|
||||
platformPaymentEvent(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
appId: item.appId || '',
|
||||
orderNo: item.providerEventId || item.eventId || item.paymentId || '-',
|
||||
amount: centsToYuan(item.payload?.amountCents || item.amountCents || 0),
|
||||
channel: item.provider || '-',
|
||||
notified: item.eventType || '-',
|
||||
updatedAt: dateTime(item.createdAt || item.processedAt),
|
||||
status: item.error ? '回调失败' : '成功',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function aggregateUsage(items) {
|
||||
@@ -752,13 +1028,29 @@
|
||||
|
||||
const bootstrapDescriptors = [
|
||||
['overview', 'PlatformAdminOverviewController_overview', {}],
|
||||
['plans', 'PlatformAdminOverviewController_plans', { query: { includeArchived: false } }],
|
||||
['plans', 'PlatformAdminOverviewController_plans', {}],
|
||||
['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }],
|
||||
['subscriptions', 'PlatformAdminBillingController_subscriptions', { query: { limit: 200 } }],
|
||||
['invoices', 'PlatformAdminBillingController_invoices', { query: { limit: 200 } }],
|
||||
['usage', 'PlatformAdminBillingController_usage', { query: { limit: 200 } }],
|
||||
['reminders', 'PlatformAdminBillingController_reminders', { query: { limit: 100 } }],
|
||||
['banks', 'PlatformAdminQuestionBanksController_banks', { query: { limit: 200 } }],
|
||||
['grants', 'PlatformAdminQuestionBanksController_grants', { query: { limit: 200 } }],
|
||||
['bankSync', 'PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }],
|
||||
['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }],
|
||||
['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }],
|
||||
['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }],
|
||||
['auditLogs', 'PlatformAdminAuditController_logs', { query: { limit: 200 } }],
|
||||
['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }],
|
||||
['crmConfigs', 'PlatformAdminCrmController_configs', { query: { limit: 200 } }],
|
||||
['crmLeads', 'PlatformAdminCrmController_leads', { query: { limit: 200 } }],
|
||||
['smsChannels', 'PlatformAdminSmsController_channels', { query: { limit: 200 } }],
|
||||
['smsTemplates', 'PlatformAdminSmsController_templates', { query: { limit: 200 } }],
|
||||
['smsLogs', 'PlatformAdminSmsController_logs', { query: { limit: 200 } }],
|
||||
['platformPaymentApps', 'PlatformAdminPaymentSettingsController_apps', { query: { limit: 200 } }],
|
||||
['tenantPaymentApps', 'PlatformAdminTenantPaymentSettingsController_apps', { query: { limit: 200 } }],
|
||||
['platformPaymentEvents', 'PlatformAdminPaymentSettingsController_events', { query: { limit: 100 } }],
|
||||
['tenantPaymentEvents', 'PlatformAdminTenantPaymentSettingsController_events', { query: { limit: 100 } }],
|
||||
];
|
||||
|
||||
async function bootstrap(options = {}) {
|
||||
@@ -782,24 +1074,26 @@
|
||||
|
||||
function adaptBootstrap(payloads, currentData = {}) {
|
||||
const patch = {};
|
||||
if (payloads.overview) patch.apiOverview = payloads.overview.item || payloads.overview;
|
||||
if (payloads.permissions?.item) patch.apiPermissions = payloads.permissions.item;
|
||||
if (Array.isArray(payloads.plans?.items)) {
|
||||
patch.plans = payloads.plans.items.map(item => adapters.plan(item, currentById(currentData.plans, item.code)));
|
||||
if (payloads.overview) patch.apiOverview = itemOf(payloads.overview);
|
||||
if (payloads.permissions) patch.apiPermissions = itemOf(payloads.permissions);
|
||||
const planPayload = itemsOf(payloads.plans, 'versions').length ? catalogPlans(payloads.plans) : itemsOf(payloads.plans);
|
||||
if (planPayload.length) {
|
||||
patch.plans = planPayload.map(item => adapters.plan(item, currentById(currentData.plans, item.code)));
|
||||
}
|
||||
const planItems = patch.plans || currentData.plans || [];
|
||||
const planMap = Object.fromEntries(planItems.map(item => [item.code, item]));
|
||||
if (Array.isArray(payloads.tenants?.items)) {
|
||||
patch.tenants = payloads.tenants.items.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap));
|
||||
const tenants = itemsOf(payloads.tenants);
|
||||
if (tenants.length) {
|
||||
patch.tenants = tenants.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap));
|
||||
}
|
||||
if (Array.isArray(payloads.staff?.items)) patch.staff = payloads.staff.items.map(adapters.staff);
|
||||
if (Array.isArray(payloads.invoices?.items)) patch.invoices = payloads.invoices.items.map(adapters.invoice);
|
||||
if (Array.isArray(payloads.subscriptions?.items)) {
|
||||
patch.subscriptions = payloads.subscriptions.items
|
||||
.filter(item => item.subscriptionId)
|
||||
.map(adapters.subscription);
|
||||
}
|
||||
if (Array.isArray(payloads.usage?.items)) patch.usage = aggregateUsage(payloads.usage.items);
|
||||
const staff = itemsOf(payloads.staff);
|
||||
if (staff.length) patch.staff = staff.map(adapters.staff);
|
||||
const invoices = itemsOf(payloads.invoices);
|
||||
if (invoices.length) patch.invoices = invoices.map(adapters.invoice);
|
||||
const subscriptions = itemsOf(payloads.subscriptions);
|
||||
if (subscriptions.length) patch.subscriptions = subscriptions.map(adapters.subscription);
|
||||
const usage = itemsOf(payloads.usage);
|
||||
if (usage.length) patch.usage = aggregateUsage(usage);
|
||||
if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) {
|
||||
const usageByKey = new Map(patch.usage.map(item => [`${item.tenantId}:${item.period}`, item]));
|
||||
payloads.usageCandidates.items.forEach(candidate => {
|
||||
@@ -809,20 +1103,49 @@
|
||||
record.overage = (candidate.items || []).map(item => item.description || item.itemType).filter(Boolean);
|
||||
});
|
||||
}
|
||||
if (Array.isArray(payloads.reminders?.items)) patch.reminders = payloads.reminders.items.map(adapters.reminder);
|
||||
if (Array.isArray(payloads.dunningChannels?.items)) patch.dunningChannels = payloads.dunningChannels.items.map(adapters.channel);
|
||||
if (Array.isArray(payloads.dunningEvents?.items)) patch.dunningEvents = payloads.dunningEvents.items.map(item => adapters.event(item, 'dunning'));
|
||||
if (Array.isArray(payloads.banks?.items)) {
|
||||
patch.banks = payloads.banks.items.map(item => adapters.bank(item, currentById(currentData.banks, item.id)));
|
||||
const reminders = itemsOf(payloads.reminders);
|
||||
if (reminders.length) patch.reminders = reminders.map(adapters.reminder);
|
||||
const dunningChannels = itemsOf(payloads.dunningChannels);
|
||||
if (dunningChannels.length) patch.dunningChannels = dunningChannels.map(adapters.channel);
|
||||
const dunningEvents = itemsOf(payloads.dunningEvents);
|
||||
if (dunningEvents.length) patch.dunningEvents = dunningEvents.map(item => adapters.event(item, 'dunning'));
|
||||
const banks = itemsOf(payloads.banks);
|
||||
if (banks.length) {
|
||||
patch.banks = banks.map(item => adapters.bank(item, currentById(currentData.banks, item.id)));
|
||||
}
|
||||
if (Array.isArray(payloads.grants?.items)) patch.grants = payloads.grants.items.map(adapters.grant);
|
||||
if (Array.isArray(payloads.bankSync?.items)) patch.bankSync = payloads.bankSync.items.map(adapters.sync);
|
||||
if (Array.isArray(payloads.auditLogs?.items)) patch.auditLogs = payloads.auditLogs.items.map(adapters.audit);
|
||||
if (Array.isArray(payloads.alertRules?.items)) patch.alertRules = payloads.alertRules.items.map(adapters.rule);
|
||||
if (Array.isArray(payloads.alerts?.items)) patch.alerts = payloads.alerts.items.map(adapters.alert);
|
||||
if (Array.isArray(payloads.auditChannels?.items)) patch.auditChannels = payloads.auditChannels.items.map(adapters.channel);
|
||||
if (Array.isArray(payloads.notificationEvents?.items)) {
|
||||
patch.notificationEvents = payloads.notificationEvents.items.map(item => adapters.event(item, 'audit'));
|
||||
const grants = itemsOf(payloads.grants);
|
||||
if (grants.length) patch.grants = grants.map(adapters.grant);
|
||||
const bankSync = itemsOf(payloads.bankSync);
|
||||
if (bankSync.length) patch.bankSync = bankSync.map(adapters.sync);
|
||||
const auditLogs = itemsOf(payloads.auditLogs);
|
||||
if (auditLogs.length) patch.auditLogs = auditLogs.map(adapters.audit);
|
||||
const alertRules = itemsOf(payloads.alertRules);
|
||||
if (alertRules.length) patch.alertRules = alertRules.map(adapters.rule);
|
||||
const alerts = itemsOf(payloads.alerts);
|
||||
if (alerts.length) patch.alerts = alerts.map(adapters.alert);
|
||||
const crmConfigs = itemsOf(payloads.crmConfigs);
|
||||
if (crmConfigs.length) patch.crmConfigs = crmConfigs.map(adapters.crmConfig);
|
||||
const crmLeads = itemsOf(payloads.crmLeads);
|
||||
if (crmLeads.length) patch.crmLeads = crmLeads.map(adapters.crmLead);
|
||||
const smsChannels = itemsOf(payloads.smsChannels);
|
||||
if (smsChannels.length) patch.smsChannels = smsChannels.map(adapters.smsChannel);
|
||||
const smsTemplates = itemsOf(payloads.smsTemplates);
|
||||
if (smsTemplates.length) patch.smsTemplates = smsTemplates.map(adapters.smsTemplate);
|
||||
const smsLogs = itemsOf(payloads.smsLogs);
|
||||
if (smsLogs.length) patch.smsLogs = smsLogs.map(adapters.smsLog);
|
||||
const platformPaymentApps = itemsOf(payloads.platformPaymentApps).map(adapters.platformPaymentApp);
|
||||
const tenantPaymentApps = itemsOf(payloads.tenantPaymentApps).map(adapters.tenantPaymentApp);
|
||||
if (platformPaymentApps.length || tenantPaymentApps.length) patch.paymentApps = [...platformPaymentApps, ...tenantPaymentApps];
|
||||
const paymentEvents = [
|
||||
...itemsOf(payloads.platformPaymentEvents).map(adapters.platformPaymentEvent),
|
||||
...itemsOf(payloads.tenantPaymentEvents).map(adapters.platformPaymentEvent),
|
||||
];
|
||||
if (paymentEvents.length) patch.paymentEvents = paymentEvents;
|
||||
const auditChannels = itemsOf(payloads.auditChannels);
|
||||
if (auditChannels.length) patch.auditChannels = auditChannels.map(adapters.channel);
|
||||
const notificationEvents = itemsOf(payloads.notificationEvents);
|
||||
if (notificationEvents.length) {
|
||||
patch.notificationEvents = notificationEvents.map(item => adapters.event(item, 'audit'));
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
['P-03', 'act.p-03.02', '编辑账务资料', 'command', 'native', 'nav', '[data-action="edit-billing-profile"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-03', 'act.p-03.03', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription-for-tenant"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'],
|
||||
['P-03', 'act.p-03.04', '查看账单/用量', 'command', 'native', 'nav', '[data-action="tenant-to-billing"],[data-action="tenant-to-usage"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||
['P-03', 'act.p-03.05', '跳转租户门户(只做链接,不冒充登录)', 'command', 'native', 'nav', '[data-action="open-tenant-portal"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||
['P-03', 'act.p-03.05', '打开租户门户入口', 'command', 'native', 'nav', '[data-action="open-tenant-portal"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||
['P-04', 'act.p-04.01', '编辑', 'command', 'native', 'mutation', '#billingProfileForm', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-04', 'act.p-04.02', '保存', 'command', 'native', 'mutation', '[form="billingProfileForm"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||
['P-04', 'act.p-04.03', '查看关联账单', 'command', 'native', 'nav', '[data-action="billing-profile-to-invoices"]', 'PlatformAdminTenantsController_billingProfile', 'allow'],
|
||||
@@ -45,20 +45,31 @@
|
||||
['P-09', 'act.p-09.03', '新建/编辑/禁用授权', 'destructive', 'native', 'mutation', '[data-action="new-grant"],[data-action="edit-grant"],[data-action="toggle-grant"]', 'PlatformAdminQuestionBanksController_upsertGrant', 'block'],
|
||||
['P-09', 'act.p-09.04', '选择题库', 'command', 'native', 'read', '[data-bank-select]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||
['P-09', 'act.p-09.05', '查看租户采用/同步', 'async', 'contract', 'task', '[data-action="preview-bank-sync"]', 'PlatformAdminQuestionBanksController_syncStatus', 'allow'],
|
||||
['P-10', 'act.p-10.01', '新建/编辑', 'command', 'native', 'mutation', '[data-action="new-staff"],[data-action="edit-staff"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-10', 'act.p-10.02', '全选/分组授权', 'command', 'native', 'mutation', '[data-action="select-permission-group"]', 'PlatformAdminOverviewController_permissions', 'block'],
|
||||
['P-10', 'act.p-10.03', '启用/禁用', 'destructive', 'native', 'mutation', '[data-action="toggle-staff"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-10', 'act.p-10.04', '禁用时撤销会话', 'destructive', 'native', 'audit', '[data-action="execute-staff-disable"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-10', 'act.p-10.05', '清空表单', 'command', 'contract', 'mutation', '[data-action="clear-staff-form"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-11', 'act.p-11.01', '筛选', 'command', 'native', 'read', '#auditSearch,#auditRangeFilter,[data-audit-severity]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-11', 'act.p-11.02', '查看详情', 'command', 'native', 'read', '[data-audit-detail]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-11', 'act.p-11.03', '导出 CSV/JSON', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_exportLogs', 'allow'],
|
||||
['P-11', 'act.p-11.04', '复制 ID', 'command', 'native', 'read', '[data-copy]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-11', 'act.p-11.05', '跳目标对象', 'command', 'native', 'nav', '[data-action="jump-audit-target"],[data-action="jump-related-alert"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-12', 'act.p-12.01', '确认/解决告警', 'command', 'native', 'mutation', '[data-action="transition-alert"]', 'PlatformAdminAuditController_updateAlert', 'allow'],
|
||||
['P-12', 'act.p-12.02', '查看证据', 'command', 'native', 'read', '[data-alert-detail]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||
['P-12', 'act.p-12.03', '新建/编辑/启停通知渠道', 'command', 'native', 'mutation', '[data-action="new-audit-channel"],[data-action="edit-audit-channel"],[data-action="toggle-audit-channel"]', 'PlatformAdminAuditController_upsertChannel', 'block'],
|
||||
['P-12', 'act.p-12.04', '查看失败事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminAuditController_events', 'allow'],
|
||||
['P-10', 'act.p-10.01', '切换 CRM 视图', 'command', 'native', 'read', '[data-crm-tab]', 'PlatformAdminCrmController_configs', 'allow'],
|
||||
['P-10', 'act.p-10.02', '新建/编辑 CRM 接入', 'command', 'native', 'mutation', '[data-action="new-crm-config"],[data-action="edit-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
|
||||
['P-10', 'act.p-10.03', '启停 CRM 接入', 'destructive', 'native', 'mutation', '[data-action="toggle-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'],
|
||||
['P-10', 'act.p-10.04', '重试线索推送', 'command', 'native', 'mutation', '[data-action="retry-crm-lead"]', 'PlatformAdminCrmController_retryLead', 'block'],
|
||||
['P-11', 'act.p-11.01', '切换短信视图', 'command', 'native', 'read', '[data-sms-tab]', 'PlatformAdminSmsController_channels', 'allow'],
|
||||
['P-11', 'act.p-11.02', '新建/编辑短信渠道', 'command', 'native', 'mutation', '[data-action="new-sms-channel"],[data-action="edit-sms-channel"]', 'PlatformAdminSmsController_upsertChannel', 'block'],
|
||||
['P-11', 'act.p-11.03', '启停短信渠道', 'destructive', 'native', 'mutation', '[data-action="toggle-sms-channel"]', 'PlatformAdminSmsController_upsertChannel', 'block'],
|
||||
['P-12', 'act.p-12.01', '切换支付设置视图', 'command', 'native', 'read', '[data-payment-tab]', 'PlatformAdminPaymentSettingsController_apps', 'allow'],
|
||||
['P-12', 'act.p-12.02', '新建/编辑支付应用', 'command', 'native', 'mutation', '[data-action="new-payment-app"],[data-action="edit-payment-app"]', 'PlatformAdminPaymentSettingsController_upsertApp', 'block'],
|
||||
['P-12', 'act.p-12.03', '启停支付应用', 'destructive', 'native', 'mutation', '[data-action="toggle-payment-app"]', 'PlatformAdminPaymentSettingsController_upsertApp', 'block'],
|
||||
['P-12', 'act.p-12.04', '查看风控规则', 'command', 'native', 'read', '[data-action="payment-rule-detail"]', 'PlatformAdminPaymentSettingsController_events', 'allow'],
|
||||
['P-13', 'act.p-13.01', '新建/编辑', 'command', 'native', 'mutation', '[data-action="new-staff"],[data-action="edit-staff"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-13', 'act.p-13.02', '全选/分组授权', 'command', 'native', 'mutation', '[data-action="select-permission-group"]', 'PlatformAdminOverviewController_permissions', 'block'],
|
||||
['P-13', 'act.p-13.03', '启用/禁用', 'destructive', 'native', 'mutation', '[data-action="toggle-staff"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-13', 'act.p-13.04', '禁用时撤销会话', 'destructive', 'native', 'audit', '[data-action="execute-staff-disable"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||
['P-13', 'act.p-13.05', '清空表单', 'command', 'contract', 'mutation', '[data-action="clear-staff-form"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||
['P-14', 'act.p-14.01', '筛选', 'command', 'native', 'read', '#auditSearch,#auditRangeFilter,[data-audit-severity]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.02', '查看详情', 'command', 'native', 'read', '[data-audit-detail]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.03', '导出 CSV/JSON', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_exportLogs', 'allow'],
|
||||
['P-14', 'act.p-14.04', '复制 ID', 'command', 'native', 'read', '[data-copy]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-14', 'act.p-14.05', '跳目标对象', 'command', 'native', 'nav', '[data-action="jump-audit-target"],[data-action="jump-related-alert"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||
['P-15', 'act.p-15.01', '确认/解决告警', 'command', 'native', 'mutation', '[data-action="transition-alert"]', 'PlatformAdminAuditController_updateAlert', 'allow'],
|
||||
['P-15', 'act.p-15.02', '查看证据', 'command', 'native', 'read', '[data-alert-detail]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||
['P-15', 'act.p-15.03', '新建/编辑/启停通知渠道', 'command', 'native', 'mutation', '[data-action="new-audit-channel"],[data-action="edit-audit-channel"],[data-action="toggle-audit-channel"]', 'PlatformAdminAuditController_upsertChannel', 'block'],
|
||||
['P-15', 'act.p-15.04', '查看失败事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminAuditController_events', 'allow'],
|
||||
].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true }));
|
||||
|
||||
const operationRows = [
|
||||
@@ -70,22 +81,23 @@
|
||||
['P-03', 'PlatformAdminTenantsController_detail', 'GET', '/api/platform-admin/tenants/detail'],
|
||||
['P-03', 'PlatformAdminTenantsController_status', 'PATCH', '/api/platform-admin/tenants/status'],
|
||||
['P-03', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||
['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/subscriptions'],
|
||||
['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||
['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/plans'],
|
||||
['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/subscriptions'],
|
||||
['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/invoices'],
|
||||
['P-06', 'PlatformAdminBillingController_createInvoice', 'POST', '/api/platform-admin/invoices'],
|
||||
['P-06', 'PlatformAdminBillingController_subscriptionCandidates', 'GET', '/api/platform-admin/invoices/subscription-candidates'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscription', 'POST', '/api/platform-admin/invoices/from-subscription'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'POST', '/api/platform-admin/invoices/from-subscriptions-batch'],
|
||||
['P-07', 'PlatformAdminBillingController_usage', 'GET', '/api/platform-admin/usage'],
|
||||
['P-07', 'PlatformAdminBillingController_recordUsage', 'POST', '/api/platform-admin/usage'],
|
||||
['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/invoices/usage-overage-candidates'],
|
||||
['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/invoices/from-usage-overage'],
|
||||
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/invoices/payments/manual-confirm'],
|
||||
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/invoices/process-overdue'],
|
||||
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/invoices/reminders'],
|
||||
['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/saas/catalog'],
|
||||
['P-05', 'PlatformAdminBillingController_subscriptions', 'GET', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'],
|
||||
['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/saas/invoices'],
|
||||
['P-06', 'PlatformAdminBillingController_createInvoice', 'POST', '/api/platform-admin/saas/invoices'],
|
||||
['P-06', 'PlatformAdminBillingController_subscriptionCandidates', 'GET', '/api/platform-admin/saas/invoices/subscription-candidates'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscription', 'POST', '/api/platform-admin/saas/invoices/from-subscription'],
|
||||
['P-06', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'POST', '/api/platform-admin/saas/invoices/from-subscriptions-batch'],
|
||||
['P-07', 'PlatformAdminBillingController_usage', 'GET', '/api/platform-admin/saas/usage'],
|
||||
['P-07', 'PlatformAdminBillingController_recordUsage', 'POST', '/api/platform-admin/saas/usage'],
|
||||
['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/saas/invoices/usage-overage-candidates'],
|
||||
['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/saas/invoices/from-usage-overage'],
|
||||
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/saas/payments/manual/confirm'],
|
||||
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/saas/invoices/process-overdue'],
|
||||
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/saas/invoices/reminders'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/saas/dunning/events'],
|
||||
@@ -93,18 +105,37 @@
|
||||
['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_syncStatus', 'GET', '/api/platform-admin/question-bank-sync-status'],
|
||||
['P-10', 'PlatformAdminOverviewController_permissions', 'GET', '/api/platform-admin/permissions'],
|
||||
['P-10', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'],
|
||||
['P-10', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'],
|
||||
['P-10', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'],
|
||||
['P-11', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||
['P-11', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'],
|
||||
['P-12', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'],
|
||||
['P-12', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||
['P-12', 'PlatformAdminAuditController_updateAlert', 'POST', '/api/platform-admin/audit-alerts/status'],
|
||||
['P-12', 'PlatformAdminAuditController_channels', 'GET', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-12', 'PlatformAdminAuditController_upsertChannel', 'PUT', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-12', 'PlatformAdminAuditController_events', 'GET', '/api/platform-admin/audit-notification-events'],
|
||||
['P-10', 'PlatformAdminCrmController_configs', 'GET', '/api/platform-admin/tenant-capabilities/crm/configs'],
|
||||
['P-10', 'PlatformAdminCrmController_upsertConfig', 'PUT', '/api/platform-admin/tenant-capabilities/crm/configs'],
|
||||
['P-10', 'PlatformAdminCrmController_leads', 'GET', '/api/platform-admin/tenant-capabilities/crm/leads'],
|
||||
['P-10', 'PlatformAdminCrmController_retryLead', 'POST', '/api/platform-admin/tenant-capabilities/crm/leads/retry'],
|
||||
['P-10', 'PlatformAdminCrmController_logs', 'GET', '/api/platform-admin/tenant-capabilities/crm/logs'],
|
||||
['P-11', 'PlatformAdminSmsController_channels', 'GET', '/api/platform-admin/tenant-capabilities/sms/channels'],
|
||||
['P-11', 'PlatformAdminSmsController_templates', 'GET', '/api/platform-admin/tenant-capabilities/sms/templates'],
|
||||
['P-11', 'PlatformAdminSmsController_logs', 'GET', '/api/platform-admin/tenant-capabilities/sms/logs'],
|
||||
['P-11', 'PlatformAdminSmsController_upsertChannel', 'PUT', '/api/platform-admin/tenant-capabilities/sms/channels'],
|
||||
['P-11', 'PlatformAdminSmsController_upsertTemplate', 'PUT', '/api/platform-admin/tenant-capabilities/sms/templates'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_apps', 'GET', '/api/platform-admin/payment-settings/apps'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_channels', 'GET', '/api/platform-admin/payment-settings/channels'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_events', 'GET', '/api/platform-admin/payment-settings/events'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_rebates', 'GET', '/api/platform-admin/payment-settings/rebates/summary'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/payment-settings/apps'],
|
||||
['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_apps', 'GET', '/api/platform-admin/tenant-capabilities/payments/apps'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_events', 'GET', '/api/platform-admin/tenant-capabilities/payments/events'],
|
||||
['P-12', 'PlatformAdminTenantPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/tenant-capabilities/payments/apps'],
|
||||
['P-13', 'PlatformAdminOverviewController_permissions', 'GET', '/api/platform-admin/permissions'],
|
||||
['P-13', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'],
|
||||
['P-13', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'],
|
||||
['P-13', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'],
|
||||
['P-14', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||
['P-14', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'],
|
||||
['P-15', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'],
|
||||
['P-15', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||
['P-15', 'PlatformAdminAuditController_updateAlert', 'POST', '/api/platform-admin/audit-alerts/status'],
|
||||
['P-15', 'PlatformAdminAuditController_channels', 'GET', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-15', 'PlatformAdminAuditController_upsertChannel', 'PUT', '/api/platform-admin/audit-notification-channels'],
|
||||
['P-15', 'PlatformAdminAuditController_events', 'GET', '/api/platform-admin/audit-notification-events'],
|
||||
];
|
||||
const operations = operationRows.map(([pageId, operationId, method, path]) => ({ pageId, operationId, method, path }));
|
||||
const operationById = Object.fromEntries(operations.map(operation => [operation.operationId, operation]));
|
||||
@@ -112,7 +143,10 @@
|
||||
const navEdges = [
|
||||
['nav.p-01.p-02', 'P-01', 'P-02', '[data-nav="tenants"]'],
|
||||
['nav.p-01.p-06', 'P-01', 'P-06', '[data-nav="billing"]'],
|
||||
['nav.p-01.p-12', 'P-01', 'P-12', '[data-nav="alerts"],[data-alert-detail]'],
|
||||
['nav.p-01.p-10', 'P-01', 'P-10', '[data-nav="crm"]'],
|
||||
['nav.p-01.p-11', 'P-01', 'P-11', '[data-nav="sms"]'],
|
||||
['nav.p-01.p-12', 'P-01', 'P-12', '[data-nav="paymentSettings"]'],
|
||||
['nav.p-01.p-15', 'P-01', 'P-15', '[data-nav="alerts"],[data-alert-detail]'],
|
||||
['nav.p-02.p-03', 'P-02', 'P-03', '[data-tenant-detail]'],
|
||||
['nav.p-03.p-04', 'P-03', 'P-04', '[data-action="edit-billing-profile"]'],
|
||||
['nav.p-03.p-05', 'P-03', 'P-05', '[data-action="create-subscription-for-tenant"]'],
|
||||
@@ -120,7 +154,7 @@
|
||||
['nav.p-05.p-06', 'P-05', 'P-06', '[data-action="subscription-to-billing"]'],
|
||||
['nav.p-05.p-07', 'P-05', 'P-07', '[data-action="subscriptions-to-usage"]'],
|
||||
['nav.p-06.p-08', 'P-06', 'P-08', '[data-nav="dunning"]'],
|
||||
['nav.p-11.p-12', 'P-11', 'P-12', '[data-action="jump-related-alert"]'],
|
||||
['nav.p-14.p-15', 'P-14', 'P-15', '[data-action="jump-related-alert"]'],
|
||||
].map(([id, fromPageId, toPageId, selector]) => ({ id, fromPageId, toPageId, selector, implemented: true }));
|
||||
|
||||
const supportingReads = [
|
||||
@@ -204,7 +238,7 @@
|
||||
}, true);
|
||||
|
||||
const coverage = () => ({
|
||||
expected: 59,
|
||||
expected: 70,
|
||||
total: actions.length,
|
||||
unique: new Set(actions.map(action => action.id)).size,
|
||||
catalogued: actions.filter(action => action.label && action.kind).length,
|
||||
@@ -227,11 +261,11 @@
|
||||
mobileDeclared: actions.filter(action => action.mobile).length,
|
||||
actuallyExecuted: new Set(trace.filter(entry => entry.phase === 'result' && entry.result === 'success').map(entry => entry.actionId)).size,
|
||||
executionNote: 'catalogue and selector metadata are not counted as successful business execution',
|
||||
navigation: { expected: 11, total: navEdges.length, unique: new Set(navEdges.map(edge => edge.id)).size },
|
||||
pageBindings: { expected: 43, total: operations.length, unique: new Set(operations.map(operation => `${operation.pageId}:${operation.operationId}`)).size },
|
||||
navigation: { expected: 14, total: navEdges.length, unique: new Set(navEdges.map(edge => edge.id)).size },
|
||||
pageBindings: { expected: 63, total: operations.length, unique: new Set(operations.map(operation => `${operation.pageId}:${operation.operationId}`)).size },
|
||||
});
|
||||
const assertions = {
|
||||
pages: new Set(actions.map(action => action.pageId)).size === 12,
|
||||
pages: new Set(actions.map(action => action.pageId)).size === 15,
|
||||
actionIds: actions.every(action => action.id.startsWith(`act.${action.pageId.toLowerCase()}.`)),
|
||||
duplicateActions: new Set(actions.map(action => action.id)).size === actions.length,
|
||||
operationContracts: operations.every(operation => /^(GET|POST|PUT|PATCH|DELETE)$/.test(operation.method) && operation.path.startsWith('/api/')),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Application.PlatformAdmin;
|
||||
|
||||
public sealed record PlatformCapabilityActor(Guid UserId);
|
||||
public sealed record PlatformCapabilityQuery(Guid? TenantId = null, string? Status = null, int Limit = 100);
|
||||
|
||||
public sealed record PlatformCrmConfigItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
bool Enabled,
|
||||
string? Url,
|
||||
string? SecretRef,
|
||||
string? FormName,
|
||||
string? ExamType,
|
||||
int? TimeoutSeconds,
|
||||
int? DelaySeconds,
|
||||
ReferralAssignmentMode AssignmentMode,
|
||||
JsonElement AssignmentPool,
|
||||
JsonElement AssignmentConfig,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformCrmConfigCommand(
|
||||
Guid? Id,
|
||||
Guid TenantId,
|
||||
bool Enabled,
|
||||
string? Url,
|
||||
string? SecretRef,
|
||||
string? FormName,
|
||||
string? ExamType,
|
||||
int? TimeoutSeconds,
|
||||
int? DelaySeconds,
|
||||
string? AssignmentMode,
|
||||
JsonElement? AssignmentPool,
|
||||
JsonElement? AssignmentConfig);
|
||||
|
||||
public sealed record PlatformCrmLeadRetryCommand(Guid QueueId, string? Note);
|
||||
public sealed record PlatformTenantCapabilityList<T>(IReadOnlyCollection<T> Items);
|
||||
|
||||
public interface IPlatformCrmAdminService
|
||||
{
|
||||
Task<PlatformTenantCapabilityList<PlatformCrmConfigItem>> GetConfigsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformCrmConfigItem> UpsertConfigAsync(PlatformCapabilityActor actor, UpsertPlatformCrmConfigCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantCapabilityList<CrmWebhookQueueItem>> GetLeadsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
Task<CrmWebhookQueueItem> RetryLeadAsync(PlatformCapabilityActor actor, PlatformCrmLeadRetryCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantCapabilityList<CrmWebhookLog>> GetLogsAsync(PlatformCapabilityActor actor, Guid? tenantId, Guid? queueId, int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PlatformSmsChannelItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
string Provider,
|
||||
string Name,
|
||||
string Signature,
|
||||
string Scene,
|
||||
TenantExternalProviderStatus Status,
|
||||
string? SecretRef,
|
||||
int Priority,
|
||||
int MonthlyQuota,
|
||||
int SentThisMonth,
|
||||
JsonElement ConfigPublic,
|
||||
JsonElement Metadata,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformSmsChannelCommand(
|
||||
Guid? Id,
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
string Name,
|
||||
string Signature,
|
||||
string Scene,
|
||||
TenantExternalProviderStatus Status,
|
||||
string? SecretRef,
|
||||
int? Priority,
|
||||
int? MonthlyQuota,
|
||||
JsonElement ConfigPublic,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformSmsTemplateItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
Guid ChannelId,
|
||||
string ChannelName,
|
||||
string Code,
|
||||
string Name,
|
||||
SmsTemplateType Type,
|
||||
SmsTemplateAuditStatus AuditStatus,
|
||||
SmsTemplateStatus Status,
|
||||
string? ProviderTemplateCode,
|
||||
string Content,
|
||||
string? Remark,
|
||||
int SentCount,
|
||||
int FailedCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformSmsTemplateCommand(
|
||||
Guid? Id,
|
||||
Guid TenantId,
|
||||
Guid ChannelId,
|
||||
string Code,
|
||||
string Name,
|
||||
SmsTemplateType Type,
|
||||
SmsTemplateAuditStatus AuditStatus,
|
||||
SmsTemplateStatus Status,
|
||||
string? ProviderTemplateCode,
|
||||
string Content,
|
||||
string? Remark,
|
||||
JsonElement Metadata);
|
||||
|
||||
public interface IPlatformSmsAdminService
|
||||
{
|
||||
Task<PlatformTenantCapabilityList<PlatformSmsChannelItem>> GetChannelsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformSmsChannelItem> UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformSmsChannelItem> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantCapabilityList<PlatformSmsTemplateItem>> GetTemplatesAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformSmsTemplateItem> UpsertTemplateAsync(PlatformCapabilityActor actor, UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformSmsTemplateItem> SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformSmsTemplateItem> DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantCapabilityList<SmsSendLog>> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record UpsertPlatformPaymentAppCommand(
|
||||
Guid? Id,
|
||||
string AppCode,
|
||||
string AppName,
|
||||
PlatformPaymentAppStatus Status,
|
||||
string SettlementMode,
|
||||
string? Description,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpsertPlatformPaymentChannelCommand(
|
||||
Guid? Id,
|
||||
Guid AppId,
|
||||
string Provider,
|
||||
string Mode,
|
||||
PlatformPaymentChannelStatus Status,
|
||||
string DisplayName,
|
||||
string? SecretRef,
|
||||
string? CallbackPath,
|
||||
int? Priority,
|
||||
JsonElement ConfigPublic,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformRebateSummary(int GrossAmountCents, int CommissionAmountCents, int PendingSettlementCents, int PaidSettlementCents, int ExceptionCount);
|
||||
|
||||
public interface IPlatformPaymentSettingsService
|
||||
{
|
||||
Task<IReadOnlyCollection<PlatformPaymentApp>> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPaymentApp> UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformPaymentChannel>> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, string? status, int limit, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPaymentChannel> UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPaymentChannel> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingPaymentEvent>> GetEventsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default);
|
||||
Task<PlatformRebateSummary> GetRebateSummaryAsync(PlatformCapabilityActor actor, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPlatformTenantPaymentAdminService
|
||||
{
|
||||
Task<PlatformTenantCapabilityList<TenantExternalProviderItem>> GetAppsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
Task<TenantExternalProviderItem> UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantCapabilityList<object>> GetEventsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformCapabilityException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -29,6 +29,12 @@ public static class BackendPermissions
|
||||
public const string PlatformBillingNotification = "platform:billing:notification";
|
||||
public const string PlatformSaasCatalogManage = "platform:saas-catalog:manage";
|
||||
public const string PlatformSaasBillingManage = "platform:saas-billing:manage";
|
||||
public const string PlatformCrmRead = "platform:crm:read";
|
||||
public const string PlatformCrmWrite = "platform:crm:write";
|
||||
public const string PlatformSmsRead = "platform:sms:read";
|
||||
public const string PlatformSmsWrite = "platform:sms:write";
|
||||
public const string PlatformPaymentRead = "platform:payment:read";
|
||||
public const string PlatformPaymentWrite = "platform:payment:write";
|
||||
|
||||
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -61,7 +67,13 @@ public static class BackendPermissions
|
||||
PlatformAuditView,
|
||||
PlatformBillingNotification,
|
||||
PlatformSaasCatalogManage,
|
||||
PlatformSaasBillingManage
|
||||
PlatformSaasBillingManage,
|
||||
PlatformCrmRead,
|
||||
PlatformCrmWrite,
|
||||
PlatformSmsRead,
|
||||
PlatformSmsWrite,
|
||||
PlatformPaymentRead,
|
||||
PlatformPaymentWrite
|
||||
};
|
||||
|
||||
public static void EnsureTenant(string permissionCode)
|
||||
|
||||
@@ -80,6 +80,9 @@ public static class PermissionModuleCatalog
|
||||
["platform_content"] = null,
|
||||
["platform_audit"] = null,
|
||||
["platform_billing"] = null,
|
||||
["platform_crm"] = null,
|
||||
["platform_sms"] = null,
|
||||
["platform_payment"] = null,
|
||||
["commerce"] = SaasFeatureCatalog.StudentStore
|
||||
};
|
||||
|
||||
@@ -108,6 +111,9 @@ public static class PermissionModuleCatalog
|
||||
BackendPermissions.PlatformAuditView => "platform_audit",
|
||||
BackendPermissions.PlatformBillingNotification => "platform_billing",
|
||||
BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => "platform_billing",
|
||||
BackendPermissions.PlatformCrmRead or BackendPermissions.PlatformCrmWrite => "platform_crm",
|
||||
BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms",
|
||||
BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment",
|
||||
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
|
||||
};
|
||||
|
||||
@@ -110,6 +110,30 @@ public sealed class PlatformBillingDunningNotificationEvent : AuditableTenantEnt
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformPaymentApp : AuditableEntity
|
||||
{
|
||||
public string AppCode { get; set; } = string.Empty;
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
public PlatformPaymentAppStatus Status { get; set; } = PlatformPaymentAppStatus.Disabled;
|
||||
public string SettlementMode { get; set; } = "PlatformCollect";
|
||||
public string? Description { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformPaymentChannel : AuditableEntity
|
||||
{
|
||||
public Guid AppId { get; set; }
|
||||
public string Provider { get; set; } = "manual";
|
||||
public string Mode { get; set; } = "PlatformCollect";
|
||||
public PlatformPaymentChannelStatus Status { get; set; } = PlatformPaymentChannelStatus.Disabled;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? SecretRef { get; set; }
|
||||
public string? CallbackPath { get; set; }
|
||||
public int Priority { get; set; } = 100;
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime }
|
||||
public enum TenantBillingInvoiceTitleType { None, NormalVat, SpecialVat }
|
||||
public enum PlatformBillingInvoiceReminderType { DueSoon, Overdue, FinalNotice, Manual }
|
||||
@@ -119,3 +143,5 @@ public enum PlatformAlertSeverity { Low, Medium, High, Critical }
|
||||
public enum PlatformAuditAlertStatus { Open, Acknowledged, Resolved, Ignored }
|
||||
public enum PlatformBillingDunningProvider { Generic, Dingtalk, Feishu, Wecom }
|
||||
public enum PlatformBillingDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded }
|
||||
public enum PlatformPaymentAppStatus { Active, Disabled, Testing }
|
||||
public enum PlatformPaymentChannelStatus { Active, Disabled, Testing }
|
||||
|
||||
@@ -110,6 +110,54 @@ public sealed class SmsSendRateLimit : ITenantOwned
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class SmsChannel : AuditableTenantEntity
|
||||
{
|
||||
public string Provider { get; set; } = "generic";
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
public string Scene { get; set; } = "login";
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
public string? SecretRef { get; set; }
|
||||
public int Priority { get; set; } = 100;
|
||||
public int MonthlyQuota { get; set; }
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class SmsTemplate : AuditableTenantEntity
|
||||
{
|
||||
public Guid ChannelId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public SmsTemplateType Type { get; set; } = SmsTemplateType.Notification;
|
||||
public SmsTemplateAuditStatus AuditStatus { get; set; } = SmsTemplateAuditStatus.Draft;
|
||||
public SmsTemplateStatus Status { get; set; } = SmsTemplateStatus.Active;
|
||||
public string? ProviderTemplateCode { get; set; }
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string? Remark { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public DateTimeOffset? DisabledAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class SmsSendLog : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid? ChannelId { get; set; }
|
||||
public Guid? TemplateId { get; set; }
|
||||
public string Provider { get; set; } = "generic";
|
||||
public string Scene { get; set; } = string.Empty;
|
||||
public string? PhoneMasked { get; set; }
|
||||
public SmsSendLogStatus Status { get; set; } = SmsSendLogStatus.Pending;
|
||||
public string? ProviderMessageId { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset? SentAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TenantClass : AuditableTenantEntity
|
||||
{
|
||||
public Guid? RegionId { get; set; }
|
||||
@@ -172,6 +220,10 @@ public enum TenantExternalProviderStatus { Active, Disabled, Testing }
|
||||
public enum TenantSecretStatus { Active, Disabled, Rotating }
|
||||
public enum SmsPurpose { Login, BindPhone, ResetPassword }
|
||||
public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed }
|
||||
public enum SmsTemplateType { VerificationCode, Marketing, Notification }
|
||||
public enum SmsTemplateAuditStatus { Draft, PendingReview, Approved, Rejected }
|
||||
public enum SmsTemplateStatus { Active, Disabled }
|
||||
public enum SmsSendLogStatus { Pending, Sent, Failed }
|
||||
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
|
||||
public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device }
|
||||
public enum TenantRecordStatus { Active, Disabled, Archived }
|
||||
|
||||
@@ -58,6 +58,9 @@ internal sealed class BackofficeService(
|
||||
new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230),
|
||||
new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240),
|
||||
new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250),
|
||||
new("platform_crm", "CRM 接入代管", BackendPermissionArea.Platform, null, 260),
|
||||
new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270),
|
||||
new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280),
|
||||
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
|
||||
];
|
||||
|
||||
@@ -89,6 +92,12 @@ internal sealed class BackofficeService(
|
||||
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"),
|
||||
new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"),
|
||||
new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"),
|
||||
new(BackendPermissions.PlatformSmsWrite, "平台短信管理", BackendPermissionArea.Platform, "platform_sms"),
|
||||
new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
|
||||
@@ -113,7 +122,10 @@ internal sealed class BackofficeService(
|
||||
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
|
||||
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
|
||||
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50),
|
||||
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60)
|
||||
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60),
|
||||
new("platform.crm", null, "CRM 接入", BackendPermissionArea.Platform, "/platform/crm", BackendPermissions.PlatformCrmRead, 70),
|
||||
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/platform/sms", BackendPermissions.PlatformSmsRead, 80),
|
||||
new("platform.payment", null, "支付设置", BackendPermissionArea.Platform, "/platform/payment-settings", BackendPermissions.PlatformPaymentRead, 90)
|
||||
];
|
||||
|
||||
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||
|
||||
@@ -8,6 +8,8 @@ using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Bootstrap;
|
||||
@@ -31,6 +33,9 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
|
||||
if (dbContext.PlatformBackendUserRoles.Any())
|
||||
{
|
||||
EnsurePlatformPermissionCatalog(dbContext);
|
||||
SeedDemoTenantCapabilities(dbContext);
|
||||
dbContext.SaveChanges();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,6 +49,7 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
.Select(permission => permission.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||
SeedDemoTenantCapabilities(dbContext);
|
||||
dbContext.SaveChanges();
|
||||
WriteFirstLoginInstructions(temporaryPassword);
|
||||
return true;
|
||||
@@ -57,6 +63,9 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
|
||||
if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken))
|
||||
{
|
||||
EnsurePlatformPermissionCatalog(dbContext);
|
||||
SeedDemoTenantCapabilities(dbContext);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -72,6 +81,7 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||
SeedDemoTenantCapabilities(dbContext);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
WriteFirstLoginInstructions(temporaryPassword);
|
||||
return true;
|
||||
@@ -166,6 +176,299 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
});
|
||||
}
|
||||
|
||||
private static void EnsurePlatformPermissionCatalog(TikuDbContext dbContext)
|
||||
{
|
||||
var permissionCodes = BackendPermissions.Platform.ToArray();
|
||||
var moduleCodes = permissionCodes
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var existingModuleCodes = dbContext.PermissionModules
|
||||
.Where(module => moduleCodes.Contains(module.Code))
|
||||
.Select(module => module.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var code in moduleCodes.Where(code => !existingModuleCodes.Contains(code)))
|
||||
{
|
||||
dbContext.PermissionModules.Add(new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
||||
});
|
||||
}
|
||||
|
||||
var existingPermissionCodes = dbContext.BackendPermissions
|
||||
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var code in permissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
|
||||
{
|
||||
dbContext.BackendPermissions.Add(new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
|
||||
var superAdminRoleId = dbContext.PlatformBackendRoles
|
||||
.Where(role => role.Code == RoleCode)
|
||||
.Select(role => (Guid?)role.Id)
|
||||
.FirstOrDefault();
|
||||
if (superAdminRoleId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var boundPermissionCodes = dbContext.PlatformBackendRolePermissions
|
||||
.Where(binding => binding.RoleId == superAdminRoleId.Value)
|
||||
.Select(binding => binding.PermissionCode)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(permissionCodes
|
||||
.Where(code => !boundPermissionCodes.Contains(code))
|
||||
.Select(code => new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = superAdminRoleId.Value,
|
||||
PermissionCode = code
|
||||
}));
|
||||
}
|
||||
|
||||
private static void SeedDemoTenantCapabilities(TikuDbContext dbContext)
|
||||
{
|
||||
var tenantA = EnsureTenant(dbContext, "demo-crm-school", "演示题库机构 A");
|
||||
var tenantB = EnsureTenant(dbContext, "demo-sms-campus", "演示题库机构 B");
|
||||
EnsureCrmDemo(dbContext, tenantA);
|
||||
EnsureSmsDemo(dbContext, tenantA, "aliyun", "登录验证码");
|
||||
EnsureSmsDemo(dbContext, tenantB, "tencent", "营销通知");
|
||||
EnsurePlatformPaymentDemo(dbContext, tenantA);
|
||||
EnsureTenantPaymentDemo(dbContext, tenantA);
|
||||
}
|
||||
|
||||
private static Tenant EnsureTenant(TikuDbContext dbContext, string slug, string name)
|
||||
{
|
||||
var tenant = dbContext.Tenants.SingleOrDefault(value => value.Slug == slug);
|
||||
if (tenant is not null)
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
|
||||
tenant = new Tenant
|
||||
{
|
||||
Slug = slug,
|
||||
Name = name,
|
||||
LegalName = $"{name}有限公司",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Active,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private static void EnsureCrmDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
{
|
||||
if (!dbContext.CrmConfigs.Any(value => value.TenantId == tenant.Id))
|
||||
{
|
||||
dbContext.CrmConfigs.Add(new CrmConfig
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Enabled = true,
|
||||
Url = "https://crm.example.test/webhook/tiku",
|
||||
SecretRef = "tenant_secret:crm:demo:redacted",
|
||||
FormName = "学生留资表单",
|
||||
ExamType = "ielts",
|
||||
TimeoutSeconds = 10,
|
||||
DelaySeconds = 0,
|
||||
AssignmentMode = ReferralAssignmentMode.RoundRobin,
|
||||
AssignmentPool = JsonSerializer.SerializeToElement(new[] { "顾问一", "顾问二" }),
|
||||
AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3, source = "development_seed" })
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbContext.CrmWebhookQueue.Any(value => value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead"))
|
||||
{
|
||||
dbContext.CrmWebhookQueue.Add(new CrmWebhookQueueItem
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
RecordId = "CRM-DEMO-LEAD-001",
|
||||
LeadId = "LEAD-001",
|
||||
Source = "tenant.student.crm_push",
|
||||
Provider = "generic",
|
||||
Status = CrmWebhookQueueStatus.Failed,
|
||||
Attempts = 2,
|
||||
LastError = "demo timeout",
|
||||
LastHttpCode = 504,
|
||||
TargetUrl = "https://crm.example.test/webhook/tiku",
|
||||
IdempotencyKey = "demo-crm-failed-lead",
|
||||
Payload = JsonSerializer.SerializeToElement(new { student = "王同学", phoneMasked = "138****0001" })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSmsDemo(TikuDbContext dbContext, Tenant tenant, string provider, string templateName)
|
||||
{
|
||||
var scene = templateName.Contains("登录", StringComparison.Ordinal) ? "login" : "marketing";
|
||||
var channel = dbContext.SmsChannels.SingleOrDefault(value => value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene);
|
||||
if (channel is null)
|
||||
{
|
||||
channel = new SmsChannel
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Provider = provider,
|
||||
Name = $"{tenant.Name} {provider} 短信渠道",
|
||||
Signature = "题库演示",
|
||||
Scene = scene,
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
SecretRef = $"tenant_secret:sms:{provider}:redacted",
|
||||
MonthlyQuota = 10000,
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { region = "cn", redacted = true }),
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
};
|
||||
dbContext.SmsChannels.Add(channel);
|
||||
}
|
||||
|
||||
var templateCode = $"demo_{provider}_{scene}";
|
||||
var template = dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode);
|
||||
if (template is null)
|
||||
{
|
||||
template = new SmsTemplate
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
ChannelId = channel.Id,
|
||||
Code = templateCode,
|
||||
Name = templateName,
|
||||
Type = scene == "login" ? SmsTemplateType.VerificationCode : SmsTemplateType.Marketing,
|
||||
AuditStatus = SmsTemplateAuditStatus.Approved,
|
||||
Status = SmsTemplateStatus.Active,
|
||||
ProviderTemplateCode = $"TPL_{provider.ToUpperInvariant()}_DEMO",
|
||||
Content = "您的验证码为 ${code},请勿泄露。",
|
||||
ApprovedAt = DateTimeOffset.UtcNow,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
};
|
||||
dbContext.SmsTemplates.Add(template);
|
||||
}
|
||||
|
||||
if (!dbContext.SmsSendLogs.Any(value => value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001"))
|
||||
{
|
||||
dbContext.SmsSendLogs.Add(new SmsSendLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
ChannelId = channel.Id,
|
||||
TemplateId = template.Id,
|
||||
Provider = provider,
|
||||
Scene = scene,
|
||||
PhoneMasked = "138****0001",
|
||||
Status = SmsSendLogStatus.Sent,
|
||||
ProviderMessageId = $"demo-{provider}-{scene}-001",
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsurePlatformPaymentDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
{
|
||||
var app = dbContext.PlatformPaymentApps.SingleOrDefault(value => value.AppCode == "platform_saas_collect");
|
||||
if (app is null)
|
||||
{
|
||||
app = new PlatformPaymentApp
|
||||
{
|
||||
AppCode = "platform_saas_collect",
|
||||
AppName = "平台 SaaS 收款",
|
||||
Status = PlatformPaymentAppStatus.Active,
|
||||
SettlementMode = "PlatformCollect",
|
||||
Description = "Development demo platform payment app.",
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
};
|
||||
dbContext.PlatformPaymentApps.Add(app);
|
||||
}
|
||||
|
||||
if (!dbContext.PlatformPaymentChannels.Any(value => value.AppId == app.Id && value.Provider == "manual"))
|
||||
{
|
||||
dbContext.PlatformPaymentChannels.Add(new PlatformPaymentChannel
|
||||
{
|
||||
AppId = app.Id,
|
||||
Provider = "manual",
|
||||
Mode = "PlatformCollect",
|
||||
Status = PlatformPaymentChannelStatus.Active,
|
||||
DisplayName = "线下人工确认",
|
||||
SecretRef = "platform_payment:manual:redacted",
|
||||
CallbackPath = "/api/platform-billing/payments/notify/manual",
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true })
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbContext.PlatformBillingPayments.Any(value => value.PaymentNo == "PB-DEMO-MANUAL-001"))
|
||||
{
|
||||
var quote = new PlatformBillingQuote
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
QuoteNo = "BQ-DEMO-001",
|
||||
IdempotencyKey = "demo-platform-payment-quote",
|
||||
Status = PlatformBillingQuoteStatus.Converted,
|
||||
Purpose = PlatformBillingOrderPurpose.NewSubscription,
|
||||
TotalAmountCents = 19900,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
|
||||
};
|
||||
var order = new PlatformBillingOrder
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
QuoteId = quote.Id,
|
||||
OrderNo = "BO-DEMO-001",
|
||||
IdempotencyKey = "demo-platform-payment-order",
|
||||
Purpose = PlatformBillingOrderPurpose.NewSubscription,
|
||||
Status = PlatformBillingOrderStatus.PendingPayment,
|
||||
TotalAmountCents = 19900,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
|
||||
};
|
||||
var payment = new PlatformBillingPayment
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
OrderId = order.Id,
|
||||
PaymentNo = "PB-DEMO-MANUAL-001",
|
||||
IdempotencyKey = "demo-platform-payment-payment",
|
||||
Provider = "manual",
|
||||
Method = "bank_transfer",
|
||||
Status = PlatformBillingPaymentStatus.Pending,
|
||||
AmountCents = 19900
|
||||
};
|
||||
dbContext.PlatformBillingQuotes.Add(quote);
|
||||
dbContext.PlatformBillingOrders.Add(order);
|
||||
dbContext.PlatformBillingPayments.Add(payment);
|
||||
dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
PaymentId = payment.Id,
|
||||
Provider = "manual",
|
||||
ProviderEventId = "demo-platform-payment-created",
|
||||
EventType = "payment_created",
|
||||
Payload = JsonSerializer.SerializeToElement(new { demo = true, amountCents = 19900 })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureTenantPaymentDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
{
|
||||
if (!dbContext.TenantExternalProviders.Any(value => value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && value.Provider == "manual"))
|
||||
{
|
||||
dbContext.TenantExternalProviders.Add(new TenantExternalProvider
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Capability = TenantExternalProviderCapability.Payment,
|
||||
Provider = "manual",
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
DisplayName = "租户线下收款",
|
||||
SecretRef = "tenant_payment:manual:redacted",
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true }),
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateTemporaryPassword() =>
|
||||
$"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";
|
||||
|
||||
|
||||
@@ -127,6 +127,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
|
||||
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
|
||||
services.AddScoped<IPlatformPaymentSettingsService, PlatformPaymentSettingsService>();
|
||||
services.AddScoped<IPlatformTenantPaymentAdminService, PlatformTenantPaymentAdminService>();
|
||||
services.AddScoped<ISaasCatalogAdminService, SaasCatalogAdminService>();
|
||||
services.AddScoped<ITenantBillingService, TenantBillingService>();
|
||||
services.AddScoped<IPlatformBillingAdminService, PlatformBillingAdminService>();
|
||||
|
||||
@@ -158,3 +158,40 @@ internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEn
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformPaymentAppConfiguration : IEntityTypeConfiguration<PlatformPaymentApp>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformPaymentApp> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_payment_apps");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.AppCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.AppName).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.SettlementMode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(500);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => entity.AppCode).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Status, entity.AppCode });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformPaymentChannelConfiguration : IEntityTypeConfiguration<PlatformPaymentChannel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformPaymentChannel> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_payment_channels");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Provider).HasMaxLength(80);
|
||||
builder.Property(entity => entity.Mode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.DisplayName).HasMaxLength(200);
|
||||
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
|
||||
builder.Property(entity => entity.CallbackPath).HasMaxLength(500);
|
||||
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.AppId, entity.Provider }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Status, entity.Priority, entity.Provider });
|
||||
builder.HasOne<PlatformPaymentApp>().WithMany().HasForeignKey(entity => entity.AppId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,80 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<S
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SmsChannelConfiguration : IEntityTypeConfiguration<SmsChannel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SmsChannel> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("sms_channels");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Provider).HasMaxLength(80);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Signature).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Scene).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
|
||||
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.Scene }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.Priority });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SmsTemplateConfiguration : IEntityTypeConfiguration<SmsTemplate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SmsTemplate> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("sms_templates");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(120);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Type).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.AuditStatus).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ProviderTemplateCode).HasMaxLength(120);
|
||||
builder.Property(entity => entity.Content).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Remark).HasMaxLength(500);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.Status });
|
||||
builder.HasOne<SmsChannel>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ChannelId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SmsSendLogConfiguration : IEntityTypeConfiguration<SmsSendLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SmsSendLog> builder)
|
||||
{
|
||||
builder.ToTable("sms_send_logs");
|
||||
builder.HasKey(entity => entity.Id);
|
||||
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(entity => entity.Provider).HasMaxLength(80);
|
||||
builder.Property(entity => entity.Scene).HasMaxLength(100);
|
||||
builder.Property(entity => entity.PhoneMasked).HasMaxLength(40);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ProviderMessageId).HasMaxLength(200);
|
||||
builder.Property(entity => entity.ErrorCode).HasMaxLength(120);
|
||||
builder.Property(entity => entity.ErrorMessage).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.CreatedAt });
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SmsChannel>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ChannelId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<SmsTemplate>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.TemplateId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantClass> builder)
|
||||
|
||||
20068
Tiku.Infrastructure/Persistence/Migrations/20260729071227_PlatformTenantCapabilities.Designer.cs
generated
Normal file
20068
Tiku.Infrastructure/Persistence/Migrations/20260729071227_PlatformTenantCapabilities.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PlatformTenantCapabilities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "platform_payment_apps",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
app_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
app_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
settlement_mode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_platform_payment_apps", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sms_channels",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
signature = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
scene = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
priority = table.Column<int>(type: "integer", nullable: false),
|
||||
monthly_quota = table.Column<int>(type: "integer", nullable: false),
|
||||
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_sms_channels", x => x.id);
|
||||
table.UniqueConstraint("ak_sms_channels_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_channels_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "platform_payment_channels",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
app_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
mode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
callback_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
priority = table.Column<int>(type: "integer", nullable: false),
|
||||
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_platform_payment_channels", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_platform_payment_channels_platform_payment_apps_app_id",
|
||||
column: x => x.app_id,
|
||||
principalTable: "platform_payment_apps",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sms_templates",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
channel_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
audit_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
provider_template_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
content = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
remark = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
approved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
disabled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_sms_templates", x => x.id);
|
||||
table.UniqueConstraint("ak_sms_templates_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_templates_sms_channels_tenant_id_channel_id",
|
||||
columns: x => new { x.tenant_id, x.channel_id },
|
||||
principalTable: "sms_channels",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_templates_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sms_send_logs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
channel_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
template_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
scene = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
phone_masked = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
provider_message_id = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
error_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
error_message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
sent_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_sms_send_logs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_send_logs_sms_channels_tenant_id_channel_id",
|
||||
columns: x => new { x.tenant_id, x.channel_id },
|
||||
principalTable: "sms_channels",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_send_logs_sms_templates_tenant_id_template_id",
|
||||
columns: x => new { x.tenant_id, x.template_id },
|
||||
principalTable: "sms_templates",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_sms_send_logs_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_platform_payment_apps_app_code",
|
||||
table: "platform_payment_apps",
|
||||
column: "app_code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_platform_payment_apps_status_app_code",
|
||||
table: "platform_payment_apps",
|
||||
columns: new[] { "status", "app_code" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_platform_payment_channels_app_id_provider",
|
||||
table: "platform_payment_channels",
|
||||
columns: new[] { "app_id", "provider" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_platform_payment_channels_status_priority_provider",
|
||||
table: "platform_payment_channels",
|
||||
columns: new[] { "status", "priority", "provider" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_channels_tenant_id_provider_scene",
|
||||
table: "sms_channels",
|
||||
columns: new[] { "tenant_id", "provider", "scene" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_channels_tenant_id_status_priority",
|
||||
table: "sms_channels",
|
||||
columns: new[] { "tenant_id", "status", "priority" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_send_logs_tenant_id_channel_id_created_at",
|
||||
table: "sms_send_logs",
|
||||
columns: new[] { "tenant_id", "channel_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_send_logs_tenant_id_created_at",
|
||||
table: "sms_send_logs",
|
||||
columns: new[] { "tenant_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_send_logs_tenant_id_template_id",
|
||||
table: "sms_send_logs",
|
||||
columns: new[] { "tenant_id", "template_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_templates_tenant_id_channel_id_status",
|
||||
table: "sms_templates",
|
||||
columns: new[] { "tenant_id", "channel_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_sms_templates_tenant_id_code",
|
||||
table: "sms_templates",
|
||||
columns: new[] { "tenant_id", "code" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "platform_payment_channels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sms_send_logs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "platform_payment_apps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sms_templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sms_channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13180,6 +13180,161 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AppCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("app_code");
|
||||
|
||||
b.Property<string>("AppName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("app_name");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("SettlementMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("settlement_mode");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_platform_payment_apps");
|
||||
|
||||
b.HasIndex("AppCode")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_platform_payment_apps_app_code");
|
||||
|
||||
b.HasIndex("Status", "AppCode")
|
||||
.HasDatabaseName("ix_platform_payment_apps_status_app_code");
|
||||
|
||||
b.ToTable("platform_payment_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<string>("CallbackPath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("callback_path");
|
||||
|
||||
b.Property<JsonElement>("ConfigPublic")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config_public")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("SecretRef")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("secret_ref");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_platform_payment_channels");
|
||||
|
||||
b.HasIndex("AppId", "Provider")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_platform_payment_channels_app_id_provider");
|
||||
|
||||
b.HasIndex("Status", "Priority", "Provider")
|
||||
.HasDatabaseName("ix_platform_payment_channels_status_priority_provider");
|
||||
|
||||
b.ToTable("platform_payment_channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.SaasFeature", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -14558,6 +14713,193 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsChannel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<JsonElement>("ConfigPublic")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config_public")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MonthlyQuota")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("monthly_quota");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("Scene")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("scene");
|
||||
|
||||
b.Property<string>("SecretRef")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("secret_ref");
|
||||
|
||||
b.Property<string>("Signature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("signature");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_sms_channels");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_sms_channels_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "Provider", "Scene")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_sms_channels_tenant_id_provider_scene");
|
||||
|
||||
b.HasIndex("TenantId", "Status", "Priority")
|
||||
.HasDatabaseName("ix_sms_channels_tenant_id_status_priority");
|
||||
|
||||
b.ToTable("sms_channels", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("ChannelId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("ErrorCode")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("error_code");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("error_message");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("PhoneMasked")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("phone_masked");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("ProviderMessageId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("provider_message_id");
|
||||
|
||||
b.Property<string>("Scene")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("scene");
|
||||
|
||||
b.Property<DateTimeOffset?>("SentAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("sent_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid?>("TemplateId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("template_id");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_sms_send_logs");
|
||||
|
||||
b.HasIndex("TenantId", "CreatedAt")
|
||||
.HasDatabaseName("ix_sms_send_logs_tenant_id_created_at");
|
||||
|
||||
b.HasIndex("TenantId", "TemplateId")
|
||||
.HasDatabaseName("ix_sms_send_logs_tenant_id_template_id");
|
||||
|
||||
b.HasIndex("TenantId", "ChannelId", "CreatedAt")
|
||||
.HasDatabaseName("ix_sms_send_logs_tenant_id_channel_id_created_at");
|
||||
|
||||
b.ToTable("sms_send_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
@@ -14602,6 +14944,114 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsTemplate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("ApprovedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("approved_at");
|
||||
|
||||
b.Property<string>("AuditStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("audit_status");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset?>("DisabledAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("disabled_at");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("ProviderTemplateCode")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("provider_template_code");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("remark");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("submitted_at");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_sms_templates");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_sms_templates_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_sms_templates_tenant_id_code");
|
||||
|
||||
b.HasIndex("TenantId", "ChannelId", "Status")
|
||||
.HasDatabaseName("ix_sms_templates_tenant_id_channel_id_status");
|
||||
|
||||
b.ToTable("sms_templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -19008,6 +19458,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_platform_billing_refunds_platform_billing_payments_tenant_i~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Platform.PlatformPaymentApp", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_platform_payment_channels_platform_payment_apps_app_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.SaasFeatureLimitDefinition", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Platform.SaasFeature", null)
|
||||
@@ -19313,6 +19773,40 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_auth_sessions_users_user_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsChannel", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_sms_channels_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_sms_send_logs_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ChannelId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_sms_send_logs_sms_channels_tenant_id_channel_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.SmsTemplate", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "TemplateId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_sms_send_logs_sms_templates_tenant_id_template_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
@@ -19323,6 +19817,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_sms_send_rate_limits_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsTemplate", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_sms_templates_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ChannelId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_sms_templates_sms_channels_tenant_id_channel_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -54,6 +54,9 @@ public sealed class TikuDbContext(
|
||||
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
|
||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
||||
public DbSet<SmsChannel> SmsChannels => Set<SmsChannel>();
|
||||
public DbSet<SmsTemplate> SmsTemplates => Set<SmsTemplate>();
|
||||
public DbSet<SmsSendLog> SmsSendLogs => Set<SmsSendLog>();
|
||||
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
|
||||
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
|
||||
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
|
||||
@@ -184,6 +187,8 @@ public sealed class TikuDbContext(
|
||||
public DbSet<PlatformAuditAlert> PlatformAuditAlerts => Set<PlatformAuditAlert>();
|
||||
public DbSet<PlatformBillingDunningNotificationChannel> PlatformBillingDunningNotificationChannels => Set<PlatformBillingDunningNotificationChannel>();
|
||||
public DbSet<PlatformBillingDunningNotificationEvent> PlatformBillingDunningNotificationEvents => Set<PlatformBillingDunningNotificationEvent>();
|
||||
public DbSet<PlatformPaymentApp> PlatformPaymentApps => Set<PlatformPaymentApp>();
|
||||
public DbSet<PlatformPaymentChannel> PlatformPaymentChannels => Set<PlatformPaymentChannel>();
|
||||
public DbSet<SaasFeature> SaasFeatures => Set<SaasFeature>();
|
||||
public DbSet<PermissionModule> PermissionModules => Set<PermissionModule>();
|
||||
public DbSet<SaasOffering> SaasOfferings => Set<SaasOffering>();
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
new(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) =>
|
||||
tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken);
|
||||
|
||||
private static bool IsEnabledStatus(string value) => value.Equals("active", StringComparison.OrdinalIgnoreCase) || value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常";
|
||||
private static PlatformCapabilityException Error(string message, string code) => new(message, code);
|
||||
private static int Limit(int value) => Math.Clamp(value, 1, 500);
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(NormalizeEnum(value), true, out var parsed) ? parsed : fallback;
|
||||
private static string? NormalizeEnum(string? value) => 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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, "platform.sms.template.review_submitted", cancellationToken);
|
||||
|
||||
public Task<PlatformSmsTemplateItem> DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) =>
|
||||
ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, "platform.sms.template.disabled", cancellationToken);
|
||||
|
||||
public Task<PlatformTenantCapabilityList<SmsSendLog>> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
new(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) =>
|
||||
new(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) =>
|
||||
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) => new(message, code);
|
||||
private static int Limit(int value) => Math.Clamp(value, 1, 500);
|
||||
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => 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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) => new(message, code);
|
||||
private static int Limit(int value) => Math.Clamp(value, 1, 500);
|
||||
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => 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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) => new(message, code);
|
||||
private static int Limit(int value) => Math.Clamp(value, 1, 500);
|
||||
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -68,8 +69,17 @@ internal sealed class PlatformBillingPaymentGateway(
|
||||
}
|
||||
else
|
||||
{
|
||||
account = await services.GetRequiredService<IPaymentProviderConfigService>()
|
||||
.GetActiveAccountAsync(platformTenantId, normalized, token);
|
||||
var channel = await dbContext.PlatformPaymentChannels.AsNoTracking()
|
||||
.Where(value => value.Provider == normalized && value.Status == PlatformPaymentChannelStatus.Active)
|
||||
.OrderBy(value => value.Priority)
|
||||
.FirstOrDefaultAsync(token)
|
||||
?? throw new PaymentProviderException("Platform payment channel is missing.", "platform_payment_channel_missing");
|
||||
account = new PaymentProviderAccount(
|
||||
platformTenantId,
|
||||
normalized,
|
||||
channel.Mode,
|
||||
channel.ConfigPublic,
|
||||
JsonDocument.Parse("{}").RootElement.Clone());
|
||||
}
|
||||
|
||||
return await operation(resolvedProvider, account, token);
|
||||
|
||||
@@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 358;
|
||||
private const string ExpectedSha256 = "5a26d6f0817b2939eef921d1883ad0dc8f82405a1593b82df951e97654a0225d";
|
||||
private const int ExpectedActionCount = 381;
|
||||
private const string ExpectedSha256 = "0846541f337a32d7ff13f0e0a29eaa17c2e4b7f90c8d6813c977a1855776bab3";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
|
||||
@@ -7,6 +7,7 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
@@ -182,6 +183,134 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_can_manage_crm_sms_and_payment_settings()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
var tenantA = Guid.NewGuid();
|
||||
var tenantB = Guid.NewGuid();
|
||||
var failedQueueId = Guid.NewGuid();
|
||||
var otherQueueId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
|
||||
new Tenant { Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
|
||||
new CrmWebhookQueueItem { Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", LastError = "timeout" },
|
||||
new CrmWebhookQueueItem { Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", LastError = "still failed" });
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var crm = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto
|
||||
{
|
||||
TenantId = tenantA,
|
||||
Enabled = true,
|
||||
Url = "https://crm.example.test/webhook",
|
||||
SecretRef = "tenant_secret:crm:redacted",
|
||||
AssignmentMode = "round_robin",
|
||||
AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }),
|
||||
AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 })
|
||||
});
|
||||
var retry = await client.PostAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" });
|
||||
var smsChannel = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto
|
||||
{
|
||||
TenantId = tenantA,
|
||||
Provider = "aliyun",
|
||||
Name = "阿里云短信",
|
||||
Signature = "题库测试",
|
||||
Scene = "login",
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
SecretRef = "tenant_secret:sms:aliyun:redacted",
|
||||
MonthlyQuota = 1000
|
||||
});
|
||||
var smsJson = JsonDocument.Parse(await smsChannel.Content.ReadAsStringAsync());
|
||||
var channelId = smsJson.RootElement.GetProperty("id").GetGuid();
|
||||
var template = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto
|
||||
{
|
||||
TenantId = tenantA,
|
||||
ChannelId = channelId,
|
||||
Code = "login_code",
|
||||
Name = "登录验证码",
|
||||
Type = SmsTemplateType.VerificationCode,
|
||||
AuditStatus = SmsTemplateAuditStatus.Draft,
|
||||
Status = SmsTemplateStatus.Active,
|
||||
Content = "验证码 ${code}"
|
||||
});
|
||||
var paymentApp = await client.PutAsJsonAsync("/api/platform-admin/payment-settings/apps", new UpsertPlatformPaymentAppDto
|
||||
{
|
||||
AppCode = "platform_collect_test",
|
||||
AppName = "平台收款测试",
|
||||
Status = PlatformPaymentAppStatus.Active,
|
||||
SettlementMode = "PlatformCollect"
|
||||
});
|
||||
var tenantPayment = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto
|
||||
{
|
||||
TenantId = tenantA,
|
||||
Provider = "manual",
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
DisplayName = "线下收款",
|
||||
SecretRef = "tenant_payment:manual:redacted"
|
||||
});
|
||||
var reads = await Task.WhenAll(
|
||||
client.GetAsync("/api/platform-admin/tenant-capabilities/crm/configs"),
|
||||
client.GetAsync("/api/platform-admin/tenant-capabilities/crm/leads"),
|
||||
client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"),
|
||||
client.GetAsync("/api/platform-admin/tenant-capabilities/sms/templates"),
|
||||
client.GetAsync("/api/platform-admin/payment-settings/apps"),
|
||||
client.GetAsync("/api/platform-admin/tenant-capabilities/payments/apps"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, crm.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, smsChannel.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, template.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, paymentApp.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenantPayment.StatusCode);
|
||||
Assert.All(reads, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform capability endpoints");
|
||||
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(CrmWebhookQueueStatus.Retrying, await db.CrmWebhookQueue.Where(item => item.Id == failedQueueId).Select(item => item.Status).SingleAsync());
|
||||
Assert.Equal(CrmWebhookQueueStatus.Failed, await db.CrmWebhookQueue.Where(item => item.Id == otherQueueId).Select(item => item.Status).SingleAsync());
|
||||
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.crm.lead.retry"));
|
||||
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.sms.channel.upserted"));
|
||||
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.payment.app.upserted"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_capability_read_permissions_cannot_write()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var platform = await SeedPlatformUserAsync(factory,
|
||||
[
|
||||
BackendPermissions.PlatformCrmRead,
|
||||
BackendPermissions.PlatformSmsRead,
|
||||
BackendPermissions.PlatformPaymentRead
|
||||
]);
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant { Id = tenantId, Slug = "readonly-capability", Name = "Readonly Capability" });
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var read = await client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels");
|
||||
var write = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = "aliyun",
|
||||
Name = "只读不应写入",
|
||||
Signature = "题库",
|
||||
Scene = "login",
|
||||
Status = TenantExternalProviderStatus.Active
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, write.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_can_manage_dunning_channels_and_retry_events()
|
||||
{
|
||||
@@ -301,11 +430,19 @@ public sealed class PlatformAdminEndpointTests
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
return await SeedPlatformUserAsync(factory, BackendPermissions.Platform);
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(
|
||||
ApiTestFactory factory,
|
||||
IEnumerable<string> platformPermissions)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var email = $"platform-{Guid.NewGuid():N}@example.test";
|
||||
var modules = BackendPermissions.Platform
|
||||
var permissionCodes = platformPermissions.Distinct(StringComparer.Ordinal).ToArray();
|
||||
var modules = permissionCodes
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(code => new PermissionModule
|
||||
@@ -317,7 +454,7 @@ public sealed class PlatformAdminEndpointTests
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
|
||||
var permissions = permissionCodes.Select(code => new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
@@ -348,7 +485,7 @@ public sealed class PlatformAdminEndpointTests
|
||||
Status = BackendRoleStatus.Active,
|
||||
IsSystem = true
|
||||
},
|
||||
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
|
||||
..permissionCodes.Select(code => new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
PermissionCode = code
|
||||
|
||||
Reference in New Issue
Block a user