feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { Controller, Post, Body, Get, Request, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } 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';
import { Public } from './decorators/public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService, private logService: OperationLogsService) {}
@Public()
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 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;
}
}
@Public()
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: any) {
return req.user;
}
}

View File

@@ -0,0 +1,30 @@
import { Module, forwardRef } 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';
import { RbacModule } from '../rbac/rbac.module';
@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') },
}),
}),
forwardRef(() => RbacModule),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,91 @@
import { Injectable, UnauthorizedException, forwardRef, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { User } from '../entities/user.entity';
import { LoginDto } from './dto/auth.dto';
import { RbacService } from '../rbac/rbac.service';
// 内存中的登录失败计数器按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,
@Inject(forwardRef(() => RbacService)) private rbacService: RbacService,
) {}
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 },
relations: ['roles'],
});
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 permissions = await this.rbacService.getUserPermissions(user.id);
const payload = { sub: user.id, username: user.username, permissions };
// 获取角色名称列表
const roleNames = user.roles ? user.roles.filter(r => r.status === 1).map(r => r.name) : [];
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
name: user.name,
roles: roleNames,
permissions,
},
};
}
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 } });
}
}

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permissions';
/**
* 声明接口所需权限。
* 多个参数之间为 OR 关系(用户拥有其中任一权限即可通过)。
* 不支持 AND 语义:多次调用装饰器会被全局 PermissionGuard 合并为扁平数组,效果等同于单次多参数调用。
*/
export const RequirePermission = (...permissions: string[]) =>
SetMetadata(PERMISSION_KEY, permissions);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

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,34 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// 2. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// 无装饰器 = 默认拒绝
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 3. 从 JWT payload 获取用户权限
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some(p => user.permissions.includes(p));
}
}

View File

@@ -0,0 +1,23 @@
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,
permissions: payload.permissions || [],
};
}
}