forked from wangziqi/gongxue-base
feat: add platform tenant detail audit console
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user