feat: add platform audit export

This commit is contained in:
Codex
2026-06-30 05:41:30 +08:00
parent c74432ce98
commit b9957d18d5
13 changed files with 422 additions and 69 deletions

View File

@@ -7,6 +7,7 @@ import {
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
@@ -36,6 +37,7 @@ export const platformAdminRoutes: RouteDefinition[] = [
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['GET', '/api/platform-admin/audit-logs/export', platformAuditLogsExportRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { currentSessionFromContext } from '../../core/auth-context.js';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
@@ -43,6 +44,67 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function csvLine(values: unknown[]) {
return values.map(csvEscape).join(',');
}
function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unknown {
if (value === null || value === undefined) return value;
if (depth > 8) return '[REDACTED_DEPTH_LIMIT]';
if (
/(?:password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|app[_-]?secret|authorization|cookie|session|cert|signature|nonce)$/i
.test(parentKey)
) {
return '[REDACTED]';
}
if (Array.isArray(value)) return value.map(item => redactAuditExportValue(item, parentKey, depth + 1));
if (typeof value === 'object') {
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
output[key] = redactAuditExportValue(item, key, depth + 1);
}
return output;
}
return value;
}
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
contentBase64: buffer.toString('base64'),
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
sizeBytes: buffer.length,
};
}
function platformExportTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
function optionalDateFilter(ctx: RequestContext, name: string) {
const value = listQuery(ctx, name);
if (!value) return '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HttpError(400, `${name} must use YYYY-MM-DD format`, 'INVALID_DATE');
}
return value;
}
function auditExportFormat(value: string) {
const format = value || 'csv';
if (!['csv', 'json'].includes(format)) {
throw new HttpError(400, 'format is invalid', 'INVALID_EXPORT_FORMAT');
}
return format;
}
function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) {
const values = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
@@ -97,6 +159,120 @@ function requestIp(ctx: RequestContext) {
return forwarded || ctx.req.socket.remoteAddress || null;
}
function auditLogQueryParams(ctx: RequestContext, defaultLimit = 100, maxLimit = 500) {
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 startDate = optionalDateFilter(ctx, 'startDate');
const endDate = optionalDateFilter(ctx, 'endDate');
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (actorUserId && !UUID_RE.test(actorUserId)) throw new HttpError(400, 'actorUserId is invalid', 'INVALID_UUID');
if (startDate && endDate && startDate > endDate) throw new HttpError(400, 'startDate must be before endDate', 'INVALID_DATE_RANGE');
const limit = intParam(ctx, 'limit', defaultLimit, maxLimit);
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 (startDate) {
params.push(startDate);
filters.push(`al.created_at >= $${params.length}::date`);
}
if (endDate) {
params.push(endDate);
filters.push(`al.created_at < ($${params.length}::date + interval '1 day')`);
}
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})`);
}
return {
filters,
params,
limit,
summary: { tenantId, action, targetType, actorUserId, q, startDate, endDate },
};
}
async function loadPlatformAuditLogs(input: {
filters: string[];
params: unknown[];
limit: number;
}) {
const params = [...input.params, input.limit];
return query<{
id: string;
tenantId: string | null;
tenantSlug: string | null;
tenantName: string | null;
actorUserId: string | null;
actorUsername: string | null;
actorName: string | null;
actorPhone: string | null;
action: string;
targetType: string | null;
targetId: string | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}>(
`
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
${input.filters.length ? `where ${input.filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
}
function platformAuditExportItems(items: Awaited<ReturnType<typeof loadPlatformAuditLogs>>) {
return items.map(item => ({
id: item.id,
tenantId: item.tenantId,
tenantSlug: item.tenantSlug,
tenantName: item.tenantName,
actorUserId: item.actorUserId,
actorUsername: item.actorUsername,
actorName: item.actorName,
action: item.action,
targetType: item.targetType,
targetId: item.targetId,
details: redactAuditExportValue(item.details),
ipAddress: item.ipAddress,
userAgent: item.userAgent,
createdAt: item.createdAt,
}));
}
async function recordPlatformAudit(
client: pg.PoolClient,
ctx: RequestContext,
@@ -577,60 +753,82 @@ export async function tenantDetailRoute(ctx: RequestContext) {
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,
);
const auditQuery = auditLogQueryParams(ctx, 100, 500);
const items = await loadPlatformAuditLogs(auditQuery);
return { items };
}
export async function platformAuditLogsExportRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const format = auditExportFormat(listQuery(ctx, 'format'));
const auditQuery = auditLogQueryParams(ctx, 1000, 5000);
const items = await loadPlatformAuditLogs(auditQuery);
const exportItems = platformAuditExportItems(items);
const exportedAt = new Date().toISOString();
const filename = `platform-audit-${platformExportTimestamp()}.${format}`;
const content = format === 'json'
? JSON.stringify({ exportedAt, filters: auditQuery.summary, items: exportItems }, null, 2)
: [
csvLine([
'id',
'tenantId',
'tenantSlug',
'tenantName',
'actorUserId',
'actorUsername',
'actorName',
'action',
'targetType',
'targetId',
'details',
'ipAddress',
'userAgent',
'createdAt',
]),
...exportItems.map(item => csvLine([
item.id,
item.tenantId,
item.tenantSlug,
item.tenantName,
item.actorUserId,
item.actorUsername,
item.actorName,
item.action,
item.targetType,
item.targetId,
item.details,
item.ipAddress,
item.userAgent,
item.createdAt,
])),
].join('\n');
const encoded = contentBase64AndHash(content);
await transaction(async client => {
await recordPlatformAudit(client, ctx, 'platform.audit.exported', 'audit_logs', null, {
format,
filename,
rowCount: items.length,
sha256: encoded.sha256,
filters: auditQuery.summary,
});
});
return {
item: {
filename,
format,
mimeType: format === 'json' ? 'application/json' : 'text/csv',
rowCount: items.length,
exportedAt,
filters: auditQuery.summary,
...encoded,
},
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import {
exportPlatformAuditLogs,
loadPlatformAuditLogs,
loadPlatformInvoices,
loadPlatformOverview,
@@ -21,6 +22,17 @@ function money(cents: unknown) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
if (typeof document === 'undefined') return false;
const link = document.createElement('a');
link.href = `data:${mimeType};base64,${contentBase64}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
return true;
}
export default function PlatformWorkbenchPage() {
const [overview, setOverview] = useState<PlatformOverview | null>(null);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
@@ -29,6 +41,7 @@ export default function PlatformWorkbenchPage() {
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
useEffect(() => {
Promise.all([
@@ -54,6 +67,23 @@ export default function PlatformWorkbenchPage() {
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
];
async function exportAuditLogs() {
setBusy('audit-export');
setError('');
try {
const payload = await exportPlatformAuditLogs({ format: 'csv', limit: 1000 });
const item = payload.item;
if (item?.contentBase64 && item.filename) {
const ok = downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv');
Taro.showToast({ title: ok ? '已导出' : '已生成', icon: 'success' });
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '审计导出失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -112,6 +142,9 @@ export default function PlatformWorkbenchPage() {
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-actions'>
<Button className='platform-button' loading={busy === 'audit-export'} onClick={exportAuditLogs}> CSV</Button>
</View>
<View className='platform-list'>
{auditLogs.map(item => (
<View className='platform-row' key={item.id}>

View File

@@ -223,6 +223,18 @@ export interface PlatformAuditLogItem {
createdAt?: string | null;
}
export interface PlatformAuditExportItem {
filename?: string | null;
format?: string | null;
mimeType?: string | null;
rowCount?: number | string | null;
exportedAt?: string | null;
sha256?: string | null;
sizeBytes?: number | string | null;
contentBase64?: string | null;
filters?: Record<string, unknown> | null;
}
export interface CreatePlatformTenantInput {
slug: string;
name: string;
@@ -342,6 +354,23 @@ export async function loadPlatformAuditLogs(query: { tenantId?: string; action?:
});
}
export async function exportPlatformAuditLogs(query: {
tenantId?: string;
action?: string;
targetType?: string;
actorUserId?: string;
q?: string;
startDate?: string;
endDate?: string;
format?: 'csv' | 'json';
limit?: number;
} = {}) {
return apiRequest<{ item?: PlatformAuditExportItem }>('/api/platform-admin/audit-logs/export', {
query: { format: 'csv', ...query, limit: query.limit || 1000 },
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 },