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:
91
server/src/auth/auth.controller.ts
Normal file
91
server/src/auth/auth.controller.ts
Normal 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('保存登录态失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
10
server/src/auth/auth.module.ts
Normal file
10
server/src/auth/auth.module.ts
Normal 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 {}
|
||||
8
server/src/auth/auth.schemas.ts
Normal file
8
server/src/auth/auth.schemas.ts
Normal 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>;
|
||||
86
server/src/auth/auth.service.ts
Normal file
86
server/src/auth/auth.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
server/src/auth/auth.types.ts
Normal file
18
server/src/auth/auth.types.ts
Normal 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;
|
||||
}
|
||||
15
server/src/auth/session-user.ts
Normal file
15
server/src/auth/session-user.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user