feat: 恭学教育基地管理系统初始提交

- 后端: NestJS 11 + TypeORM + JWT认证 + SQLite/MySQL
- 前端: React 19 + Ant Design 6 + Vite 8 + ECharts
- 功能模块: 数据面板、学生管理、宿舍管理、入住管理、费用录入、账单管理、教室管理、押金管理、操作日志、账号管理
- 支持Docker一键部署
This commit is contained in:
陈浩
2026-06-06 17:26:10 +08:00
commit 78676a124a
123 changed files with 26403 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import { Controller, Post, Body, UseGuards, Get, Put, Delete, Param, Request, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto } from './dto/auth.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { Throttle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService, private logService: OperationLogsService) {}
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 5 } }) // 登录接口每分钟最多5次
async login(@Body() dto: LoginDto, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.authService.login(dto, ipAddress);
await this.logService.log({
userId: result.user.id, username: result.user.username,
module: '认证', action: '登录成功',
ipAddress, userAgent, status: 'success',
});
return result;
} catch (e: any) {
await this.logService.log({
username: dto.username,
module: '认证', action: '登录失败',
detail: e.message || '密码错误',
ipAddress, userAgent, status: 'fail',
});
throw e;
}
}
@Post('register')
@UseGuards(JwtAuthGuard)
async register(@Body() dto: RegisterDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.authService.register(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '创建账号', detail: `用户名: ${dto.username}, 姓名: ${dto.name}`, ipAddress, userAgent });
return result;
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: any) {
return req.user;
}
// ---- 用户管理 ----
@UseGuards(JwtAuthGuard)
@Get('users')
findAllUsers() {
return this.authService.findAllUsers();
}
@UseGuards(JwtAuthGuard)
@Put('users/:id')
async updateUser(@Param('id') id: string, @Body() body: { name?: string; role?: string; isActive?: boolean; username?: string; allowedMenus?: string[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.authService.updateUser(+id, body);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(body), ipAddress, userAgent });
return result;
}
@UseGuards(JwtAuthGuard)
@Put('users/:id/password')
async resetPassword(@Param('id') id: string, @Body() body: { password: string }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.authService.resetPassword(+id, body.password);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '重置密码', targetId: +id, targetType: 'user', ipAddress, userAgent });
return result;
}
@UseGuards(JwtAuthGuard)
@Delete('users/:id')
async removeUser(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.authService.removeUser(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '删除账号', targetId: +id, targetType: 'user', ipAddress, userAgent });
return result;
}
}

View File

@@ -0,0 +1,33 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../entities/user.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule implements OnModuleInit {
constructor(private authService: AuthService) {}
async onModuleInit() {
await this.authService.initAdmin();
}
}

View File

