|
|
|
|
@@ -58,6 +58,28 @@ const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical'])
|
|
|
|
|
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
|
|
|
|
|
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
|
|
|
|
|
|
|
|
|
|
const PLATFORM_PERMISSION_CATALOG = [
|
|
|
|
|
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' },
|
|
|
|
|
{ key: 'platform:tenant:read', group: 'tenant', label: '查看租户' },
|
|
|
|
|
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
|
|
|
|
|
{ key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' },
|
|
|
|
|
{ key: 'platform:tenant:billing_profile', group: 'tenant', label: '维护租户账务资料' },
|
|
|
|
|
{ key: 'platform:plan:read', group: 'billing', label: '查看 SaaS 套餐' },
|
|
|
|
|
{ key: 'platform:billing:read', group: 'billing', label: '查看平台账务' },
|
|
|
|
|
{ key: 'platform:billing:write', group: 'billing', label: '创建订阅/账单' },
|
|
|
|
|
{ key: 'platform:billing:payment', group: 'billing', label: '确认服务费收款' },
|
|
|
|
|
{ key: 'platform:billing:dunning', group: 'billing', label: '处理逾期催缴' },
|
|
|
|
|
{ key: 'platform:billing:notification', group: 'billing', label: '维护催缴通知' },
|
|
|
|
|
{ key: 'platform:usage:read', group: 'usage', label: '查看租户用量' },
|
|
|
|
|
{ key: 'platform:usage:write', group: 'usage', label: '记录租户用量' },
|
|
|
|
|
{ key: 'platform:audit:read', group: 'audit', label: '查看平台审计' },
|
|
|
|
|
{ key: 'platform:audit:export', group: 'audit', label: '导出平台审计' },
|
|
|
|
|
{ key: 'platform:audit:alert', group: 'audit', label: '处理平台审计告警' },
|
|
|
|
|
{ key: 'platform:audit:notification', group: 'audit', label: '维护审计告警通知' },
|
|
|
|
|
{ key: 'platform:question_bank:read', group: 'question_bank', label: '查看公共题库' },
|
|
|
|
|
{ key: 'platform:question_bank:grant', group: 'question_bank', label: '授权公共题库' },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function csvEscape(value: unknown) {
|
|
|
|
|
if (value === null || value === undefined) return '';
|
|
|
|
|
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
|
|
|
@@ -476,8 +498,38 @@ function grantStatusFrom(value: string) {
|
|
|
|
|
return status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformOverviewRoute(ctx: RequestContext) {
|
|
|
|
|
function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) {
|
|
|
|
|
if (permissions['*'] === true) return true;
|
|
|
|
|
if (permissions[permission] === true) return true;
|
|
|
|
|
const parts = permission.split(':');
|
|
|
|
|
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
|
|
|
|
if (permissions[`${parts.slice(0, i).join(':')}:*`] === true) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformPermissionsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
const session = currentSessionFromContext(ctx);
|
|
|
|
|
const permissions = session?.platformPermissions && typeof session.platformPermissions === 'object'
|
|
|
|
|
? session.platformPermissions
|
|
|
|
|
: { '*': true };
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
item: {
|
|
|
|
|
userId: session?.id || null,
|
|
|
|
|
primaryRole: session?.primaryRole || 'platform_admin_key',
|
|
|
|
|
permissions,
|
|
|
|
|
effective: Object.fromEntries(
|
|
|
|
|
PLATFORM_PERMISSION_CATALOG.map(item => [item.key, platformPermissionAllowed(permissions, item.key)]),
|
|
|
|
|
),
|
|
|
|
|
catalog: PLATFORM_PERMISSION_CATALOG,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformOverviewRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:overview:read');
|
|
|
|
|
|
|
|
|
|
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
|
|
|
|
|
queryOne<{
|
|
|
|
|
@@ -554,7 +606,7 @@ export async function platformOverviewRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformPlansRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:plan:read');
|
|
|
|
|
|
|
|
|
|
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
|
|
|
|
|
const items = await query(
|
|
|
|
|
@@ -574,7 +626,7 @@ export async function platformPlansRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformQuestionBanksRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
|
|
|
|
|
|
|
|
|
|
const q = listQuery(ctx, 'q');
|
|
|
|
|
const regionId = listQuery(ctx, 'regionId');
|
|
|
|
|
@@ -617,7 +669,7 @@ export async function platformQuestionBanksRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function questionBankGrantsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
|
|
|
|
|
|
|
|
|
|
const questionBankId = listQuery(ctx, 'questionBankId');
|
|
|
|
|
const status = listQuery(ctx, 'status');
|
|
|
|
|
@@ -649,7 +701,7 @@ export async function questionBankGrantsRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:question_bank:grant');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const questionBankId = requiredString(body, 'sourceQuestionBankId');
|
|
|
|
|
@@ -777,7 +829,7 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function tenantsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:tenant:read');
|
|
|
|
|
|
|
|
|
|
const status = listQuery(ctx, 'status');
|
|
|
|
|
const billingStatus = listQuery(ctx, 'billingStatus');
|
|
|
|
|
@@ -825,7 +877,7 @@ export async function tenantsRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function tenantDetailRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:tenant:read');
|
|
|
|
|
|
|
|
|
|
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
|
|
|
|
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
|
|
|
|
|
@@ -907,7 +959,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditLogsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:read');
|
|
|
|
|
|
|
|
|
|
const auditQuery = auditLogQueryParams(ctx, 100, 500);
|
|
|
|
|
const items = await loadPlatformAuditLogs(auditQuery);
|
|
|
|
|
@@ -916,7 +968,7 @@ export async function platformAuditLogsRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditLogsExportRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:export');
|
|
|
|
|
|
|
|
|
|
const format = auditExportFormat(listQuery(ctx, 'format'));
|
|
|
|
|
const auditQuery = auditLogQueryParams(ctx, 1000, 5000);
|
|
|
|
|
@@ -986,7 +1038,7 @@ export async function platformAuditLogsExportRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
|
|
|
|
|
|
|
|
|
const enabled = listQuery(ctx, 'enabled');
|
|
|
|
|
if (enabled && !['true', 'false'].includes(enabled)) {
|
|
|
|
|
@@ -1010,7 +1062,7 @@ export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditAlertsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
|
|
|
|
|
|
|
|
|
const tenantId = listQuery(ctx, 'tenantId');
|
|
|
|
|
const status = listQuery(ctx, 'status');
|
|
|
|
|
@@ -1077,7 +1129,7 @@ export async function platformAuditAlertsRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const alertId = requiredString(body, 'alertId');
|
|
|
|
|
@@ -1127,7 +1179,7 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditNotificationChannelsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
|
|
|
|
|
|
|
|
|
const enabled = listQuery(ctx, 'enabled');
|
|
|
|
|
const provider = listQuery(ctx, 'provider');
|
|
|
|
|
@@ -1162,7 +1214,7 @@ export async function platformAuditNotificationChannelsRoute(ctx: RequestContext
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
|
|
|
|
|
@@ -1270,7 +1322,7 @@ export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestCo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformAuditNotificationEventsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
|
|
|
|
|
|
|
|
|
const channelId = listQuery(ctx, 'channelId');
|
|
|
|
|
const alertId = listQuery(ctx, 'alertId');
|
|
|
|
|
@@ -1329,7 +1381,7 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
|
|
|
|
|
|
|
|
|
const enabled = listQuery(ctx, 'enabled');
|
|
|
|
|
const provider = listQuery(ctx, 'provider');
|
|
|
|
|
@@ -1362,7 +1414,7 @@ export async function platformDunningNotificationChannelsRoute(ctx: RequestConte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
|
|
|
|
|
@@ -1471,7 +1523,7 @@ export async function upsertPlatformDunningNotificationChannelRoute(ctx: Request
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
|
|
|
|
|
|
|
|
|
const channelId = listQuery(ctx, 'channelId');
|
|
|
|
|
const reminderId = listQuery(ctx, 'reminderId');
|
|
|
|
|
@@ -1540,7 +1592,7 @@ export async function platformDunningNotificationEventsRoute(ctx: RequestContext
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createTenantRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:tenant:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const slug = normalizeSlug(requiredString(body, 'slug'));
|
|
|
|
|
@@ -1679,7 +1731,7 @@ export async function createTenantRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateTenantStatusRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:tenant:status');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -1715,7 +1767,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:tenant:billing_profile');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -1781,7 +1833,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createSubscriptionRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -1844,7 +1896,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function tenantInvoicesRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
|
|
|
|
|
|
|
|
|
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
|
|
|
|
const status = listQuery(ctx, 'status');
|
|
|
|
|
@@ -1966,7 +2018,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createInvoiceRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -1992,7 +2044,7 @@ export async function createInvoiceRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:payment');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -2088,7 +2140,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const dryRun = booleanFrom(body.dryRun, false);
|
|
|
|
|
@@ -2124,7 +2176,7 @@ export async function processOverdueInvoicesRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function invoiceRemindersRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
|
|
|
|
|
|
|
|
|
|
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
|
|
|
|
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
|
|
|
|
|
@@ -2160,7 +2212,7 @@ export async function invoiceRemindersRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function recordUsageRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:usage:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -2187,7 +2239,7 @@ export async function recordUsageRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function tenantUsageRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:usage:read');
|
|
|
|
|
|
|
|
|
|
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
|
|
|
|
const limit = intParam(ctx, 'limit', 100, 500);
|
|
|
|
|
@@ -2274,7 +2326,7 @@ async function subscriptionInvoiceCandidateQuery(params: {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
|
|
|
|
|
|
|
|
|
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
|
|
|
|
|
const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds');
|
|
|
|
|
@@ -2292,7 +2344,7 @@ export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantId = requiredString(body, 'tenantId');
|
|
|
|
|
@@ -2382,7 +2434,7 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) {
|
|
|
|
|
await requirePlatformAdmin(ctx);
|
|
|
|
|
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
|
|
|
|
|
|
|
|
|
const body = await readJsonBody(ctx);
|
|
|
|
|
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');
|
|
|
|
|
|