feat(M08-D): 补审计日志与系统配置
This commit is contained in:
@@ -57,6 +57,10 @@ import {
|
||||
type BusinessStatisticsRouteOptions
|
||||
} from './routes/business-statistics.js';
|
||||
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||
import {
|
||||
registerSystemOperationsRoutes,
|
||||
type SystemOperationsRouteOptions
|
||||
} from './routes/system-operations.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -83,6 +87,7 @@ export interface BuildAppOptions {
|
||||
cleaning?: CleaningRouteOptions;
|
||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||
franchise?: FranchiseRouteOptions;
|
||||
systemOperations?: SystemOperationsRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -193,6 +198,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.franchise) {
|
||||
await registerFranchiseRoutes(app, options.franchise);
|
||||
}
|
||||
if (options.systemOperations) {
|
||||
await registerSystemOperationsRoutes(app, options.systemOperations);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
tenantId: string; page: number; pageSize: number; action?: string; resourceType?: string;
|
||||
actorId?: string; search?: string; from?: Date; to?: Date;
|
||||
}
|
||||
|
||||
interface AuditRow extends RowDataPacket {
|
||||
id: string; tenantId: string; actorType: string; actorId: string | null; actorName: string | null;
|
||||
action: string; resourceType: string; resourceId: string | null; traceId: string;
|
||||
ip: string; userAgent: string; metadata: unknown; createdAt: Date;
|
||||
}
|
||||
|
||||
export class SystemOperationsError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class SystemOperationsRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listAuditLogs(input: AuditLogQuery) {
|
||||
const page = Number.isSafeInteger(input.page) && input.page > 0 ? input.page : 1;
|
||||
const pageSize = Number.isSafeInteger(input.pageSize)
|
||||
? Math.min(100, Math.max(1, input.pageSize)) : 20;
|
||||
const where = ['l.tenant_id = ?']; const params: Array<string | Date> = [input.tenantId];
|
||||
if (input.action) { where.push('l.action = ?'); params.push(input.action); }
|
||||
if (input.resourceType) { where.push('l.resource_type = ?'); params.push(input.resourceType); }
|
||||
if (input.actorId) { where.push('l.actor_id = ?'); params.push(input.actorId); }
|
||||
if (input.from) { where.push('l.created_at >= ?'); params.push(input.from); }
|
||||
if (input.to) { where.push('l.created_at < ?'); params.push(input.to); }
|
||||
if (input.search) {
|
||||
where.push('(l.action LIKE ? OR l.resource_type LIKE ? OR l.trace_id LIKE ? OR u.nickname LIKE ?)');
|
||||
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||
}
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_audit_logs l
|
||||
LEFT JOIN qipai_users u ON u.id = l.actor_id WHERE ${where.join(' AND ')}`, params
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [rows] = await this.pool.execute<AuditRow[]>(
|
||||
`SELECT l.id, l.tenant_id AS tenantId, l.actor_type AS actorType,
|
||||
l.actor_id AS actorId, u.nickname AS actorName, l.action,
|
||||
l.resource_type AS resourceType, l.resource_id AS resourceId,
|
||||
l.trace_id AS traceId, l.ip, l.user_agent AS userAgent,
|
||||
l.metadata, l.created_at AS createdAt
|
||||
FROM qipai_audit_logs l LEFT JOIN qipai_users u ON u.id = l.actor_id
|
||||
WHERE ${where.join(' AND ')} ORDER BY l.id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`, params
|
||||
);
|
||||
return { items: rows.map((row) => ({ ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||
actorId: row.actorId === null ? null : String(row.actorId),
|
||||
resourceId: row.resourceId === null ? null : String(row.resourceId),
|
||||
ip: maskIp(row.ip), metadata: sanitizeMetadata(parseJson(row.metadata)) })),
|
||||
total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||
}
|
||||
|
||||
async getSystemOverview(tenantId: string) {
|
||||
const [tenants] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, code, name, status, timezone, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
|
||||
);
|
||||
if (!tenants[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||
const [counts] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM qipai_stores WHERE tenant_id = ? AND deleted_at IS NULL) AS storeCount,
|
||||
(SELECT COUNT(*) FROM qipai_users WHERE tenant_id = ? AND deleted_at IS NULL) AS userCount,
|
||||
(SELECT COUNT(*) FROM qipai_auth_sessions WHERE tenant_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3)) AS activeSessionCount,
|
||||
(SELECT COUNT(*) FROM qipai_tenant_apps WHERE tenant_id = ? AND deleted_at IS NULL) AS appBindingCount,
|
||||
(SELECT COUNT(*) FROM qipai_audit_logs WHERE tenant_id = ? AND created_at >= UTC_DATE()) AS auditTodayCount`,
|
||||
[tenantId, tenantId, tenantId, tenantId, tenantId]
|
||||
);
|
||||
const [migrations] = await this.pool.execute<RowDataPacket[]>(
|
||||
'SELECT version, name, applied_at AS appliedAt FROM qipai_schema_migrations ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
return { tenant: { ...tenants[0], id: String(tenants[0].id) },
|
||||
counts: Object.fromEntries(Object.entries(counts[0] ?? {}).map(([key, value]) => [key, Number(value)])),
|
||||
latestMigration: migrations[0] ?? null };
|
||||
}
|
||||
|
||||
async updateTenant(actor: ManagementActor, tenantId: string, input: {
|
||||
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { name: string; timezone: string; status: string }>>(
|
||||
'SELECT name, timezone, status FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL FOR UPDATE', [tenantId]
|
||||
);
|
||||
if (!rows[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||
await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_tenants SET name = ?, timezone = ?, status = COALESCE(?, status)
|
||||
WHERE id = ? AND deleted_at IS NULL`, [input.name, input.timezone, input.status ?? null, tenantId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'TENANT_SYSTEM_CONFIG_UPDATED',
|
||||
'TENANT', ?, ?, ?, ?, ?)`,
|
||||
[tenantId, actor.userId, tenantId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||
JSON.stringify({ before: rows[0], after: input })]
|
||||
);
|
||||
return { tenantId, updated: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
try { return JSON.parse(value); } catch { return {}; }
|
||||
}
|
||||
function sanitizeMetadata(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sanitizeMetadata);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) =>
|
||||
[key, /(secret|token|password|private|credential|certificate|api.?key|phone|openid|receiver|voucher)/i.test(key)
|
||||
? '[REDACTED]' : sanitizeMetadata(item)]));
|
||||
}
|
||||
function maskIp(ip: string) {
|
||||
if (!ip) return '';
|
||||
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}::****`;
|
||||
const parts = ip.split('.'); return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.***` : '***';
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import {
|
||||
SystemOperationsError,
|
||||
type SystemOperationsRepository
|
||||
} from '../operations/system-operations-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const auditQuerySchema = z.object({
|
||||
tenantId: id.optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
action: z.string().trim().min(1).max(128).optional(),
|
||||
resourceType: z.string().trim().min(1).max(64).optional(),
|
||||
actorId: id.optional(),
|
||||
search: z.string().trim().min(1).max(128).optional(),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional()
|
||||
}).strict();
|
||||
const tenantQuerySchema = z.object({ tenantId: id.optional() }).strict();
|
||||
const updateTenantSchema = z.object({
|
||||
tenantId: id.optional(),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
timezone: z.string().trim().min(1).max(64),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional()
|
||||
}).strict();
|
||||
|
||||
export interface SystemOperationsRouteOptions {
|
||||
repository: Pick<SystemOperationsRepository, 'listAuditLogs' | 'getSystemOverview' | 'updateTenant'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerSystemOperationsRoutes(
|
||||
app: FastifyInstance,
|
||||
options: SystemOperationsRouteOptions
|
||||
) {
|
||||
app.get('/admin-api/audit-logs', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const query = auditQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
const to = query.data.to ?? new Date();
|
||||
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||
if (to.getTime() <= from.getTime() || to.getTime() - from.getTime() > 366 * 86400000) {
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAuditLogs({ ...query.data, tenantId, from, to }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/system/overview', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const query = tenantQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getSystemOverview(tenantId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/system/tenant', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const body = updateTenantSchema.safeParse(request.body);
|
||||
if (!actor || !body.success || !isValidTimeZone(body.data.timezone)) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const tenantId = resolveTenant(actor, body.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
if (body.data.status && !isPlatform(actor.access)) {
|
||||
return reply.status(403).send({
|
||||
code: 'SYSTEM_STATUS_FORBIDDEN',
|
||||
message: 'Platform management permission is required to change tenant status.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateTenant(actor, tenantId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireManager(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: SystemOperationsRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||
reply.status(403).send({
|
||||
code: 'SYSTEM_OPERATIONS_FORBIDDEN',
|
||||
message: 'Tenant management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||
return isPlatform(actor.access) ? requested : null;
|
||||
}
|
||||
|
||||
function isPlatform(access: AccessProfile) {
|
||||
return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
|
||||
}
|
||||
|
||||
function isValidTimeZone(value: string) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof SystemOperationsError)) throw error;
|
||||
const statusCode = error.code.endsWith('_NOT_FOUND') ? 404 : 400;
|
||||
return reply.status(statusCode).send({
|
||||
code: error.code,
|
||||
message: 'The system operations request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_SYSTEM_OPERATIONS_REQUEST',
|
||||
message: 'The system operations request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function tenantForbidden(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(403).send({
|
||||
code: 'SYSTEM_TENANT_FORBIDDEN',
|
||||
message: 'The tenant is outside the allowed scope.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -225,6 +226,12 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
systemOperations: {
|
||||
repository: new SystemOperationsRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
Reference in New Issue
Block a user