Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
Post,
Req,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditService } from '../audit/audit.service.js';
import { AuthService } from './auth.service.js';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly audit?: AuditService,
) {}
@Post('login')
@HttpCode(200)
async login(@Body() body: unknown, @Req() request: Request) {
const user = await this.authService.login(body);
request.session.userId = user.id;
await this.saveSession(request);
await this.audit?.record({
actor: user,
action: 'auth.login',
targetType: 'User',
targetId: user.id,
metadata: { username: user.username },
ip: request.ip,
userAgent: request.get('user-agent'),
});
return {
data: {
user,
},
};
}
@Get('me')
async me(@Req() request: Request) {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return {
data: {
user: await this.authService.findMe(request.session.userId),
},
};
}
@Post('logout')
@HttpCode(200)
async logout(@Req() request: Request) {
const actor = request.session.userId
? await this.authService.findMe(request.session.userId).catch(() => null)
: null;
await new Promise<void>((resolve, reject) => {
request.session.destroy((error) => {
if (error) reject(error);
else resolve();
});
});
await this.audit?.record({
actor,
action: 'auth.logout',
targetType: 'User',
targetId: actor?.id,
ip: request.ip,
userAgent: request.get('user-agent'),
});
return { data: null };
}
private async saveSession(request: Request) {
await new Promise<void>((resolve, reject) => {
request.session.save((error) => {
if (error) reject(error);
else resolve();
});
}).catch(() => {
throw new InternalServerErrorException('保存登录态失败');
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().trim().min(1, '用户名不能为空'),
password: z.string().min(1, '密码不能为空'),
});
export type LoginInput = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,86 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import argon2 from 'argon2';
import { PrismaService } from '../prisma/prisma.service.js';
import { loginSchema, type LoginInput } from './auth.schemas.js';
import type { SafeUser } from './auth.types.js';
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async login(input: unknown) {
const result = loginSchema.safeParse(input);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const user = await this.findUserForLogin(result.data);
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
const isPasswordValid = await argon2.verify(user.passwordHash, result.data.password);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
if (user.status !== 'ACTIVE') {
throw new ForbiddenException('账号已禁用');
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return this.toSafeUser(user);
}
async findMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { department: true },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('登录态已失效');
}
return this.toSafeUser(user);
}
private findUserForLogin(input: LoginInput) {
return this.prisma.user.findFirst({
where: { username: input.username },
include: { department: true },
});
}
private toSafeUser(user: Awaited<ReturnType<AuthService['findUserForLogin']>>): SafeUser {
if (!user) {
throw new UnauthorizedException('登录态已失效');
}
return {
id: user.id,
username: user.username,
role: user.role.toLowerCase() as SafeUser['role'],
name: user.name,
tenantId: user.tenantId,
departmentId: user.departmentId,
departmentName: user.department.name,
status: user.status.toLowerCase() as SafeUser['status'],
phone: user.phone ?? undefined,
email: user.email ?? undefined,
signatureFileId: user.signatureFileId ?? undefined,
signature: user.signatureFileId ? `/api/files/${user.signatureFileId}/content` : undefined,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,18 @@
import type { UserRole, UserStatus } from '@prisma/client';
export interface SafeUser {
id: string;
username: string;
role: Lowercase<UserRole>;
name: string;
tenantId: string;
departmentId: string;
departmentName: string;
status: Lowercase<UserStatus>;
phone?: string;
email?: string;
signatureFileId?: string;
signature?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,15 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import type { SafeUser } from './auth.types.js';
export const getSessionUser = async (
request: Request,
authService: AuthService,
): Promise<SafeUser> => {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return authService.findMe(request.session.userId);
};