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