feat(platform): add CRM SMS and payment settings

This commit is contained in:
2026-07-29 15:41:27 +08:00
parent 6db200a2fc
commit ead7bdc2e2
25 changed files with 23250 additions and 140 deletions

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

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

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

View File

@@ -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(

View File

@@ -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('地区 IDUUID', 'regionIds', grant.allowedRegionIds?.join(',') || '', 'placeholder="多个 UUID 用逗号分隔"')}${formField('科目 IDUUID', '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; }

View File

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

View File

@@ -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/')),

View File

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

View File

@@ -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)

View File

@@ -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.")
};

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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(

View File

@@ -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";

View File

@@ -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>();

View File

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

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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)

View File

@@ -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>();

View File

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

View File

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

View File

@@ -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()

View File

@@ -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