feat: add platform tenant detail audit console

This commit is contained in:
Codex
2026-06-30 04:38:36 +08:00
parent e62653025c
commit d5d32e84b3
16 changed files with 688 additions and 111 deletions

View File

@@ -5,6 +5,7 @@ import {
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
@@ -30,6 +31,7 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -1,4 +1,6 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import type pg from 'pg';
import { currentSessionFromContext } from '../../core/auth-context.js';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalStringArray,
@@ -37,6 +39,54 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean);
}
function billingInvoiceTypeFrom(value: string) {
const invoiceType = value || 'none';
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
throw new HttpError(400, 'invoiceType is invalid', 'INVALID_INVOICE_TYPE');
}
return invoiceType;
}
function platformAuditDetails(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
function requestIp(ctx: RequestContext) {
const forwarded = getHeader(ctx.req, 'x-forwarded-for').split(',')[0]?.trim();
return forwarded || ctx.req.socket.remoteAddress || null;
}
async function recordPlatformAudit(
client: pg.PoolClient,
ctx: RequestContext,
action: string,
targetType: string,
targetId: string | null,
details: Record<string, unknown> = {},
tenantId: string | null = null,
) {
const session = currentSessionFromContext(ctx);
await client.query(
`
insert into public.audit_logs (
tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent
)
values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7, $8)
`,
[
tenantId || null,
session?.id || null,
action,
targetType,
targetId,
platformAuditDetails(details),
requestIp(ctx),
getHeader(ctx.req, 'user-agent') || null,
],
);
}
function grantScopeFrom(value: string) {
const scope = value || 'plans';
if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) {
@@ -417,7 +467,8 @@ export async function tenantDetailRoute(ctx: RequestContext) {
bp.billing_name as "billingName", bp.tax_id as "taxId",
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
bp.invoice_type as "invoiceType"
bp.invoice_type as "invoiceType", bp.billing_address as "billingAddress",
bp.bank_name as "bankName", bp.bank_account_masked as "bankAccountMasked"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
@@ -454,7 +505,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
),
query(
`
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
select id, tenant_id as "tenantId", invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", due_date as "dueDate",
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
@@ -467,7 +518,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
),
query(
`
select metric_key as "metricKey", metric_value as "metricValue",
select id, tenant_id as "tenantId", metric_key as "metricKey", metric_value as "metricValue",
period_start as "periodStart", period_end as "periodEnd", metadata,
created_at as "createdAt"
from public.tenant_usage_records
@@ -482,6 +533,63 @@ export async function tenantDetailRoute(ctx: RequestContext) {
return { item: { tenant, domains, subscriptions, invoices, usage } };
}
export async function platformAuditLogsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = listQuery(ctx, 'tenantId');
const action = listQuery(ctx, 'action');
const targetType = listQuery(ctx, 'targetType');
const actorUserId = listQuery(ctx, 'actorUserId');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [];
const filters: string[] = [];
if (tenantId) {
params.push(tenantId);
filters.push(`al.tenant_id = $${params.length}::uuid`);
}
if (action) {
params.push(`${action}%`);
filters.push(`al.action ilike $${params.length}`);
}
if (targetType) {
params.push(targetType);
filters.push(`al.target_type = $${params.length}`);
}
if (actorUserId) {
params.push(actorUserId);
filters.push(`al.actor_user_id = $${params.length}::uuid`);
}
if (q) {
params.push(`%${q}%`);
filters.push(`(al.action ilike $${params.length} or al.target_type ilike $${params.length} or al.target_id ilike $${params.length})`);
}
params.push(limit);
const items = await query(
`
select al.id, al.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", al.actor_user_id as "actorUserId",
u.username as "actorUsername", u.name as "actorName",
u.phone as "actorPhone", al.action,
al.target_type as "targetType", al.target_id as "targetId",
al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent",
al.created_at as "createdAt"
from public.audit_logs al
left join public.tenants t on t.id = al.tenant_id
left join public.platform_users u on u.id = al.actor_user_id
${filters.length ? `where ${filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
@@ -497,6 +605,7 @@ export async function createTenantRoute(ctx: RequestContext) {
const legalName = optionalString(body, 'legalName') || null;
const primaryHost = optionalString(body, 'primaryHost');
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
const billingInvoiceType = billingInvoiceTypeFrom(typeof billing.invoiceType === 'string' ? billing.invoiceType.trim() : '');
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
const item = await transaction(async client => {
@@ -561,7 +670,7 @@ export async function createTenantRoute(ctx: RequestContext) {
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
billingInvoiceType,
jsonBodyValue(billing.metadata),
],
);
@@ -605,6 +714,15 @@ export async function createTenantRoute(ctx: RequestContext) {
],
);
await recordPlatformAudit(client, ctx, 'platform.tenant.created', 'tenant', tenant.id, {
slug,
name,
legalName,
planCode: plan.code,
billingStatus,
primaryHost: primaryHost ? normalizeHost(primaryHost) : null,
}, tenant.id);
return tenant;
});
@@ -620,21 +738,30 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
const item = await queryOne(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
const item = await transaction(async client => {
const result = await client.query(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
status: status || null,
billingStatus: billingStatus || null,
reason: optionalString(body, 'reason') || null,
}, tenantId);
return result.rows[0];
});
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
return { item };
}
@@ -643,49 +770,63 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const invoiceType = billingInvoiceTypeFrom(optionalString(body, 'invoiceType'));
const item = await queryOne(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
optionalString(body, 'invoiceType') || 'none',
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
invoiceType,
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
await recordPlatformAudit(client, ctx, 'platform.tenant.billing_profile_upserted', 'tenant_billing_profile', tenantId, {
billingName: optionalString(body, 'billingName') || null,
contactName: optionalString(body, 'contactName') || null,
contactPhoneSet: Boolean(optionalString(body, 'contactPhone')),
contactEmailSet: Boolean(optionalString(body, 'contactEmail')),
invoiceType,
bankAccountMaskedSet: Boolean(optionalString(body, 'bankAccountMasked')),
}, tenantId);
return result.rows[0];
});
return { item };
}