feat(M08-D): 补审计日志与系统配置

This commit is contained in:
Codex
2026-08-10 13:28:54 +08:00
parent bd00aa6275
commit 7e170e8743
13 changed files with 794 additions and 3 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+8
View File
@@ -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]}.***.***` : '***';
}
+183
View File
@@ -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
});
}
+7
View File
@@ -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 () => {
@@ -42,6 +42,7 @@ import { DeviceCommandService } from '../dist/devices/device-command-service.js'
import { DeviceControlService } from '../dist/devices/device-control-service.js';
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -1795,6 +1796,49 @@ async function assertFranchiseManagement(pool, context) {
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
}
async function assertSystemOperations(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
);
const adminId = String(adminRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const actor = { tenantId: context.tenantId, userId: adminId, access,
traceId: 'm08d-system-live', ip: '172.18.20.42', userAgent: 'M08-D system live test' };
await pool.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', ?, 'M08D_REDACTION_PROBE',
'TENANT', ?, ?, ?, ?, ?)`,
[context.tenantId, adminId, context.tenantId, actor.traceId, actor.ip, actor.userAgent,
JSON.stringify({ orderId: 'safe-order', phone: '13800138000', nested: { apiKey: 'secret', safe: 'visible' } })]
);
const repository = new SystemOperationsRepository(pool);
const logs = await repository.listAuditLogs({
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'M08D_REDACTION_PROBE'
});
assert.equal(logs.total, 1);
assert.equal(logs.items[0].tenantId, context.tenantId);
assert.equal(logs.items[0].ip, '172.18.***.***');
assert.equal(logs.items[0].metadata.phone, '[REDACTED]');
assert.equal(logs.items[0].metadata.nested.apiKey, '[REDACTED]');
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081003');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
});
const updatedLogs = await repository.listAuditLogs({
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'TENANT_SYSTEM_CONFIG_UPDATED'
});
assert.equal(updatedLogs.total, 1);
assert.equal(updatedLogs.items[0].actorId, adminId);
}
async function assertDeviceTopology(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -2083,6 +2127,7 @@ try {
await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext);
await assertFranchiseManagement(pool, loginContext);
await assertSystemOperations(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
@@ -2173,6 +2218,10 @@ try {
'versioned decoration publish and archive',
'store advertisement delivery scope',
'platform advertisement rejection',
'franchise application idempotency and phone-free audit metadata',
'franchise assignment and controlled follow-up status transition',
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
'system overview and audited tenant configuration update',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result',
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
const secret = 'test-only-system-operations-secret-32';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
}, secret, 900);
let auditQuery;
let overviewTenantId;
let updateCall;
const repository = {
async listAuditLogs(input) {
auditQuery = input;
return { items: [], total: 0, page: input.page, pageSize: input.pageSize };
},
async getSystemOverview(tenantId) {
overviewTenantId = tenantId;
return {
tenant: { id: tenantId, code: 'demo', name: '演示租户', status: 'ACTIVE', timezone: 'Asia/Shanghai' },
counts: { storeCount: 1, userCount: 2, activeSessionCount: 1, appBindingCount: 1, auditTodayCount: 3 },
latestMigration: { version: '2026081003', name: 'm08d_franchise_leads' }
};
},
async updateTenant(actor, tenantId, input) {
updateCall = { actor, tenantId, input };
return { tenantId, updated: true };
}
};
const authRepository = {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
nickname: '管理员', avatarUrl: '', phone: '' }
};
}
};
const tenantAccess = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
const app = await buildApp({ systemOperations: {
repository, authRepository,
accessControl: { async getAccessProfile() { return tenantAccess; } },
jwtSecret: secret
} });
const auth = { authorization: `Bearer ${token}` };
const listed = await app.inject({ method: 'GET',
url: '/admin-api/audit-logs?action=ORDER_CREATED&resourceType=ORDER&page=2&pageSize=10', headers: auth });
assert.equal(listed.statusCode, 200);
assert.deepEqual({ tenantId: auditQuery.tenantId, action: auditQuery.action,
resourceType: auditQuery.resourceType, page: auditQuery.page },
{ tenantId: '7', action: 'ORDER_CREATED', resourceType: 'ORDER', page: 2 });
assert.ok(auditQuery.from instanceof Date);
assert.ok(auditQuery.to instanceof Date);
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/audit-logs?tenantId=8', headers: auth });
assert.equal(crossTenant.statusCode, 403);
const invalidRange = await app.inject({ method: 'GET',
url: '/admin-api/audit-logs?from=2025-01-01&to=2026-08-01', headers: auth });
assert.equal(invalidRange.statusCode, 400);
const overview = await app.inject({ method: 'GET', url: '/admin-api/system/overview', headers: auth });
assert.equal(overview.statusCode, 200);
assert.equal(overviewTenantId, '7');
assert.equal(overview.json().data.counts.auditTodayCount, 3);
const updated = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
payload: { name: '新租户名称', timezone: 'Asia/Shanghai' } });
assert.equal(updated.statusCode, 200);
assert.deepEqual({ tenantId: updateCall.tenantId, name: updateCall.input.name,
timezone: updateCall.input.timezone },
{ tenantId: '7', name: '新租户名称', timezone: 'Asia/Shanghai' });
const invalidTimezone = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
payload: { name: '新租户名称', timezone: 'Mars/Olympus' } });
assert.equal(invalidTimezone.statusCode, 400);
const tenantStatusChange = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
payload: { name: '新租户名称', timezone: 'Asia/Shanghai', status: 'DISABLED' } });
assert.equal(tenantStatusChange.statusCode, 403);
assert.equal(tenantStatusChange.json().code, 'SYSTEM_STATUS_FORBIDDEN');
await app.close();
const platformApp = await buildApp({ systemOperations: {
repository, authRepository,
accessControl: { async getAccessProfile() {
return { roles: ['PLATFORM_ADMIN'], capabilities: ['platform.manage'], storeIds: [] };
} }, jwtSecret: secret
} });
const platformUpdate = await platformApp.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
payload: { tenantId: '8', name: '平台目标租户', timezone: 'UTC', status: 'DISABLED' } });
assert.equal(platformUpdate.statusCode, 200);
assert.equal(updateCall.tenantId, '8');
assert.equal(updateCall.input.status, 'DISABLED');
await platformApp.close();
const sensitivePool = {
async execute(sql) {
if (sql.includes('COUNT(*)')) return [[{ total: 1 }], []];
return [[{
id: '99', tenantId: '7', actorType: 'USER', actorId: '21', actorName: '管理员',
action: 'CONFIG_UPDATED', resourceType: 'TENANT', resourceId: '7', traceId: 'trace-99',
ip: '192.168.10.22', userAgent: 'test',
metadata: JSON.stringify({ orderId: '88', phone: '13800138000',
nested: { apiKey: 'secret-key', safe: 'visible' } }), createdAt: new Date()
}], []];
}
};
const sanitized = await new SystemOperationsRepository(sensitivePool).listAuditLogs({
tenantId: '7', page: 1, pageSize: 20
});
assert.equal(sanitized.items[0].ip, '192.168.***.***');
assert.equal(sanitized.items[0].metadata.phone, '[REDACTED]');
assert.equal(sanitized.items[0].metadata.nested.apiKey, '[REDACTED]');
assert.equal(sanitized.items[0].metadata.nested.safe, 'visible');
console.log('PASS: M08-D tenant-scoped audit logs, metadata redaction and system settings routes are present.');