Add audit log UI and backend API seeded E2E

- Add Auth Context route role guards so doctors cannot directly enter template management, user management, or audit logs.

- Add Audit Logs page, sidebar entry, frontend audit API client, and API client test.

- Add backend audit log query endpoint with super/admin visibility rules and query filtering.

- Extend PostgreSQL integration tests to cover audit log query permissions.

- Move Playwright E2E away from localStorage seed data to real backend API login and seed helpers.

- Add E2E coverage for route guards and audit log visibility.

- Run Playwright backend on port 3100 and proxy Vite API requests there to avoid local port conflicts.

- Make server:dev use the compiled NestJS server path, avoiding tsx parameter-property injection issues.

- Update README, AGENTS, feature, testing, security, deployment, progress, API, backendization, and auth/user module docs.
This commit is contained in:
2026-05-02 02:04:56 +08:00
parent a16f522a4b
commit 750cf4129d
31 changed files with 719 additions and 261 deletions

View File

@@ -0,0 +1,19 @@
import { Controller, Get, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { AuditService } from './audit.service.js';
@Controller('audit-logs')
export class AuditController {
constructor(
private readonly authService: AuthService,
private readonly auditService: AuditService,
) {}
@Get()
async list(@Req() request: Request, @Query() query: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.auditService.list(actor, query) };
}
}

View File

@@ -1,10 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { AuthModule } from '../auth/auth.module.js';
import { AuditController } from './audit.controller.js';
import { AuditService } from './audit.service.js';
@Global()
@Module({
imports: [PrismaModule],
imports: [PrismaModule, AuthModule],
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const listAuditLogsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(50),
action: z.string().trim().optional(),
targetType: z.string().trim().optional(),
actor: z.string().trim().optional(),
});
export type ListAuditLogsQuery = z.infer<typeof listAuditLogsQuerySchema>;

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { SafeUser } from '../auth/auth.types.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { listAuditLogsQuerySchema } from './audit.schemas.js';
export interface AuditInput {
actor?: SafeUser | null;
@@ -18,6 +19,84 @@ export interface AuditInput {
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async list(actor: SafeUser, rawQuery: unknown) {
if (actor.role === 'doctor') {
throw new ForbiddenException('无权查看审计日志');
}
const parsed = listAuditLogsQuerySchema.safeParse(rawQuery);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues.map((issue) => issue.message).join(''));
}
const query = parsed.data;
const where: Prisma.AuditLogWhereInput = {
tenantId: actor.tenantId,
};
if (actor.role === 'admin') {
where.OR = [
{ departmentId: actor.departmentId },
{ actorUserId: actor.id },
];
}
if (query.action) {
where.action = query.action;
}
if (query.targetType) {
where.targetType = query.targetType;
}
if (query.actor) {
where.actor = {
OR: [
{ username: { contains: query.actor, mode: 'insensitive' } },
{ name: { contains: query.actor, mode: 'insensitive' } },
],
};
}
const skip = (query.page - 1) * query.pageSize;
const [items, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
include: {
actor: {
select: {
id: true,
username: true,
name: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip,
take: query.pageSize,
}),
this.prisma.auditLog.count({ where }),
]);
return {
items: items.map((item) => ({
id: item.id,
actorUserId: item.actorUserId,
actorUsername: item.actor?.username ?? null,
actorName: item.actor?.name ?? null,
actorRole: item.actorRole,
action: item.action,
targetType: item.targetType,
targetId: item.targetId,
departmentId: item.departmentId,
ip: item.ip,
userAgent: item.userAgent,
metadata: item.metadata,
createdAt: item.createdAt.toISOString(),
})),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async record(input: AuditInput) {
const actor = input.actor || null;
const tenantId = actor?.tenantId;

View File

@@ -171,6 +171,10 @@ describe('Prisma-backed service integration', () => {
expect(managerStats.totalCount).toBeGreaterThanOrEqual(1);
expect(managerStats.trend).toHaveLength(7);
expect(managerStats.templateCount).toBeGreaterThanOrEqual(0);
const auditList = await auditService.list(superActor, { action: 'report.complete', page: 1, pageSize: 10 });
expect(auditList.items.map((item) => item.targetId)).toContain(ownReport.id);
await expect(auditService.list(doctorActor, {})).rejects.toThrow('无权查看审计日志');
});
it('stores department and personal templates with real permission filtering', async () => {

View File

@@ -1,8 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
imports: [ConfigModule],
providers: [PrismaService],
exports: [PrismaService],
})

View File

@@ -8,8 +8,8 @@ import { Pool } from 'pg';
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly pool: Pool;
constructor(configService: ConfigService) {
const connectionString = configService.get<string>('DATABASE_URL');
constructor(configService?: ConfigService) {
const connectionString = configService?.get<string>('DATABASE_URL') ?? process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required to start the API server');
}