@@ -0,0 +1,149 @@
import { Injectable, UnauthorizedException, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, MoreThan } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { User } from '../entities/user.entity';
import { LoginDto, RegisterDto } from './dto/auth.dto';
// 内存中的登录失败计数器按IP+用户名)
const loginAttempts = new Map<string, { count: number; lockedUntil?: Date }>();
const MAX_ATTEMPTS = 5;
const LOCK_MINUTES = 15;
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async register(dto: RegisterDto) {
const exists = await this.userRepo.findOne({ where: { username: dto.username } });
if (exists) throw new UnauthorizedException('用户名已存在');
const hash = await bcrypt.hash(dto.password, 10);
const user = this.userRepo.create({
username: dto.username,
passwordHash: hash,
name: dto.name,
role: 'operator',
allowedMenus: (dto as any).allowedMenus ? JSON.stringify((dto as any).allowedMenus) : null as any,
});
await this.userRepo.save(user);
return { message: '注册成功' };
}
async login(dto: LoginDto, ip?: string) {
const attemptKey = `${ip || 'unknown'}:${dto.username}`;
const attempt = loginAttempts.get(attemptKey);
// 检查是否在锁定期
if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) {
const remaining = Math.ceil((attempt.lockedUntil.getTime() - Date.now()) / 60000);
throw new UnauthorizedException(`账号已被临时锁定,请 ${remaining} 分钟后重试`);
}
const user = await this.userRepo.findOne({ where: { username: dto.username } });
if (!user) {
this.recordFailedAttempt(attemptKey);
throw new UnauthorizedException('用户名或密码错误');
}
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员');
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) {
this.recordFailedAttempt(attemptKey);
const att = loginAttempts.get(attemptKey);
const remaining = MAX_ATTEMPTS - (att?.count || 0);
if (remaining > 0) {
throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`);
}
throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
}
// 登录成功,清除失败计数
loginAttempts.delete(attemptKey);
// 记录登录时间
user.lastLoginAt = new Date();
await this.userRepo.save(user);
const payload = { sub: user.id, username: user.username, role: user.role };
const allowedMenus = user.allowedMenus ? JSON.parse(user.allowedMenus) : null;
return { access_token: this.jwtService.sign(payload), user: { id: user.id, username: user.username, name: user.name, role: user.role, allowedMenus } };
}
private recordFailedAttempt(key: string) {
const attempt = loginAttempts.get(key) || { count: 0 };
attempt.count++;
if (attempt.count >= MAX_ATTEMPTS) {
attempt.lockedUntil = new Date(Date.now() + LOCK_MINUTES * 60 * 1000);
}
loginAttempts.set(key, attempt);
}
async validateUser(payload: any) {
return this.userRepo.findOne({ where: { id: payload.sub } });
}
async initAdmin() {
const count = await this.userRepo.count();
if (count === 0) {
const adminPassword = this.configService.get('ADMIN_PASSWORD', 'admin123');
const hash = await bcrypt.hash(adminPassword, 10);
await this.userRepo.save(this.userRepo.create({ username: 'admin', passwordHash: hash, name: '管理员', role: 'admin' }));
console.log(`已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`);
}
}
// ---- 用户管理 CRUD ----
async findAllUsers() {
const users = await this.userRepo.find({
select: ['id', 'username', 'name', 'role', 'isActive', 'allowedMenus', 'lastLoginAt', 'createdAt', 'updatedAt'],
order: { createdAt: 'DESC' },
});
return users.map(u => ({
...u,
allowedMenus: u.allowedMenus ? JSON.parse(u.allowedMenus) : null,
}));
}
async updateUser(id: number, data: { name?: string; role?: string; isActive?: boolean; username?: string; allowedMenus?: string[] }) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
if (user.username === 'admin' && data.role && data.role !== 'admin') {
throw new BadRequestException('不能修改默认管理员的角色');
}
if (user.username === 'admin' && data.isActive === false) {
throw new BadRequestException('不能禁用默认管理员');
}
if (data.username !== undefined && data.username !== user.username) {
const exists = await this.userRepo.findOne({ where: { username: data.username } });
if (exists) throw new BadRequestException('用户名已存在');
user.username = data.username;
}
if (data.name !== undefined) user.name = data.name;
if (data.role !== undefined) user.role = data.role;
if (data.isActive !== undefined) user.isActive = data.isActive;
if (data.allowedMenus !== undefined) user.allowedMenus = data.allowedMenus ? JSON.stringify(data.allowedMenus) : null as any;
await this.userRepo.save(user);
return { message: '更新成功' };
}
async resetPassword(id: number, newPassword: string) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
user.passwordHash = await bcrypt.hash(newPassword, 10);
await this.userRepo.save(user);
return { message: '密码已重置' };
}
async removeUser(id: number) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
if (user.username === 'admin') throw new BadRequestException('不能删除默认管理员');
await this.userRepo.delete(id);
return { message: '用户已删除' };
}
}

View File

@@ -0,0 +1,22 @@
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
username: string;
@IsString()
@MinLength(4)
password: string;
}
export class RegisterDto {
@IsString()
username: string;
@IsString()
@MinLength(4)
password: string;
@IsString()
name: string;
}

View File

@@ -0,0 +1,5 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
});
}
async validate(payload: any) {
return { id: payload.sub, username: payload.username, role: payload.role };
}
}