初始化游戏小组管理系统后端项目
- 基于 NestJS + TypeScript + MySQL + Redis 架构 - 完整的模块化设计(认证、用户、小组、游戏、预约等) - JWT 认证和 RBAC 权限控制系统 - Docker 容器化部署支持 - 添加 CLAUDE.md 项目开发指南 - 配置 .gitignore 忽略文件 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
27
src/app.controller.ts
Normal file
27
src/app.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { AppService } from './app.service';
|
||||
import { Public } from './common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('system')
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '系统欢迎信息' })
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: '健康检查' })
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
104
src/app.module.ts
Normal file
104
src/app.module.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
// 公共模块
|
||||
import { CommonModule } from './common/common.module';
|
||||
|
||||
// 配置文件
|
||||
import appConfig from './config/app.config';
|
||||
import databaseConfig from './config/database.config';
|
||||
import jwtConfig from './config/jwt.config';
|
||||
import redisConfig from './config/redis.config';
|
||||
import cacheConfig from './config/cache.config';
|
||||
import performanceConfig from './config/performance.config';
|
||||
|
||||
// 业务模块
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { GroupsModule } from './modules/groups/groups.module';
|
||||
import { GamesModule } from './modules/games/games.module';
|
||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
import { LedgersModule } from './modules/ledgers/ledgers.module';
|
||||
import { SchedulesModule } from './modules/schedules/schedules.module';
|
||||
import { BlacklistModule } from './modules/blacklist/blacklist.module';
|
||||
import { HonorsModule } from './modules/honors/honors.module';
|
||||
import { AssetsModule } from './modules/assets/assets.module';
|
||||
import { PointsModule } from './modules/points/points.module';
|
||||
import { BetsModule } from './modules/bets/bets.module';
|
||||
|
||||
// 守卫
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig, cacheConfig, performanceConfig],
|
||||
envFilePath: [
|
||||
`.env.${process.env.NODE_ENV || 'development'}`,
|
||||
'.env.local',
|
||||
'.env',
|
||||
],
|
||||
}),
|
||||
|
||||
// 数据库模块
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
host: configService.get('database.host'),
|
||||
port: configService.get('database.port'),
|
||||
username: configService.get('database.username'),
|
||||
password: configService.get('database.password'),
|
||||
database: configService.get('database.database'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: configService.get('database.synchronize'),
|
||||
logging: configService.get('database.logging'),
|
||||
timezone: '+08:00',
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// 定时任务模块
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// 公共模块
|
||||
CommonModule,
|
||||
|
||||
// 业务模块
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
GroupsModule,
|
||||
GamesModule,
|
||||
AppointmentsModule,
|
||||
LedgersModule,
|
||||
SchedulesModule,
|
||||
BlacklistModule,
|
||||
HonorsModule,
|
||||
AssetsModule,
|
||||
PointsModule,
|
||||
BetsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
// 全局守卫
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
9
src/common/common.module.ts
Normal file
9
src/common/common.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { CacheService } from './services/cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
12
src/common/decorators/current-user.decorator.ts
Normal file
12
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 获取当前登录用户装饰器
|
||||
* 用法: @CurrentUser() user: User
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
10
src/common/decorators/public.decorator.ts
Normal file
10
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/**
|
||||
* 公开接口装饰器
|
||||
* 使用此装饰器的接口不需要认证
|
||||
* 用法: @Public()
|
||||
*/
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
10
src/common/decorators/roles.decorator.ts
Normal file
10
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../enums';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/**
|
||||
* 角色装饰器
|
||||
* 用法: @Roles(UserRole.ADMIN)
|
||||
*/
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
91
src/common/enums/index.ts
Normal file
91
src/common/enums/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 用户角色枚举
|
||||
*/
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin', // 系统管理员
|
||||
USER = 'user', // 普通用户
|
||||
}
|
||||
|
||||
/**
|
||||
* 小组成员角色枚举
|
||||
*/
|
||||
export enum GroupMemberRole {
|
||||
OWNER = 'owner', // 组长
|
||||
ADMIN = 'admin', // 管理员
|
||||
MEMBER = 'member', // 普通成员
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约状态枚举
|
||||
*/
|
||||
export enum AppointmentStatus {
|
||||
PENDING = 'pending', // 待开始
|
||||
OPEN = 'open', // 开放中
|
||||
FULL = 'full', // 已满员
|
||||
CANCELLED = 'cancelled', // 已取消
|
||||
FINISHED = 'finished', // 已完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约参与状态枚举
|
||||
*/
|
||||
export enum ParticipantStatus {
|
||||
JOINED = 'joined', // 已加入
|
||||
PENDING = 'pending', // 待定
|
||||
REJECTED = 'rejected', // 已拒绝
|
||||
}
|
||||
|
||||
/**
|
||||
* 账目类型枚举
|
||||
*/
|
||||
export enum LedgerType {
|
||||
INCOME = 'income', // 收入
|
||||
EXPENSE = 'expense', // 支出
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产类型枚举
|
||||
*/
|
||||
export enum AssetType {
|
||||
ACCOUNT = 'account', // 账号
|
||||
ITEM = 'item', // 物品
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产状态枚举
|
||||
*/
|
||||
export enum AssetStatus {
|
||||
AVAILABLE = 'available', // 可用
|
||||
IN_USE = 'in_use', // 使用中
|
||||
BORROWED = 'borrowed', // 已借出
|
||||
MAINTENANCE = 'maintenance', // 维护中
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产操作类型枚举
|
||||
*/
|
||||
export enum AssetLogAction {
|
||||
BORROW = 'borrow', // 借出
|
||||
RETURN = 'return', // 归还
|
||||
ADD = 'add', // 添加
|
||||
REMOVE = 'remove', // 移除
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑名单状态枚举
|
||||
*/
|
||||
export enum BlacklistStatus {
|
||||
PENDING = 'pending', // 待审核
|
||||
APPROVED = 'approved', // 已通过
|
||||
REJECTED = 'rejected', // 已拒绝
|
||||
}
|
||||
|
||||
/**
|
||||
* 竞猜状态枚举
|
||||
*/
|
||||
export enum BetStatus {
|
||||
PENDING = 'pending', // 进行中
|
||||
WON = 'won', // 赢
|
||||
CANCELLED = 'cancelled', // 已取消
|
||||
LOST = 'lost', // 输
|
||||
}
|
||||
76
src/common/filters/http-exception.filter.ts
Normal file
76
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface';
|
||||
|
||||
/**
|
||||
* 全局异常过滤器
|
||||
* 统一处理所有异常,返回统一格式的错误响应
|
||||
*/
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
// 默认状态码和错误信息
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = ErrorCode.SERVER_ERROR;
|
||||
let message = ErrorMessage[ErrorCode.SERVER_ERROR];
|
||||
let data = null;
|
||||
|
||||
// 处理 HttpException
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'object') {
|
||||
code = (exceptionResponse as any).code || status;
|
||||
message =
|
||||
(exceptionResponse as any).message ||
|
||||
exception.message ||
|
||||
ErrorMessage[code] ||
|
||||
'请求失败';
|
||||
|
||||
// 处理验证错误
|
||||
if ((exceptionResponse as any).message instanceof Array) {
|
||||
message = (exceptionResponse as any).message.join('; ');
|
||||
code = ErrorCode.PARAM_ERROR;
|
||||
}
|
||||
} else {
|
||||
message = exceptionResponse as string;
|
||||
}
|
||||
} else {
|
||||
// 处理其他类型的错误
|
||||
message = exception.message || ErrorMessage[ErrorCode.UNKNOWN_ERROR];
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
this.logger.error(
|
||||
`[${request.method}] ${request.url} - ${status} - ${message}`,
|
||||
exception.stack,
|
||||
);
|
||||
|
||||
// 返回统一格式的错误响应
|
||||
response.status(status).json({
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
43
src/common/guards/jwt-auth.guard.ts
Normal file
43
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface';
|
||||
|
||||
/**
|
||||
* JWT 认证守卫
|
||||
* 默认所有接口都需要认证,除非使用 @Public() 装饰器
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// 检查是否是公开接口
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw (
|
||||
err ||
|
||||
new UnauthorizedException({
|
||||
code: ErrorCode.UNAUTHORIZED,
|
||||
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
|
||||
})
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
45
src/common/guards/roles.guard.ts
Normal file
45
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { UserRole } from '../enums';
|
||||
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface';
|
||||
|
||||
/**
|
||||
* 角色守卫
|
||||
* 检查用户是否拥有所需的角色
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.UNAUTHORIZED,
|
||||
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
|
||||
});
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
48
src/common/interceptors/logging.interceptor.ts
Normal file
48
src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* 日志拦截器
|
||||
* 记录请求和响应信息
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, url, body, query, params } = request;
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
const ip = request.ip;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
this.logger.log(
|
||||
`[${method}] ${url} - ${ip} - ${userAgent} - Body: ${JSON.stringify(body)} - Query: ${JSON.stringify(query)} - Params: ${JSON.stringify(params)}`,
|
||||
);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
const responseTime = Date.now() - now;
|
||||
this.logger.log(
|
||||
`[${method}] ${url} - ${responseTime}ms - ${context.switchToHttp().getResponse().statusCode}`,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
const responseTime = Date.now() - now;
|
||||
this.logger.error(
|
||||
`[${method}] ${url} - ${responseTime}ms - Error: ${error.message}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/common/interceptors/transform.interceptor.ts
Normal file
40
src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse, ErrorCode } from '../interfaces/response.interface';
|
||||
|
||||
/**
|
||||
* 全局响应拦截器
|
||||
* 统一处理成功响应的格式
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, ApiResponse<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
// 如果返回的数据已经是 ApiResponse 格式,直接返回
|
||||
if (data && typeof data === 'object' && 'code' in data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 否则包装成统一格式
|
||||
return {
|
||||
code: ErrorCode.SUCCESS,
|
||||
message: 'success',
|
||||
data: data || null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
src/common/interfaces/response.interface.ts
Normal file
129
src/common/interfaces/response.interface.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 统一响应格式接口
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应接口
|
||||
*/
|
||||
export interface PaginatedResponse<T = any> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误码枚举
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// 通用错误 (00xxx)
|
||||
SUCCESS = 0,
|
||||
UNKNOWN_ERROR = 1,
|
||||
PARAM_ERROR = 2,
|
||||
NOT_FOUND = 3,
|
||||
|
||||
// 用户相关 (10xxx)
|
||||
USER_NOT_FOUND = 10001,
|
||||
PASSWORD_ERROR = 10002,
|
||||
USER_EXISTS = 10003,
|
||||
TOKEN_INVALID = 10004,
|
||||
TOKEN_EXPIRED = 10005,
|
||||
UNAUTHORIZED = 10006,
|
||||
|
||||
// 小组相关 (20xxx)
|
||||
GROUP_NOT_FOUND = 20001,
|
||||
GROUP_FULL = 20002,
|
||||
NO_PERMISSION = 20003,
|
||||
GROUP_LIMIT_EXCEEDED = 20004,
|
||||
JOIN_GROUP_LIMIT_EXCEEDED = 20005,
|
||||
ALREADY_IN_GROUP = 20006,
|
||||
NOT_IN_GROUP = 20007,
|
||||
|
||||
// 预约相关 (30xxx)
|
||||
APPOINTMENT_NOT_FOUND = 30001,
|
||||
APPOINTMENT_FULL = 30002,
|
||||
APPOINTMENT_CLOSED = 30003,
|
||||
ALREADY_JOINED = 30004,
|
||||
NOT_JOINED = 30005,
|
||||
|
||||
// 游戏相关 (40xxx)
|
||||
GAME_NOT_FOUND = 40001,
|
||||
GAME_EXISTS = 40002,
|
||||
|
||||
// 账本相关 (50xxx)
|
||||
LEDGER_NOT_FOUND = 50001,
|
||||
|
||||
// 黑名单相关 (60xxx)
|
||||
BLACKLIST_NOT_FOUND = 60001,
|
||||
INVALID_OPERATION = 60002,
|
||||
|
||||
// 荣誉相关 (70xxx)
|
||||
HONOR_NOT_FOUND = 70001,
|
||||
|
||||
// 资产相关 (80xxx)
|
||||
ASSET_NOT_FOUND = 80001,
|
||||
|
||||
// 积分相关 (85xxx)
|
||||
INSUFFICIENT_POINTS = 85001,
|
||||
|
||||
// 系统相关 (90xxx)
|
||||
SERVER_ERROR = 90001,
|
||||
DATABASE_ERROR = 90002,
|
||||
CACHE_ERROR = 90003,
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误信息映射
|
||||
*/
|
||||
export const ErrorMessage: Record<ErrorCode, string> = {
|
||||
[ErrorCode.SUCCESS]: '成功',
|
||||
[ErrorCode.UNKNOWN_ERROR]: '未知错误',
|
||||
[ErrorCode.PARAM_ERROR]: '参数错误',
|
||||
[ErrorCode.NOT_FOUND]: '资源不存在',
|
||||
|
||||
[ErrorCode.USER_NOT_FOUND]: '用户不存在',
|
||||
[ErrorCode.PASSWORD_ERROR]: '密码错误',
|
||||
[ErrorCode.USER_EXISTS]: '用户已存在',
|
||||
[ErrorCode.TOKEN_INVALID]: 'Token无效',
|
||||
[ErrorCode.TOKEN_EXPIRED]: 'Token已过期',
|
||||
[ErrorCode.UNAUTHORIZED]: '未授权',
|
||||
|
||||
[ErrorCode.GROUP_NOT_FOUND]: '小组不存在',
|
||||
[ErrorCode.GROUP_FULL]: '小组已满员',
|
||||
[ErrorCode.NO_PERMISSION]: '无权限操作',
|
||||
[ErrorCode.GROUP_LIMIT_EXCEEDED]: '小组数量超限',
|
||||
[ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED]: '加入小组数量超限',
|
||||
[ErrorCode.ALREADY_IN_GROUP]: '已在该小组中',
|
||||
[ErrorCode.NOT_IN_GROUP]: '不在该小组中',
|
||||
|
||||
[ErrorCode.APPOINTMENT_NOT_FOUND]: '预约不存在',
|
||||
[ErrorCode.APPOINTMENT_FULL]: '预约已满',
|
||||
[ErrorCode.APPOINTMENT_CLOSED]: '预约已关闭',
|
||||
[ErrorCode.ALREADY_JOINED]: '已加入预约',
|
||||
[ErrorCode.NOT_JOINED]: '未加入预约',
|
||||
|
||||
[ErrorCode.GAME_NOT_FOUND]: '游戏不存在',
|
||||
[ErrorCode.GAME_EXISTS]: '游戏已存在',
|
||||
|
||||
[ErrorCode.LEDGER_NOT_FOUND]: '账本记录不存在',
|
||||
|
||||
[ErrorCode.BLACKLIST_NOT_FOUND]: '黑名单记录不存在',
|
||||
[ErrorCode.INVALID_OPERATION]: '无效操作',
|
||||
|
||||
[ErrorCode.HONOR_NOT_FOUND]: '荣誉记录不存在',
|
||||
|
||||
[ErrorCode.ASSET_NOT_FOUND]: '资产不存在',
|
||||
|
||||
[ErrorCode.INSUFFICIENT_POINTS]: '积分不足',
|
||||
|
||||
[ErrorCode.SERVER_ERROR]: '服务器错误',
|
||||
[ErrorCode.DATABASE_ERROR]: '数据库错误',
|
||||
[ErrorCode.CACHE_ERROR]: '缓存错误',
|
||||
};
|
||||
43
src/common/pipes/validation.pipe.ts
Normal file
43
src/common/pipes/validation.pipe.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
PipeTransform,
|
||||
Injectable,
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { ErrorCode } from '../interfaces/response.interface';
|
||||
|
||||
/**
|
||||
* 全局验证管道
|
||||
* 自动验证 DTO 并返回统一格式的错误
|
||||
*/
|
||||
@Injectable()
|
||||
export class ValidationPipe implements PipeTransform<any> {
|
||||
async transform(value: any, { metatype }: ArgumentMetadata) {
|
||||
if (!metatype || !this.toValidate(metatype)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const object = plainToInstance(metatype, value);
|
||||
const errors = await validate(object);
|
||||
|
||||
if (errors.length > 0) {
|
||||
const messages = errors
|
||||
.map((error) => Object.values(error.constraints || {}).join(', '))
|
||||
.join('; ');
|
||||
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: messages,
|
||||
});
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
private toValidate(metatype: Function): boolean {
|
||||
const types: Function[] = [String, Boolean, Number, Array, Object];
|
||||
return !types.includes(metatype);
|
||||
}
|
||||
}
|
||||
111
src/common/services/cache.service.ts
Normal file
111
src/common/services/cache.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
private readonly logger = new Logger(CacheService.name);
|
||||
private readonly cache = new Map<string, { value: any; expires: number }>();
|
||||
private readonly defaultTTL: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.defaultTTL = this.configService.get('cache.ttl', 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
*/
|
||||
set(key: string, value: any, options?: CacheOptions): void {
|
||||
const ttl = options?.ttl || this.defaultTTL;
|
||||
const prefix = options?.prefix || '';
|
||||
const fullKey = prefix ? `${prefix}:${key}` : key;
|
||||
|
||||
const expires = Date.now() + ttl * 1000;
|
||||
this.cache.set(fullKey, { value, expires });
|
||||
|
||||
this.logger.debug(`Cache set: ${fullKey} (TTL: ${ttl}s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
*/
|
||||
get<T>(key: string, options?: CacheOptions): T | null {
|
||||
const prefix = options?.prefix || '';
|
||||
const fullKey = prefix ? `${prefix}:${key}` : key;
|
||||
|
||||
const item = this.cache.get(fullKey);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > item.expires) {
|
||||
this.cache.delete(fullKey);
|
||||
this.logger.debug(`Cache expired: ${fullKey}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(`Cache hit: ${fullKey}`);
|
||||
return item.value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
*/
|
||||
del(key: string, options?: CacheOptions): void {
|
||||
const prefix = options?.prefix || '';
|
||||
const fullKey = prefix ? `${prefix}:${key}` : key;
|
||||
|
||||
this.cache.delete(fullKey);
|
||||
this.logger.debug(`Cache deleted: ${fullKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.logger.log('Cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定前缀的缓存
|
||||
*/
|
||||
clearByPrefix(prefix: string): void {
|
||||
const keys = Array.from(this.cache.keys());
|
||||
let count = 0;
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith(`${prefix}:`)) {
|
||||
this.cache.delete(key);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(`Cleared ${count} cache entries with prefix: ${prefix}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或设置缓存(如果不存在则执行回调并缓存结果)
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
callback: () => Promise<T>,
|
||||
options?: CacheOptions,
|
||||
): Promise<T> {
|
||||
const cached = this.get<T>(key, options);
|
||||
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await callback();
|
||||
this.set(key, value, options);
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
37
src/common/utils/crypto.util.ts
Normal file
37
src/common/utils/crypto.util.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* 加密工具类
|
||||
*/
|
||||
export class CryptoUtil {
|
||||
/**
|
||||
* 生成密码哈希
|
||||
*/
|
||||
static async hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
*/
|
||||
static async comparePassword(
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
static generateRandomString(length: number = 32): string {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
71
src/common/utils/date.util.ts
Normal file
71
src/common/utils/date.util.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/**
|
||||
* 日期时间工具类
|
||||
*/
|
||||
export class DateUtil {
|
||||
/**
|
||||
* 获取当前时间戳(秒)
|
||||
*/
|
||||
static nowTimestamp(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间戳(毫秒)
|
||||
*/
|
||||
static nowMilliseconds(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
static format(
|
||||
date: Date | string | number,
|
||||
format: string = 'YYYY-MM-DD HH:mm:ss',
|
||||
): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期
|
||||
*/
|
||||
static parse(dateString: string, format?: string): Date {
|
||||
return dayjs(dateString, format).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时区时间
|
||||
*/
|
||||
static getTimezoneDate(tz: string = 'Asia/Shanghai'): Date {
|
||||
return dayjs().tz(tz).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期加减
|
||||
*/
|
||||
static add(
|
||||
date: Date,
|
||||
value: number,
|
||||
unit: dayjs.ManipulateType = 'day',
|
||||
): Date {
|
||||
return dayjs(date).add(value, unit).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算时间差
|
||||
*/
|
||||
static diff(
|
||||
date1: Date,
|
||||
date2: Date,
|
||||
unit: dayjs.QUnitType = 'day',
|
||||
): number {
|
||||
return dayjs(date1).diff(dayjs(date2), unit);
|
||||
}
|
||||
}
|
||||
32
src/common/utils/pagination.util.ts
Normal file
32
src/common/utils/pagination.util.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 分页工具类
|
||||
*/
|
||||
export class PaginationUtil {
|
||||
/**
|
||||
* 计算偏移量
|
||||
*/
|
||||
static getOffset(page: number, limit: number): number {
|
||||
return (page - 1) * limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
static getTotalPages(total: number, limit: number): number {
|
||||
return Math.ceil(total / limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分页参数
|
||||
*/
|
||||
static formatPaginationParams(page?: number, limit?: number) {
|
||||
const normalizedPage = Math.max(1, page || 1);
|
||||
const normalizedLimit = Math.min(100, Math.max(1, limit || 10));
|
||||
|
||||
return {
|
||||
page: normalizedPage,
|
||||
limit: normalizedLimit,
|
||||
offset: this.getOffset(normalizedPage, normalizedLimit),
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/config/app.config.ts
Normal file
11
src/config/app.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
apiPrefix: process.env.API_PREFIX || 'api',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
}));
|
||||
7
src/config/cache.config.ts
Normal file
7
src/config/cache.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('cache', () => ({
|
||||
ttl: parseInt(process.env.CACHE_TTL || '300', 10),
|
||||
max: parseInt(process.env.CACHE_MAX || '100', 10),
|
||||
isGlobal: true,
|
||||
}));
|
||||
36
src/config/database.config.ts
Normal file
36
src/config/database.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('database', () => {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
return {
|
||||
type: process.env.DB_TYPE || 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
username: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || 'password',
|
||||
database: process.env.DB_DATABASE || 'gamegroup',
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
logging: process.env.DB_LOGGING === 'true',
|
||||
timezone: '+08:00',
|
||||
// 生产环境优化配置
|
||||
extra: {
|
||||
// 连接池配置
|
||||
connectionLimit: isProduction ? 20 : 10,
|
||||
// 连接超时
|
||||
connectTimeout: 10000,
|
||||
// 查询超时
|
||||
timeout: 30000,
|
||||
// 字符集
|
||||
charset: 'utf8mb4',
|
||||
},
|
||||
// 查询性能优化
|
||||
maxQueryExecutionTime: isProduction ? 1000 : 5000, // 毫秒
|
||||
cache: isProduction ? {
|
||||
type: 'database',
|
||||
tableName: 'query_result_cache',
|
||||
duration: 60000, // 1分钟
|
||||
} : false,
|
||||
};
|
||||
});
|
||||
8
src/config/jwt.config.ts
Normal file
8
src/config/jwt.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
}));
|
||||
8
src/config/performance.config.ts
Normal file
8
src/config/performance.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('performance', () => ({
|
||||
enableCompression: process.env.ENABLE_COMPRESSION === 'true',
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
queryLimit: 100,
|
||||
queryTimeout: 30000,
|
||||
}));
|
||||
8
src/config/redis.config.ts
Normal file
8
src/config/redis.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
}));
|
||||
48
src/entities/appointment-participant.entity.ts
Normal file
48
src/entities/appointment-participant.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ParticipantStatus } from '../common/enums';
|
||||
import { Appointment } from './appointment.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('appointment_participants')
|
||||
@Unique(['appointmentId', 'userId'])
|
||||
export class AppointmentParticipant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
appointmentId: string;
|
||||
|
||||
@ManyToOne(() => Appointment, (appointment) => appointment.participants, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'appointmentId' })
|
||||
appointment: Appointment;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ParticipantStatus,
|
||||
default: ParticipantStatus.JOINED,
|
||||
})
|
||||
status: ParticipantStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '备注' })
|
||||
note: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
joinedAt: Date;
|
||||
}
|
||||
81
src/entities/appointment.entity.ts
Normal file
81
src/entities/appointment.entity.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { AppointmentStatus } from '../common/enums';
|
||||
import { Group } from './group.entity';
|
||||
import { Game } from './game.entity';
|
||||
import { User } from './user.entity';
|
||||
import { AppointmentParticipant } from './appointment-participant.entity';
|
||||
|
||||
@Entity('appointments')
|
||||
export class Appointment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, (group) => group.appointments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column()
|
||||
gameId: string;
|
||||
|
||||
@ManyToOne(() => Game, (game) => game.appointments)
|
||||
@JoinColumn({ name: 'gameId' })
|
||||
game: Game;
|
||||
|
||||
@Column()
|
||||
initiatorId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.appointments)
|
||||
@JoinColumn({ name: 'initiatorId' })
|
||||
initiator: User;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'datetime' })
|
||||
startTime: Date;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
endTime: Date;
|
||||
|
||||
@Column({ comment: '最大参与人数' })
|
||||
maxParticipants: number;
|
||||
|
||||
@Column({ default: 0, comment: '当前参与人数' })
|
||||
currentParticipants: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AppointmentStatus,
|
||||
default: AppointmentStatus.OPEN,
|
||||
})
|
||||
status: AppointmentStatus;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(
|
||||
() => AppointmentParticipant,
|
||||
(participant) => participant.appointment,
|
||||
)
|
||||
participants: AppointmentParticipant[];
|
||||
}
|
||||
43
src/entities/asset-log.entity.ts
Normal file
43
src/entities/asset-log.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { AssetLogAction } from '../common/enums';
|
||||
import { Asset } from './asset.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('asset_logs')
|
||||
export class AssetLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
assetId: string;
|
||||
|
||||
@ManyToOne(() => Asset, (asset) => asset.logs, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'assetId' })
|
||||
asset: Asset;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'enum', enum: AssetLogAction })
|
||||
action: AssetLogAction;
|
||||
|
||||
@Column({ default: 1, comment: '数量' })
|
||||
quantity: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '备注' })
|
||||
note: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
60
src/entities/asset.entity.ts
Normal file
60
src/entities/asset.entity.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { AssetType, AssetStatus } from '../common/enums';
|
||||
import { Group } from './group.entity';
|
||||
import { AssetLog } from './asset-log.entity';
|
||||
|
||||
@Entity('assets')
|
||||
export class Asset {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column({ type: 'enum', enum: AssetType })
|
||||
type: AssetType;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '描述' })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '加密的账号凭据' })
|
||||
accountCredentials?: string | null;
|
||||
|
||||
@Column({ default: 1, comment: '数量(用于物品)' })
|
||||
quantity: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AssetStatus,
|
||||
default: AssetStatus.AVAILABLE,
|
||||
})
|
||||
status: AssetStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, comment: '当前借用人ID' })
|
||||
currentBorrowerId?: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => AssetLog, (log) => log.asset)
|
||||
logs: AssetLog[];
|
||||
}
|
||||
50
src/entities/bet.entity.ts
Normal file
50
src/entities/bet.entity.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { BetStatus } from '../common/enums';
|
||||
import { Appointment } from './appointment.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('bets')
|
||||
export class Bet {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
appointmentId: string;
|
||||
|
||||
@ManyToOne(() => Appointment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'appointmentId' })
|
||||
appointment: Appointment;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ length: 100, comment: '下注选项' })
|
||||
betOption: string;
|
||||
|
||||
@Column({ type: 'int', comment: '下注积分' })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'enum', enum: BetStatus, default: BetStatus.PENDING })
|
||||
status: BetStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0, comment: '赢得的积分' })
|
||||
winAmount: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
52
src/entities/blacklist.entity.ts
Normal file
52
src/entities/blacklist.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { BlacklistStatus } from '../common/enums';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('blacklists')
|
||||
export class Blacklist {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 100, comment: '目标游戏ID或用户名' })
|
||||
targetGameId: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
reason: string;
|
||||
|
||||
@Column()
|
||||
reporterId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'reporterId' })
|
||||
reporter: User;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, comment: '证据图片' })
|
||||
proofImages: string[];
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BlacklistStatus,
|
||||
default: BlacklistStatus.PENDING,
|
||||
})
|
||||
status: BlacklistStatus;
|
||||
|
||||
@Column({ nullable: true, comment: '审核人ID' })
|
||||
reviewerId: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'reviewerId' })
|
||||
reviewer: User;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '审核意见' })
|
||||
reviewNote: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
48
src/entities/game.entity.ts
Normal file
48
src/entities/game.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Appointment } from './appointment.entity';
|
||||
|
||||
@Entity('games')
|
||||
export class Game {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 255 })
|
||||
coverUrl: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ comment: '最大玩家数' })
|
||||
maxPlayers: number;
|
||||
|
||||
@Column({ default: 1, comment: '最小玩家数' })
|
||||
minPlayers: number;
|
||||
|
||||
@Column({ length: 50, nullable: true, comment: '平台' })
|
||||
platform: string;
|
||||
|
||||
@Column({ type: 'simple-array', nullable: true, comment: '游戏标签' })
|
||||
tags: string[];
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => Appointment, (appointment) => appointment.game)
|
||||
appointments: Appointment[];
|
||||
}
|
||||
49
src/entities/group-member.entity.ts
Normal file
49
src/entities/group-member.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { GroupMemberRole } from '../common/enums';
|
||||
import { User } from './user.entity';
|
||||
import { Group } from './group.entity';
|
||||
|
||||
@Entity('group_members')
|
||||
@Unique(['groupId', 'userId'])
|
||||
export class GroupMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, (group) => group.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.groupMembers, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: GroupMemberRole,
|
||||
default: GroupMemberRole.MEMBER,
|
||||
})
|
||||
role: GroupMemberRole;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 50, comment: '组内昵称' })
|
||||
nickname: string;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
joinedAt: Date;
|
||||
}
|
||||
69
src/entities/group.entity.ts
Normal file
69
src/entities/group.entity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { GroupMember } from './group-member.entity';
|
||||
import { Appointment } from './appointment.entity';
|
||||
|
||||
@Entity('groups')
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 255 })
|
||||
avatar: string;
|
||||
|
||||
@Column()
|
||||
ownerId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'ownerId' })
|
||||
owner: User;
|
||||
|
||||
@Column({ default: 'normal', length: 20, comment: '类型: normal/guild' })
|
||||
type: string;
|
||||
|
||||
@Column({ nullable: true, comment: '父组ID,用于子组' })
|
||||
parentId: string;
|
||||
|
||||
@ManyToOne(() => Group, { nullable: true })
|
||||
@JoinColumn({ name: 'parentId' })
|
||||
parent: Group;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '公示信息' })
|
||||
announcement: string;
|
||||
|
||||
@Column({ default: 50, comment: '最大成员数' })
|
||||
maxMembers: number;
|
||||
|
||||
@Column({ default: 1, comment: '当前成员数' })
|
||||
currentMembers: number;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => GroupMember, (member) => member.group)
|
||||
members: GroupMember[];
|
||||
|
||||
@OneToMany(() => Appointment, (appointment) => appointment.group)
|
||||
appointments: Appointment[];
|
||||
}
|
||||
48
src/entities/honor.entity.ts
Normal file
48
src/entities/honor.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Group } from './group.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('honors')
|
||||
export class Honor {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column({ length: 200 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, comment: '媒体文件URLs' })
|
||||
mediaUrls: string[];
|
||||
|
||||
@Column({ type: 'date', comment: '事件日期' })
|
||||
eventDate: Date;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, comment: '参与者ID列表' })
|
||||
participantIds: string[];
|
||||
|
||||
@Column()
|
||||
creatorId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'creatorId' })
|
||||
creator: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
49
src/entities/ledger.entity.ts
Normal file
49
src/entities/ledger.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { LedgerType } from '../common/enums';
|
||||
import { Group } from './group.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('ledgers')
|
||||
export class Ledger {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column()
|
||||
creatorId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'creatorId' })
|
||||
creator: User;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'enum', enum: LedgerType })
|
||||
type: LedgerType;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, comment: '分类' })
|
||||
category: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, comment: '凭证图片' })
|
||||
proofImages: string[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
45
src/entities/point.entity.ts
Normal file
45
src/entities/point.entity.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Group } from './group.entity';
|
||||
|
||||
@Entity('points')
|
||||
export class Point {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.points, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column({ type: 'int', comment: '积分变动值,正为增加,负为减少' })
|
||||
amount: number;
|
||||
|
||||
@Column({ length: 100, comment: '原因' })
|
||||
reason: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '详细说明' })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, comment: '关联ID(如活动ID、预约ID)' })
|
||||
relatedId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
43
src/entities/schedule.entity.ts
Normal file
43
src/entities/schedule.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Group } from './group.entity';
|
||||
|
||||
@Entity('schedules')
|
||||
export class Schedule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
groupId: string;
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'groupId' })
|
||||
group: Group;
|
||||
|
||||
@Column({
|
||||
type: 'simple-json',
|
||||
comment: '空闲时间段 JSON: { "mon": ["20:00-23:00"], ... }',
|
||||
})
|
||||
availableSlots: Record<string, string[]>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
63
src/entities/user.entity.ts
Normal file
63
src/entities/user.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserRole } from '../common/enums';
|
||||
import { GroupMember } from './group-member.entity';
|
||||
import { Appointment } from './appointment.entity';
|
||||
import { Point } from './point.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true, length: 50 })
|
||||
username: string;
|
||||
|
||||
@Column({ unique: true, nullable: true, length: 100 })
|
||||
email: string;
|
||||
|
||||
@Column({ unique: true, nullable: true, length: 20 })
|
||||
phone: string;
|
||||
|
||||
@Column({ select: false })
|
||||
password: string;
|
||||
|
||||
@Column({ nullable: true, length: 255 })
|
||||
avatar: string;
|
||||
|
||||
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
|
||||
role: UserRole;
|
||||
|
||||
@Column({ default: false, comment: '是否为会员' })
|
||||
isMember: boolean;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true, comment: '会员到期时间' })
|
||||
memberExpireAt: Date;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 50, comment: '最后登录IP' })
|
||||
lastLoginIp: string | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true, comment: '最后登录时间' })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => GroupMember, (groupMember) => groupMember.user)
|
||||
groupMembers: GroupMember[];
|
||||
|
||||
@OneToMany(() => Appointment, (appointment) => appointment.initiator)
|
||||
appointments: Appointment[];
|
||||
|
||||
@OneToMany(() => Point, (point) => point.user)
|
||||
points: Point[];
|
||||
}
|
||||
113
src/main.ts
Normal file
113
src/main.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
import { ValidationPipe } from './common/pipes/validation.pipe';
|
||||
import compression from 'compression';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: process.env.NODE_ENV === 'production'
|
||||
? ['error', 'warn', 'log']
|
||||
: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const isProduction = configService.get('app.isProduction', false);
|
||||
|
||||
// 启用压缩
|
||||
if (configService.get('performance.enableCompression', true)) {
|
||||
app.use(compression());
|
||||
}
|
||||
|
||||
// 设置全局前缀
|
||||
const apiPrefix = configService.get<string>('app.apiPrefix', 'api');
|
||||
app.setGlobalPrefix(apiPrefix);
|
||||
|
||||
// 启用 CORS
|
||||
const corsOrigin = configService.get('performance.corsOrigin', '*');
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
// 开发环境允许所有来源
|
||||
if (!isProduction) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
// 生产环境使用配置的来源
|
||||
if (!origin || corsOrigin === '*') {
|
||||
callback(null, true);
|
||||
} else {
|
||||
const allowedOrigins = corsOrigin.split(',');
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
|
||||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'Accept',
|
||||
'X-Requested-With',
|
||||
'Origin',
|
||||
'Access-Control-Request-Method',
|
||||
'Access-Control-Request-Headers',
|
||||
],
|
||||
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
maxAge: 86400,
|
||||
});
|
||||
|
||||
// 全局过滤器、拦截器、管道
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(
|
||||
new LoggingInterceptor(),
|
||||
new TransformInterceptor(),
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
|
||||
// Swagger 文档(仅在开发环境)
|
||||
if (!isProduction) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('GameGroup API')
|
||||
.setDescription('GameGroup 游戏小组管理系统 API 文档')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('auth', '认证相关')
|
||||
.addTag('users', '用户管理')
|
||||
.addTag('groups', '小组管理')
|
||||
.addTag('games', '游戏库')
|
||||
.addTag('appointments', '预约管理')
|
||||
.addTag('ledgers', '账目管理')
|
||||
.addTag('schedules', '排班管理')
|
||||
.addTag('blacklist', '黑名单')
|
||||
.addTag('honors', '荣誉墙')
|
||||
.addTag('assets', '资产管理')
|
||||
.addTag('points', '积分系统')
|
||||
.addTag('bets', '竞猜系统')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
}
|
||||
|
||||
const port = configService.get<number>('app.port', 3000);
|
||||
await app.listen(port);
|
||||
|
||||
const environment = configService.get('app.environment', 'development');
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}/${apiPrefix}`);
|
||||
console.log(`🌍 Environment: ${environment}`);
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`📚 Swagger documentation: http://localhost:${port}/docs`);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
146
src/modules/appointments/appointments.controller.ts
Normal file
146
src/modules/appointments/appointments.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import {
|
||||
CreateAppointmentDto,
|
||||
UpdateAppointmentDto,
|
||||
QueryAppointmentsDto,
|
||||
JoinAppointmentDto,
|
||||
} from './dto/appointment.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('appointments')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('appointments')
|
||||
export class AppointmentsController {
|
||||
constructor(private readonly appointmentsService: AppointmentsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建预约' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取预约列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'gameId', required: false, description: '游戏ID' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '状态' })
|
||||
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
|
||||
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryAppointmentsDto,
|
||||
) {
|
||||
return this.appointmentsService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: '获取我参与的预约' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findMyAppointments(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryAppointmentsDto,
|
||||
) {
|
||||
return this.appointmentsService.findMyAppointments(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取预约详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.findOne(id, userId);
|
||||
}
|
||||
|
||||
@Post('join')
|
||||
@ApiOperation({ summary: '加入预约' })
|
||||
@ApiResponse({ status: 200, description: '加入成功' })
|
||||
async join(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() joinDto: JoinAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.join(userId, joinDto.appointmentId);
|
||||
}
|
||||
|
||||
@Delete(':id/leave')
|
||||
@ApiOperation({ summary: '退出预约' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async leave(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.leave(userId, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新预约' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Put(':id/confirm')
|
||||
@ApiOperation({ summary: '确认预约' })
|
||||
@ApiResponse({ status: 200, description: '确认成功' })
|
||||
async confirm(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.confirm(userId, id);
|
||||
}
|
||||
|
||||
@Put(':id/complete')
|
||||
@ApiOperation({ summary: '完成预约' })
|
||||
@ApiResponse({ status: 200, description: '完成成功' })
|
||||
async complete(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.complete(userId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '取消预约' })
|
||||
@ApiResponse({ status: 200, description: '取消成功' })
|
||||
async cancel(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.cancel(userId, id);
|
||||
}
|
||||
}
|
||||
27
src/modules/appointments/appointments.module.ts
Normal file
27
src/modules/appointments/appointments.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { AppointmentsController } from './appointments.controller';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Appointment,
|
||||
AppointmentParticipant,
|
||||
Group,
|
||||
GroupMember,
|
||||
Game,
|
||||
User,
|
||||
]),
|
||||
],
|
||||
controllers: [AppointmentsController],
|
||||
providers: [AppointmentsService],
|
||||
exports: [AppointmentsService],
|
||||
})
|
||||
export class AppointmentsModule {}
|
||||
396
src/modules/appointments/appointments.service.spec.ts
Normal file
396
src/modules/appointments/appointments.service.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
enum AppointmentStatus {
|
||||
PENDING = 'pending',
|
||||
CONFIRMED = 'confirmed',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
describe('AppointmentsService', () => {
|
||||
let service: AppointmentsService;
|
||||
let mockAppointmentRepository: any;
|
||||
let mockParticipantRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
let mockGameRepository: any;
|
||||
let mockUserRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
|
||||
const mockGame = { id: 'game-1', name: '测试游戏' };
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockAppointment = {
|
||||
id: 'appointment-1',
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
creatorId: 'user-1',
|
||||
title: '周末开黑',
|
||||
description: '描述',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
endTime: new Date('2024-01-20T23:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
status: AppointmentStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockParticipant = {
|
||||
id: 'participant-1',
|
||||
appointmentId: 'appointment-1',
|
||||
userId: 'user-1',
|
||||
status: 'accepted',
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockAppointmentRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockParticipantRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
count: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
mockGameRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
clearByPrefix: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AppointmentsService,
|
||||
{
|
||||
provide: getRepositoryToken(Appointment),
|
||||
useValue: mockAppointmentRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppointmentParticipant),
|
||||
useValue: mockParticipantRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Game),
|
||||
useValue: mockGameRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: mockCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AppointmentsService>(AppointmentsService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建预约', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGameRepository.findOne.mockResolvedValue(mockGame);
|
||||
mockAppointmentRepository.create.mockReturnValue(mockAppointment);
|
||||
mockAppointmentRepository.save.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.create.mockReturnValue(mockParticipant);
|
||||
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
|
||||
mockAppointmentRepository.findOne.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
participants: [mockParticipant],
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.title).toBe('周末开黑');
|
||||
expect(mockAppointmentRepository.save).toHaveBeenCalled();
|
||||
expect(mockParticipantRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在游戏不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGameRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取预约列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockAppointment], 1]),
|
||||
};
|
||||
|
||||
mockAppointmentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取预约详情', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('appointment-1');
|
||||
});
|
||||
|
||||
it('应该在预约不存在时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('appointment-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新预约', async () => {
|
||||
mockAppointmentRepository.findOne
|
||||
.mockResolvedValueOnce(mockAppointment)
|
||||
.mockResolvedValueOnce({
|
||||
...mockAppointment,
|
||||
title: '更新后的标题',
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
});
|
||||
mockAppointmentRepository.save.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
title: '更新后的标题',
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'appointment-1', {
|
||||
title: '更新后的标题',
|
||||
});
|
||||
|
||||
expect(result.title).toBe('更新后的标题');
|
||||
});
|
||||
|
||||
it('应该在非创建者更新时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'appointment-1', { title: '新标题' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('应该成功取消预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockAppointmentRepository.save.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
status: AppointmentStatus.CANCELLED,
|
||||
});
|
||||
|
||||
const result = await service.cancel('user-1', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在非创建者取消时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.cancel('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
it('应该成功加入预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
mockParticipantRepository.count.mockResolvedValue(3);
|
||||
mockParticipantRepository.create.mockReturnValue(mockParticipant);
|
||||
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
|
||||
|
||||
const result = await service.join('user-2', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockParticipantRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在预约已满时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
mockParticipantRepository.count.mockResolvedValue(5);
|
||||
|
||||
await expect(
|
||||
service.join('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在已加入时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
|
||||
|
||||
await expect(
|
||||
service.join('user-1', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leave', () => {
|
||||
it('应该成功离开预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
|
||||
mockParticipantRepository.remove.mockResolvedValue(mockParticipant);
|
||||
|
||||
const result = await service.leave('user-1', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockParticipantRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在创建者尝试离开时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.leave('user-1', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在未加入时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.leave('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
512
src/modules/appointments/appointments.service.ts
Normal file
512
src/modules/appointments/appointments.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between, LessThan, MoreThan } from 'typeorm';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import {
|
||||
CreateAppointmentDto,
|
||||
UpdateAppointmentDto,
|
||||
QueryAppointmentsDto,
|
||||
} from './dto/appointment.dto';
|
||||
import { AppointmentStatus, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppointmentsService {
|
||||
private readonly CACHE_PREFIX = 'appointment';
|
||||
private readonly CACHE_TTL = 300; // 5分钟
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Appointment)
|
||||
private appointmentRepository: Repository<Appointment>,
|
||||
@InjectRepository(AppointmentParticipant)
|
||||
private participantRepository: Repository<AppointmentParticipant>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
@InjectRepository(Game)
|
||||
private gameRepository: Repository<Game>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建预约
|
||||
*/
|
||||
async create(userId: string, createDto: CreateAppointmentDto) {
|
||||
const { groupId, gameId, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户是否在小组中
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证游戏是否存在
|
||||
const game = await this.gameRepository.findOne({
|
||||
where: { id: gameId, isActive: true },
|
||||
});
|
||||
if (!game) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GAME_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建预约
|
||||
const appointment = this.appointmentRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
gameId,
|
||||
initiatorId: userId,
|
||||
status: AppointmentStatus.OPEN,
|
||||
});
|
||||
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
// 创建者自动加入预约
|
||||
const participant = this.participantRepository.create({
|
||||
appointmentId: appointment.id,
|
||||
userId,
|
||||
});
|
||||
await this.participantRepository.save(participant);
|
||||
|
||||
return this.findOne(appointment.id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预约列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QueryAppointmentsDto) {
|
||||
const {
|
||||
groupId,
|
||||
gameId,
|
||||
status,
|
||||
startTime,
|
||||
endTime,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.appointmentRepository
|
||||
.createQueryBuilder('appointment')
|
||||
.leftJoinAndSelect('appointment.group', 'group')
|
||||
.leftJoinAndSelect('appointment.game', 'game')
|
||||
.leftJoinAndSelect('appointment.creator', 'creator')
|
||||
.leftJoinAndSelect('appointment.participants', 'participants')
|
||||
.leftJoinAndSelect('participants.user', 'participantUser');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
queryBuilder.andWhere('appointment.groupId = :groupId', { groupId });
|
||||
}
|
||||
|
||||
if (gameId) {
|
||||
queryBuilder.andWhere('appointment.gameId = :gameId', { gameId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('appointment.status = :status', { status });
|
||||
}
|
||||
|
||||
if (startTime && endTime) {
|
||||
queryBuilder.andWhere('appointment.startTime BETWEEN :startTime AND :endTime', {
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
} else if (startTime) {
|
||||
queryBuilder.andWhere('appointment.startTime >= :startTime', { startTime });
|
||||
} else if (endTime) {
|
||||
queryBuilder.andWhere('appointment.startTime <= :endTime', { endTime });
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('appointment.startTime', 'ASC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.formatAppointment(item, userId)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我参与的预约
|
||||
*/
|
||||
async findMyAppointments(userId: string, queryDto: QueryAppointmentsDto) {
|
||||
const { status, page = 1, limit = 10 } = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.appointmentRepository
|
||||
.createQueryBuilder('appointment')
|
||||
.innerJoin('appointment.participants', 'participant', 'participant.userId = :userId', {
|
||||
userId,
|
||||
})
|
||||
.leftJoinAndSelect('appointment.group', 'group')
|
||||
.leftJoinAndSelect('appointment.game', 'game')
|
||||
.leftJoinAndSelect('appointment.creator', 'creator')
|
||||
.leftJoinAndSelect('appointment.participants', 'participants')
|
||||
.leftJoinAndSelect('participants.user', 'participantUser');
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('appointment.status = :status', { status });
|
||||
}
|
||||
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('appointment.startTime', 'ASC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.formatAppointment(item, userId)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预约详情
|
||||
*/
|
||||
async findOne(id: string, userId?: string) {
|
||||
// 先查缓存
|
||||
const cacheKey = userId ? `${id}_${userId}` : id;
|
||||
const cached = this.cacheService.get<any>(cacheKey, { prefix: this.CACHE_PREFIX });
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'game', 'creator', 'participants', 'participants.user'],
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const result = this.formatAppointment(appointment, userId);
|
||||
|
||||
// 写入缓存
|
||||
this.cacheService.set(cacheKey, result, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
ttl: this.CACHE_TTL,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入预约(使用原子更新防止并发竞态条件)
|
||||
*/
|
||||
async join(userId: string, appointmentId: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查预约状态
|
||||
if (appointment.status === AppointmentStatus.CANCELLED) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_CLOSED,
|
||||
message: '预约已取消',
|
||||
});
|
||||
}
|
||||
|
||||
if (appointment.status === AppointmentStatus.FINISHED) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_CLOSED,
|
||||
message: '预约已完成',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否在小组中
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: appointment.groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已经参与
|
||||
const existingParticipant = await this.participantRepository.findOne({
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
if (existingParticipant) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.ALREADY_JOINED,
|
||||
message: ErrorMessage[ErrorCode.ALREADY_JOINED],
|
||||
});
|
||||
}
|
||||
|
||||
// 使用原子更新:只有当当前参与人数小于最大人数时才成功
|
||||
const updateResult = await this.appointmentRepository
|
||||
.createQueryBuilder()
|
||||
.update(Appointment)
|
||||
.set({
|
||||
currentParticipants: () => 'currentParticipants + 1',
|
||||
})
|
||||
.where('id = :id', { id: appointmentId })
|
||||
.andWhere('currentParticipants < maxParticipants')
|
||||
.execute();
|
||||
|
||||
// 如果影响的行数为0,说明预约已满
|
||||
if (updateResult.affected === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_FULL,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_FULL],
|
||||
});
|
||||
}
|
||||
|
||||
// 加入预约
|
||||
const participant = this.participantRepository.create({
|
||||
appointmentId,
|
||||
userId,
|
||||
});
|
||||
await this.participantRepository.save(participant);
|
||||
|
||||
return this.findOne(appointmentId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出预约
|
||||
*/
|
||||
async leave(userId: string, appointmentId: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建者不能退出
|
||||
if (appointment.initiatorId === userId) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '创建者不能退出预约',
|
||||
});
|
||||
}
|
||||
|
||||
const participant = await this.participantRepository.findOne({
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NOT_JOINED,
|
||||
message: ErrorMessage[ErrorCode.NOT_JOINED],
|
||||
});
|
||||
}
|
||||
|
||||
await this.participantRepository.remove(participant);
|
||||
|
||||
return { message: '已退出预约' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预约
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateAppointmentDto) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
Object.assign(appointment, updateDto);
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
// 清除缓存(包括有userId和无userId的两种情况)
|
||||
this.cacheService.clearByPrefix(`${this.CACHE_PREFIX}:${id}`);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
*/
|
||||
async cancel(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
appointment.status = AppointmentStatus.CANCELLED;
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return { message: '预约已取消' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认预约
|
||||
*/
|
||||
async confirm(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['participants'],
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
// 检查是否已满员
|
||||
if (appointment.participants.length >= appointment.maxParticipants) {
|
||||
appointment.status = AppointmentStatus.FULL;
|
||||
}
|
||||
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成预约
|
||||
*/
|
||||
async complete(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
appointment.status = AppointmentStatus.FINISHED;
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
initiatorId: string,
|
||||
): Promise<void> {
|
||||
// 如果是创建者,直接通过
|
||||
if (userId === initiatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是小组管理员或组长
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化预约数据
|
||||
*/
|
||||
private formatAppointment(appointment: Appointment, userId?: string) {
|
||||
const participantCount = appointment.participants?.length || 0;
|
||||
const isParticipant = userId
|
||||
? appointment.participants?.some((p) => p.userId === userId)
|
||||
: false;
|
||||
const isCreator = userId ? appointment.initiatorId === userId : false;
|
||||
|
||||
return {
|
||||
...appointment,
|
||||
participantCount,
|
||||
isParticipant,
|
||||
isCreator,
|
||||
isFull: participantCount >= appointment.maxParticipants,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
src/modules/appointments/dto/appointment.dto.ts
Normal file
189
src/modules/appointments/dto/appointment.dto.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { AppointmentStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateAppointmentDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏ID不能为空' })
|
||||
gameId: string;
|
||||
|
||||
@ApiProperty({ description: '预约标题' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '预约描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '预约开始时间' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '预约结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '最大参与人数', example: 5 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
maxParticipants: number;
|
||||
}
|
||||
|
||||
export class UpdateAppointmentDto {
|
||||
@ApiProperty({ description: '预约标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '预约描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '预约开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '预约结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '最大参与人数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxParticipants?: number;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
|
||||
@IsEnum(AppointmentStatus)
|
||||
@IsOptional()
|
||||
status?: AppointmentStatus;
|
||||
}
|
||||
|
||||
export class JoinAppointmentDto {
|
||||
@ApiProperty({ description: '预约ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约ID不能为空' })
|
||||
appointmentId: string;
|
||||
}
|
||||
|
||||
export class QueryAppointmentsDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
gameId?: string;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
|
||||
@IsEnum(AppointmentStatus)
|
||||
@IsOptional()
|
||||
status?: AppointmentStatus;
|
||||
|
||||
@ApiProperty({ description: '开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PollOptionDto {
|
||||
@ApiProperty({ description: '选项时间' })
|
||||
@IsDateString()
|
||||
time: Date;
|
||||
|
||||
@ApiProperty({ description: '选项描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreatePollDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏ID不能为空' })
|
||||
gameId: string;
|
||||
|
||||
@ApiProperty({ description: '投票标题' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '投票标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '投票描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '投票选项', type: [PollOptionDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PollOptionDto)
|
||||
options: PollOptionDto[];
|
||||
|
||||
@ApiProperty({ description: '投票截止时间' })
|
||||
@IsDateString()
|
||||
deadline: Date;
|
||||
}
|
||||
|
||||
export class VoteDto {
|
||||
@ApiProperty({ description: '投票ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '投票ID不能为空' })
|
||||
pollId: string;
|
||||
|
||||
@ApiProperty({ description: '选项索引' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
optionIndex: number;
|
||||
}
|
||||
84
src/modules/assets/assets.controller.ts
Normal file
84
src/modules/assets/assets.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto, ReturnAssetDto } from './dto/asset.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('assets')
|
||||
@Controller('assets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建资产(管理员)' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateAssetDto) {
|
||||
return this.assetsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get('group/:groupId')
|
||||
@ApiOperation({ summary: '查询小组资产列表' })
|
||||
findAll(@Param('groupId') groupId: string) {
|
||||
return this.assetsService.findAll(groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询资产详情' })
|
||||
findOne(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.findOne(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新资产(管理员)' })
|
||||
update(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateAssetDto,
|
||||
) {
|
||||
return this.assetsService.update(user.id, id, updateDto);
|
||||
}
|
||||
|
||||
@Post(':id/borrow')
|
||||
@ApiOperation({ summary: '借用资产' })
|
||||
borrow(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() borrowDto: BorrowAssetDto,
|
||||
) {
|
||||
return this.assetsService.borrow(user.id, id, borrowDto);
|
||||
}
|
||||
|
||||
@Post(':id/return')
|
||||
@ApiOperation({ summary: '归还资产' })
|
||||
returnAsset(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() returnDto: ReturnAssetDto,
|
||||
) {
|
||||
return this.assetsService.return(user.id, id, returnDto.note);
|
||||
}
|
||||
|
||||
@Get(':id/logs')
|
||||
@ApiOperation({ summary: '查询资产借还记录' })
|
||||
getLogs(@Param('id') id: string) {
|
||||
return this.assetsService.getLogs(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除资产(管理员)' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
16
src/modules/assets/assets.module.ts
Normal file
16
src/modules/assets/assets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
exports: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
242
src/modules/assets/assets.service.spec.ts
Normal file
242
src/modules/assets/assets.service.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { AssetType, AssetStatus, GroupMemberRole, AssetLogAction } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('AssetsService', () => {
|
||||
let service: AssetsService;
|
||||
let assetRepository: Repository<Asset>;
|
||||
let assetLogRepository: Repository<AssetLog>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockAsset = {
|
||||
id: 'asset-1',
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
accountCredentials: 'encrypted-data',
|
||||
quantity: 1,
|
||||
status: AssetStatus.AVAILABLE,
|
||||
currentBorrowerId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssetsService,
|
||||
{
|
||||
provide: getRepositoryToken(Asset),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AssetLog),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssetsService>(AssetsService);
|
||||
assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset));
|
||||
assetLogRepository = module.get<Repository<AssetLog>>(getRepositoryToken(AssetLog));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建资产', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'create').mockReturnValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(groupRepository.findOne).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
expect(groupMemberRepository.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回资产列表', async () => {
|
||||
jest.spyOn(assetRepository, 'find').mockResolvedValue([mockAsset] as any);
|
||||
|
||||
const result = await service.findAll('group-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].accountCredentials).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('borrow', () => {
|
||||
it('应该成功借用资产', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue({ ...mockAsset, status: AssetStatus.IN_USE } as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.borrow('user-1', 'asset-1', borrowDto);
|
||||
|
||||
expect(result.message).toBe('借用成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
expect(assetLogRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('资产不可用时应该抛出异常', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
status: AssetStatus.IN_USE,
|
||||
} as any);
|
||||
|
||||
await expect(service.borrow('user-1', 'asset-1', borrowDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return', () => {
|
||||
it('应该成功归还资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-1',
|
||||
} as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.return('user-1', 'asset-1', '已归还');
|
||||
|
||||
expect(result.message).toBe('归还成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非借用人归还时应该抛出异常', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-2',
|
||||
} as any);
|
||||
|
||||
await expect(service.return('user-1', 'asset-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'remove').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.remove('user-1', 'asset-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(assetRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
355
src/modules/assets/assets.service.ts
Normal file
355
src/modules/assets/assets.service.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto } from './dto/asset.dto';
|
||||
import { AssetStatus, AssetLogAction, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
private readonly ENCRYPTION_KEY = process.env.ASSET_ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Asset)
|
||||
private assetRepository: Repository<Asset>,
|
||||
@InjectRepository(AssetLog)
|
||||
private assetLogRepository: Repository<AssetLog>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 加密账号凭据
|
||||
*/
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账号凭据
|
||||
*/
|
||||
private decrypt(encrypted: string): string {
|
||||
const parts = encrypted.split(':');
|
||||
const ivStr = parts.shift();
|
||||
if (!ivStr) throw new Error('Invalid encrypted data');
|
||||
const iv = Buffer.from(ivStr, 'hex');
|
||||
const encryptedText = parts.join(':');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资产
|
||||
*/
|
||||
async create(userId: string, createDto: CreateAssetDto) {
|
||||
const { groupId, accountCredentials, ...rest } = createDto;
|
||||
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const asset = this.assetRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
accountCredentials: accountCredentials ? this.encrypt(accountCredentials) : undefined,
|
||||
});
|
||||
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(asset.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询资产列表
|
||||
*/
|
||||
async findAll(groupId: string) {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['group'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return assets.map((asset) => ({
|
||||
...asset,
|
||||
accountCredentials: undefined, // 不返回加密凭据
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个资产详情(包含解密后的凭据,需管理员权限)
|
||||
*/
|
||||
async findOne(id: string, userId?: string) {
|
||||
const asset = await this.assetRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group'],
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果提供了userId,验证权限后返回解密凭据
|
||||
if (userId) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (membership && (membership.role === GroupMemberRole.ADMIN || membership.role === GroupMemberRole.OWNER)) {
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: asset.accountCredentials ? this.decrypt(asset.accountCredentials) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资产
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateDto.accountCredentials) {
|
||||
updateDto.accountCredentials = this.encrypt(updateDto.accountCredentials);
|
||||
}
|
||||
|
||||
Object.assign(asset, updateDto);
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 借用资产(使用事务和悲观锁防止并发问题)
|
||||
*/
|
||||
async borrow(userId: string, id: string, borrowDto: BorrowAssetDto) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发借用
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.status !== AssetStatus.AVAILABLE) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '资产不可用',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户在小组中
|
||||
const membership = await queryRunner.manager.findOne(GroupMember, {
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.IN_USE;
|
||||
asset.currentBorrowerId = userId;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.BORROW,
|
||||
note: borrowDto.reason,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '借用成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归还资产(使用事务确保数据一致性)
|
||||
*/
|
||||
async return(userId: string, id: string, note?: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发问题
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.currentBorrowerId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '无权归还此资产',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.AVAILABLE;
|
||||
asset.currentBorrowerId = null;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.RETURN,
|
||||
note,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '归还成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产借还记录
|
||||
*/
|
||||
async getLogs(id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await this.assetLogRepository.find({
|
||||
where: { assetId: id },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资产
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.assetRepository.remove(asset);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
84
src/modules/assets/dto/asset.dto.ts
Normal file
84
src/modules/assets/dto/asset.dto.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetType, AssetStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '资产类型', enum: AssetType })
|
||||
@IsEnum(AssetType)
|
||||
type: AssetType;
|
||||
|
||||
@ApiProperty({ description: '资产名称', example: '公用游戏账号' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据(将加密存储)', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@ApiProperty({ description: '资产名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AssetStatus, required: false })
|
||||
@IsEnum(AssetStatus)
|
||||
@IsOptional()
|
||||
status?: AssetStatus;
|
||||
}
|
||||
|
||||
export class BorrowAssetDto {
|
||||
@ApiProperty({ description: '借用理由', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ReturnAssetDto {
|
||||
@ApiProperty({ description: '归还备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
note?: string;
|
||||
}
|
||||
140
src/modules/auth/auth.controller.spec.ts
Normal file
140
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
describe('AuthController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let authService: AuthService;
|
||||
|
||||
const mockAuthService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
await app.init();
|
||||
|
||||
authService = moduleFixture.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/api/auth/register (POST)', () => {
|
||||
it('应该成功注册并返回用户信息和Token', () => {
|
||||
const registerDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.register.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send(registerDto)
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('user');
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在缺少必填字段时返回400', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
// 缺少密码
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/login (POST)', () => {
|
||||
it('应该成功登录', () => {
|
||||
const loginDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.login.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send(loginDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/refresh (POST)', () => {
|
||||
it('应该成功刷新Token', () => {
|
||||
const refreshDto = {
|
||||
refreshToken: 'valid-refresh-token',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.refreshToken.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send(refreshDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
37
src/modules/auth/auth.controller.ts
Normal file
37
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiResponse({ status: 201, description: '注册成功' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
async login(@Body() loginDto: LoginDto, @Ip() ip: string) {
|
||||
return this.authService.login(loginDto, ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
}
|
||||
30
src/modules/auth/auth.module.ts
Normal file
30
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('jwt.secret'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('jwt.expiresIn'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
312
src/modules/auth/auth.service.spec.ts
Normal file
312
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userRepository: Repository<User>;
|
||||
let jwtService: JwtService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
password: 'hashedPassword',
|
||||
role: UserRole.USER,
|
||||
isMember: false,
|
||||
memberExpiredAt: null,
|
||||
lastLoginAt: null,
|
||||
lastLoginIp: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
signAsync: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config = {
|
||||
'jwt.secret': 'test-secret',
|
||||
'jwt.accessExpiresIn': '15m',
|
||||
'jwt.refreshExpiresIn': '7d',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: mockJwtService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('应该成功注册新用户', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'new@example.com',
|
||||
phone: '13900139000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱检查
|
||||
.mockResolvedValueOnce(null); // 手机号检查
|
||||
|
||||
mockUserRepository.create.mockReturnValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
password: 'hashedPassword',
|
||||
});
|
||||
|
||||
mockUserRepository.save.mockResolvedValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.register(registerDto);
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在邮箱已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'existing@example.com',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValueOnce(mockUser);
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在手机号已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
phone: '13800138000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱不存在
|
||||
.mockResolvedValueOnce(mockUser); // 手机号已存在
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在缺少邮箱和手机号时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('应该使用用户名成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { username: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该使用邮箱成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'nonexistent',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在密码错误时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'WrongPassword',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('CorrectPassword'),
|
||||
});
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('应该成功刷新Token', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'test-user-id',
|
||||
username: 'testuser',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('new-access-token')
|
||||
.mockResolvedValueOnce('new-refresh-token');
|
||||
|
||||
const result = await service.refreshToken(refreshToken);
|
||||
|
||||
expect(result).toHaveProperty('accessToken', 'new-access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'new-refresh-token');
|
||||
expect(mockJwtService.verify).toHaveBeenCalledWith('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('应该在Token无效时抛出异常', async () => {
|
||||
const refreshToken = 'invalid-token';
|
||||
|
||||
mockJwtService.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'nonexistent-user-id',
|
||||
username: 'nonexistent',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUser', () => {
|
||||
it('应该返回用户信息(排除密码)', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.validateUser('test-user-id');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('test-user-id');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('应该在用户不存在时返回null', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateUser('nonexistent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/modules/auth/auth.service.ts
Normal file
233
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException, HttpException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(registerDto: RegisterDto) {
|
||||
const { username, password, email, phone } = registerDto;
|
||||
|
||||
// 验证邮箱和手机号至少有一个
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: '邮箱和手机号至少填写一个',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [
|
||||
{ username },
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phone }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.username === username) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '用户名已存在',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (email && existingUser.email === email) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '邮箱已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (phone && existingUser.phone === phone) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '手机号已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await CryptoUtil.hashPassword(password);
|
||||
|
||||
// 创建用户
|
||||
const user = this.userRepository.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email,
|
||||
phone,
|
||||
});
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
isMember: user.isMember,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(loginDto: LoginDto, ip?: string) {
|
||||
const { account, password } = loginDto;
|
||||
|
||||
// 查找用户(支持用户名、邮箱、手机号登录)
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.username = :account', { account })
|
||||
.orWhere('user.email = :account', { account })
|
||||
.orWhere('user.phone = :account', { account })
|
||||
.addSelect('user.password')
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await CryptoUtil.comparePassword(
|
||||
password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.PASSWORD_ERROR,
|
||||
message: ErrorMessage[ErrorCode.PASSWORD_ERROR],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
user.lastLoginIp = ip || null;
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
isMember: user.isMember,
|
||||
memberExpireAt: user.memberExpireAt,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
});
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return this.generateTokens(user);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.TOKEN_INVALID,
|
||||
message: ErrorMessage[ErrorCode.TOKEN_INVALID],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户
|
||||
*/
|
||||
async validateUser(userId: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 access token 和 refresh token
|
||||
*/
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.secret'),
|
||||
expiresIn: this.configService.get('jwt.expiresIn'),
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
45
src/modules/auth/dto/auth.dto.ts
Normal file
45
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '用户名', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '用户名不能为空' })
|
||||
@MinLength(3, { message: '用户名至少3个字符' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
@MinLength(6, { message: '密码至少6个字符' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: '邮箱', example: 'john@example.com', required: false })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800138000', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '用户名/邮箱/手机号', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '账号不能为空' })
|
||||
account: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refreshToken: string;
|
||||
}
|
||||
33
src/modules/auth/jwt.strategy.ts
Normal file
33
src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret') || 'default-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.UNAUTHORIZED,
|
||||
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
49
src/modules/bets/bets.controller.ts
Normal file
49
src/modules/bets/bets.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BetsService } from './bets.service';
|
||||
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('bets')
|
||||
@Controller('bets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class BetsController {
|
||||
constructor(private readonly betsService: BetsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建竞猜下注' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateBetDto) {
|
||||
return this.betsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get('appointment/:appointmentId')
|
||||
@ApiOperation({ summary: '查询预约的所有竞猜' })
|
||||
findAll(@Param('appointmentId') appointmentId: string) {
|
||||
return this.betsService.findAll(appointmentId);
|
||||
}
|
||||
|
||||
@Post('appointment/:appointmentId/settle')
|
||||
@ApiOperation({ summary: '结算竞猜(管理员)' })
|
||||
settle(
|
||||
@CurrentUser() user,
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Body() settleDto: SettleBetDto,
|
||||
) {
|
||||
return this.betsService.settle(user.id, appointmentId, settleDto);
|
||||
}
|
||||
|
||||
@Post('appointment/:appointmentId/cancel')
|
||||
@ApiOperation({ summary: '取消竞猜' })
|
||||
cancel(@Param('appointmentId') appointmentId: string) {
|
||||
return this.betsService.cancel(appointmentId);
|
||||
}
|
||||
}
|
||||
16
src/modules/bets/bets.module.ts
Normal file
16
src/modules/bets/bets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BetsController } from './bets.controller';
|
||||
import { BetsService } from './bets.service';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Bet, Appointment, Point, GroupMember])],
|
||||
controllers: [BetsController],
|
||||
providers: [BetsService],
|
||||
exports: [BetsService],
|
||||
})
|
||||
export class BetsModule {}
|
||||
283
src/modules/bets/bets.service.spec.ts
Normal file
283
src/modules/bets/bets.service.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { BetsService } from './bets.service';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
|
||||
import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('BetsService', () => {
|
||||
let service: BetsService;
|
||||
let betRepository: Repository<Bet>;
|
||||
let appointmentRepository: Repository<Appointment>;
|
||||
let pointRepository: Repository<Point>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockAppointment = {
|
||||
id: 'appointment-1',
|
||||
groupId: 'group-1',
|
||||
title: '测试预约',
|
||||
status: AppointmentStatus.PENDING,
|
||||
};
|
||||
|
||||
const mockBet = {
|
||||
id: 'bet-1',
|
||||
appointmentId: 'appointment-1',
|
||||
userId: 'user-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
status: BetStatus.PENDING,
|
||||
winAmount: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BetsService,
|
||||
{
|
||||
provide: getRepositoryToken(Bet),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Appointment),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Point),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BetsService>(BetsService);
|
||||
betRepository = module.get<Repository<Bet>>(getRepositoryToken(Bet));
|
||||
appointmentRepository = module.get<Repository<Appointment>>(getRepositoryToken(Appointment));
|
||||
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建竞猜下注', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
jest.spyOn(betRepository, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(betRepository, 'create').mockReturnValue(mockBet as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue(mockBet as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(betRepository.save).toHaveBeenCalled();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('预约不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('预约已结束时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue({
|
||||
...mockAppointment,
|
||||
status: AppointmentStatus.FINISHED,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('积分不足时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 100,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '50' });
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('重复下注时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
jest.spyOn(betRepository, 'findOne').mockResolvedValue(mockBet as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回竞猜列表及统计', async () => {
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 10 },
|
||||
{ ...mockBet, id: 'bet-2', betOption: '胜', amount: 20 },
|
||||
{ ...mockBet, id: 'bet-3', betOption: '负', amount: 15 },
|
||||
];
|
||||
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
|
||||
const result = await service.findAll('appointment-1');
|
||||
|
||||
expect(result.bets).toHaveLength(3);
|
||||
expect(result.totalBets).toBe(3);
|
||||
expect(result.totalAmount).toBe(45);
|
||||
expect(result.stats['胜']).toBeDefined();
|
||||
expect(result.stats['胜'].count).toBe(2);
|
||||
expect(result.stats['胜'].totalAmount).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settle', () => {
|
||||
it('应该成功结算竞猜', async () => {
|
||||
const settleDto = { winningOption: '胜' };
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 30 },
|
||||
{ ...mockBet, id: 'bet-2', betOption: '负', amount: 20 },
|
||||
];
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.settle('user-1', 'appointment-1', settleDto);
|
||||
|
||||
expect(result.message).toBe('结算成功');
|
||||
expect(result.winners).toBe(1);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const settleDto = { winningOption: '胜' };
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('没有人下注该选项时应该抛出异常', async () => {
|
||||
const settleDto = { winningOption: '平' };
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 30 },
|
||||
];
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
|
||||
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('应该成功取消竞猜并退还积分', async () => {
|
||||
const bets = [
|
||||
{ ...mockBet, status: BetStatus.PENDING, appointment: mockAppointment },
|
||||
];
|
||||
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.cancel('appointment-1');
|
||||
|
||||
expect(result.message).toBe('竞猜已取消,积分已退还');
|
||||
expect(betRepository.save).toHaveBeenCalled();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
302
src/modules/bets/bets.service.ts
Normal file
302
src/modules/bets/bets.service.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
|
||||
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BetsService {
|
||||
constructor(
|
||||
@InjectRepository(Bet)
|
||||
private betRepository: Repository<Bet>,
|
||||
@InjectRepository(Appointment)
|
||||
private appointmentRepository: Repository<Appointment>,
|
||||
@InjectRepository(Point)
|
||||
private pointRepository: Repository<Point>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建竞猜下注
|
||||
*/
|
||||
async create(userId: string, createDto: CreateBetDto) {
|
||||
const { appointmentId, amount, betOption } = createDto;
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 验证预约存在
|
||||
const appointment = await queryRunner.manager.findOne(Appointment, {
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证预约状态
|
||||
if (appointment.status !== AppointmentStatus.PENDING) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '预约已结束,无法下注',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户积分是否足够
|
||||
const balance = await queryRunner.manager
|
||||
.createQueryBuilder(Point, 'point')
|
||||
.select('SUM(point.amount)', 'total')
|
||||
.where('point.userId = :userId', { userId })
|
||||
.andWhere('point.groupId = :groupId', { groupId: appointment.groupId })
|
||||
.getRawOne();
|
||||
|
||||
const currentBalance = parseInt(balance.total || '0');
|
||||
if (currentBalance < amount) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INSUFFICIENT_POINTS,
|
||||
message: '积分不足',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已下注
|
||||
const existingBet = await queryRunner.manager.findOne(Bet, {
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
|
||||
if (existingBet) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '已下注,不能重复下注',
|
||||
});
|
||||
}
|
||||
|
||||
const bet = queryRunner.manager.create(Bet, {
|
||||
appointmentId,
|
||||
userId,
|
||||
betOption,
|
||||
amount,
|
||||
});
|
||||
|
||||
const savedBet = await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 扣除积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: -amount,
|
||||
reason: '竞猜下注',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: savedBet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return savedBet;
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询预约的所有竞猜
|
||||
*/
|
||||
async findAll(appointmentId: string) {
|
||||
const bets = await this.betRepository.find({
|
||||
where: { appointmentId },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// 统计各选项的下注情况
|
||||
const stats = bets.reduce((acc, bet) => {
|
||||
if (!acc[bet.betOption]) {
|
||||
acc[bet.betOption] = { count: 0, totalAmount: 0 };
|
||||
}
|
||||
acc[bet.betOption].count++;
|
||||
acc[bet.betOption].totalAmount += bet.amount;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
bets,
|
||||
stats,
|
||||
totalBets: bets.length,
|
||||
totalAmount: bets.reduce((sum, bet) => sum + bet.amount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算竞猜(管理员)
|
||||
*/
|
||||
async settle(userId: string, appointmentId: string, settleDto: SettleBetDto) {
|
||||
const { winningOption } = settleDto;
|
||||
|
||||
// 验证预约存在
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: appointment.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 获取所有下注
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
});
|
||||
|
||||
// 计算总奖池和赢家总下注
|
||||
const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
const winningBets = bets.filter((bet) => bet.betOption === winningOption);
|
||||
const winningTotal = winningBets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
|
||||
if (winningTotal === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '没有人下注该选项',
|
||||
});
|
||||
}
|
||||
|
||||
// 按比例分配奖池,修复精度损失问题
|
||||
let distributedAmount = 0;
|
||||
|
||||
for (let i = 0; i < winningBets.length; i++) {
|
||||
const bet = winningBets[i];
|
||||
let winAmount: number;
|
||||
|
||||
if (i === winningBets.length - 1) {
|
||||
// 最后一个赢家获得剩余所有积分,避免精度损失
|
||||
winAmount = totalPool - distributedAmount;
|
||||
} else {
|
||||
winAmount = Math.floor((bet.amount / winningTotal) * totalPool);
|
||||
distributedAmount += winAmount;
|
||||
}
|
||||
|
||||
bet.winAmount = winAmount;
|
||||
bet.status = BetStatus.WON;
|
||||
|
||||
// 返还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: winAmount,
|
||||
reason: '竞猜获胜',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
|
||||
// 更新输家状态
|
||||
for (const bet of bets) {
|
||||
if (bet.betOption !== winningOption) {
|
||||
bet.status = BetStatus.LOST;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
message: '结算成功',
|
||||
winningOption,
|
||||
totalPool,
|
||||
winners: winningBets.length,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消竞猜(预约取消时)
|
||||
*/
|
||||
async cancel(appointmentId: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
relations: ['appointment'],
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
if (bet.status === BetStatus.PENDING) {
|
||||
bet.status = BetStatus.CANCELLED;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 退还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: bet.appointment.groupId,
|
||||
amount: bet.amount,
|
||||
reason: '竞猜取消退款',
|
||||
description: `预约: ${bet.appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '竞猜已取消,积分已退还' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/modules/bets/dto/bet.dto.ts
Normal file
31
src/modules/bets/dto/bet.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateBetDto {
|
||||
@ApiProperty({ description: '预约ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约ID不能为空' })
|
||||
appointmentId: string;
|
||||
|
||||
@ApiProperty({ description: '下注选项', example: '胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '下注选项不能为空' })
|
||||
betOption: string;
|
||||
|
||||
@ApiProperty({ description: '下注积分', example: 10 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class SettleBetDto {
|
||||
@ApiProperty({ description: '胜利选项', example: '胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '胜利选项不能为空' })
|
||||
winningOption: string;
|
||||
}
|
||||
68
src/modules/blacklist/blacklist.controller.ts
Normal file
68
src/modules/blacklist/blacklist.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import {
|
||||
CreateBlacklistDto,
|
||||
ReviewBlacklistDto,
|
||||
QueryBlacklistDto,
|
||||
} from './dto/blacklist.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('blacklist')
|
||||
@Controller('blacklist')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class BlacklistController {
|
||||
constructor(private readonly blacklistService: BlacklistService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '提交黑名单举报' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateBlacklistDto) {
|
||||
return this.blacklistService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询黑名单列表' })
|
||||
findAll(@Query() query: QueryBlacklistDto) {
|
||||
return this.blacklistService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('check/:targetGameId')
|
||||
@ApiOperation({ summary: '检查游戏ID是否在黑名单中' })
|
||||
checkBlacklist(@Param('targetGameId') targetGameId: string) {
|
||||
return this.blacklistService.checkBlacklist(targetGameId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询单个黑名单记录' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.blacklistService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id/review')
|
||||
@ApiOperation({ summary: '审核黑名单(管理员)' })
|
||||
review(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() reviewDto: ReviewBlacklistDto,
|
||||
) {
|
||||
return this.blacklistService.review(user.id, id, reviewDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除黑名单记录' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.blacklistService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
14
src/modules/blacklist/blacklist.module.ts
Normal file
14
src/modules/blacklist/blacklist.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlacklistController } from './blacklist.controller';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Blacklist, User])],
|
||||
controllers: [BlacklistController],
|
||||
providers: [BlacklistService],
|
||||
exports: [BlacklistService],
|
||||
})
|
||||
export class BlacklistModule {}
|
||||
272
src/modules/blacklist/blacklist.service.spec.ts
Normal file
272
src/modules/blacklist/blacklist.service.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { BlacklistStatus } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('BlacklistService', () => {
|
||||
let service: BlacklistService;
|
||||
let blacklistRepository: Repository<Blacklist>;
|
||||
let userRepository: Repository<User>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockBlacklist = {
|
||||
id: 'blacklist-1',
|
||||
reporterId: 'user-1',
|
||||
targetGameId: 'game-123',
|
||||
targetNickname: '违规玩家',
|
||||
reason: '恶意行为',
|
||||
proofImages: ['image1.jpg'],
|
||||
status: BlacklistStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: '举报人',
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BlacklistService,
|
||||
{
|
||||
provide: getRepositoryToken(Blacklist),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BlacklistService>(BlacklistService);
|
||||
blacklistRepository = module.get<Repository<Blacklist>>(getRepositoryToken(Blacklist));
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建黑名单举报', async () => {
|
||||
const createDto = {
|
||||
targetGameId: 'game-123',
|
||||
targetNickname: '违规玩家',
|
||||
reason: '恶意行为',
|
||||
proofImages: ['image1.jpg'],
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'create').mockReturnValue(mockBlacklist as any);
|
||||
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(mockBlacklist as any);
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(blacklistRepository.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
reporterId: 'user-1',
|
||||
status: BlacklistStatus.PENDING,
|
||||
});
|
||||
expect(blacklistRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回黑名单列表', async () => {
|
||||
const query = { status: BlacklistStatus.APPROVED };
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
|
||||
|
||||
const result = await service.findAll(query);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(blacklistRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该支持按状态筛选', async () => {
|
||||
const query = { status: BlacklistStatus.PENDING };
|
||||
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
|
||||
|
||||
await service.findAll(query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'blacklist.status = :status',
|
||||
{ status: BlacklistStatus.PENDING }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回单个黑名单记录', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.findOne('blacklist-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('blacklist-1');
|
||||
});
|
||||
|
||||
it('记录不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review', () => {
|
||||
it('应该成功审核黑名单(会员权限)', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
reviewNote: '确认违规',
|
||||
};
|
||||
|
||||
const updatedBlacklist = {
|
||||
...mockBlacklist,
|
||||
...reviewDto,
|
||||
reviewerId: 'user-1',
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'findOne')
|
||||
.mockResolvedValueOnce(mockBlacklist as any) // First call in review method
|
||||
.mockResolvedValueOnce(updatedBlacklist as any); // Second call in findOne at the end
|
||||
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(updatedBlacklist as any);
|
||||
|
||||
const result = await service.review('user-1', 'blacklist-1', reviewDto);
|
||||
|
||||
expect(result.status).toBe(BlacklistStatus.APPROVED);
|
||||
expect(blacklistRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非会员审核时应该抛出异常', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
|
||||
...mockUser,
|
||||
isMember: false,
|
||||
} as any);
|
||||
|
||||
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('用户不存在时应该抛出异常', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBlacklist', () => {
|
||||
it('应该正确检查玩家是否在黑名单', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
status: BlacklistStatus.APPROVED,
|
||||
} as any);
|
||||
|
||||
const result = await service.checkBlacklist('game-123');
|
||||
|
||||
expect(result.isBlacklisted).toBe(true);
|
||||
expect(result.blacklist).toBeDefined();
|
||||
expect(blacklistRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
targetGameId: 'game-123',
|
||||
status: BlacklistStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('玩家不在黑名单时应该返回false', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const result = await service.checkBlacklist('game-123');
|
||||
|
||||
expect(result.isBlacklisted).toBe(false);
|
||||
expect(result.blacklist).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('举报人应该可以删除自己的举报', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.remove('user-1', 'blacklist-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(blacklistRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('会员应该可以删除任何举报', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
reporterId: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.remove('user-1', 'blacklist-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
});
|
||||
|
||||
it('非举报人且非会员删除时应该抛出异常', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
reporterId: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
|
||||
...mockUser,
|
||||
isMember: false,
|
||||
} as any);
|
||||
|
||||
await expect(service.remove('user-1', 'blacklist-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
src/modules/blacklist/blacklist.service.ts
Normal file
175
src/modules/blacklist/blacklist.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import {
|
||||
CreateBlacklistDto,
|
||||
ReviewBlacklistDto,
|
||||
QueryBlacklistDto,
|
||||
} from './dto/blacklist.dto';
|
||||
import { BlacklistStatus } from '../../common/enums';
|
||||
import {
|
||||
ErrorCode,
|
||||
ErrorMessage,
|
||||
} from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BlacklistService {
|
||||
constructor(
|
||||
@InjectRepository(Blacklist)
|
||||
private blacklistRepository: Repository<Blacklist>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 提交黑名单举报
|
||||
*/
|
||||
async create(userId: string, createDto: CreateBlacklistDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = this.blacklistRepository.create({
|
||||
...createDto,
|
||||
reporterId: userId,
|
||||
status: BlacklistStatus.PENDING,
|
||||
});
|
||||
|
||||
await this.blacklistRepository.save(blacklist);
|
||||
|
||||
return this.findOne(blacklist.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询黑名单列表
|
||||
*/
|
||||
async findAll(query: QueryBlacklistDto) {
|
||||
const qb = this.blacklistRepository
|
||||
.createQueryBuilder('blacklist')
|
||||
.leftJoinAndSelect('blacklist.reporter', 'reporter')
|
||||
.leftJoinAndSelect('blacklist.reviewer', 'reviewer');
|
||||
|
||||
if (query.targetGameId) {
|
||||
qb.andWhere('blacklist.targetGameId LIKE :targetGameId', {
|
||||
targetGameId: `%${query.targetGameId}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('blacklist.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
qb.orderBy('blacklist.createdAt', 'DESC');
|
||||
|
||||
const blacklists = await qb.getMany();
|
||||
|
||||
return blacklists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个黑名单记录
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const blacklist = await this.blacklistRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['reporter', 'reviewer'],
|
||||
});
|
||||
|
||||
if (!blacklist) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.BLACKLIST_NOT_FOUND,
|
||||
message: '黑名单记录不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return blacklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核黑名单(管理员权限)
|
||||
*/
|
||||
async review(userId: string, id: string, reviewDto: ReviewBlacklistDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user || !user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要会员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = await this.findOne(id);
|
||||
|
||||
if (blacklist.status !== BlacklistStatus.PENDING) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '该记录已审核',
|
||||
});
|
||||
}
|
||||
|
||||
blacklist.status = reviewDto.status;
|
||||
if (reviewDto.reviewNote) {
|
||||
blacklist.reviewNote = reviewDto.reviewNote;
|
||||
}
|
||||
blacklist.reviewerId = userId;
|
||||
|
||||
await this.blacklistRepository.save(blacklist);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查游戏ID是否在黑名单中
|
||||
*/
|
||||
async checkBlacklist(targetGameId: string) {
|
||||
const blacklist = await this.blacklistRepository.findOne({
|
||||
where: {
|
||||
targetGameId,
|
||||
status: BlacklistStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isBlacklisted: !!blacklist,
|
||||
blacklist: blacklist || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除黑名单记录(仅举报人或管理员)
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = await this.findOne(id);
|
||||
|
||||
if (blacklist.reporterId !== userId && !user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.blacklistRepository.remove(blacklist);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
59
src/modules/blacklist/dto/blacklist.dto.ts
Normal file
59
src/modules/blacklist/dto/blacklist.dto.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BlacklistStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateBlacklistDto {
|
||||
@ApiProperty({ description: '目标游戏ID或用户名', example: 'PlayerXXX#1234' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '目标游戏ID不能为空' })
|
||||
@MaxLength(100)
|
||||
targetGameId: string;
|
||||
|
||||
@ApiProperty({ description: '举报原因' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '举报原因不能为空' })
|
||||
reason: string;
|
||||
|
||||
@ApiProperty({ description: '证据图片URL列表', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
proofImages?: string[];
|
||||
}
|
||||
|
||||
export class ReviewBlacklistDto {
|
||||
@ApiProperty({
|
||||
description: '审核状态',
|
||||
enum: BlacklistStatus,
|
||||
example: BlacklistStatus.APPROVED,
|
||||
})
|
||||
@IsEnum(BlacklistStatus)
|
||||
status: BlacklistStatus;
|
||||
|
||||
@ApiProperty({ description: '审核意见', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reviewNote?: string;
|
||||
}
|
||||
|
||||
export class QueryBlacklistDto {
|
||||
@ApiProperty({ description: '目标游戏ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
targetGameId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态',
|
||||
enum: BlacklistStatus,
|
||||
required: false,
|
||||
})
|
||||
@IsEnum(BlacklistStatus)
|
||||
@IsOptional()
|
||||
status?: BlacklistStatus;
|
||||
}
|
||||
117
src/modules/games/dto/game.dto.ts
Normal file
117
src/modules/games/dto/game.dto.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, IsArray } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateGameDto {
|
||||
@ApiProperty({ description: '游戏名称', example: '王者荣耀' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '游戏封面URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverUrl?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '最大玩家数', example: 5 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
maxPlayers: number;
|
||||
|
||||
@ApiProperty({ description: '最小玩家数', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', example: 'PC/iOS/Android', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', example: ['MOBA', '5v5'], required: false, type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class UpdateGameDto {
|
||||
@ApiProperty({ description: '游戏名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏封面URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverUrl?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '最大玩家数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '最小玩家数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', required: false, type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class SearchGameDto {
|
||||
@ApiProperty({ description: '搜索关键词', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyword?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tag?: string;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
95
src/modules/games/games.controller.ts
Normal file
95
src/modules/games/games.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { GamesService } from './games.service';
|
||||
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('games')
|
||||
@Controller('games')
|
||||
export class GamesController {
|
||||
constructor(private readonly gamesService: GamesService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取游戏列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'keyword', required: false, description: '搜索关键词' })
|
||||
@ApiQuery({ name: 'platform', required: false, description: '游戏平台' })
|
||||
@ApiQuery({ name: 'tag', required: false, description: '游戏标签' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(@Query() searchDto: SearchGameDto) {
|
||||
return this.gamesService.findAll(searchDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('popular')
|
||||
@ApiOperation({ summary: '获取热门游戏' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '数量限制' })
|
||||
async findPopular(@Query('limit') limit?: number) {
|
||||
return this.gamesService.findPopular(limit);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: '获取所有游戏标签' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getTags() {
|
||||
return this.gamesService.getTags();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('platforms')
|
||||
@ApiOperation({ summary: '获取所有游戏平台' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getPlatforms() {
|
||||
return this.gamesService.getPlatforms();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取游戏详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.gamesService.findOne(id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建游戏' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(@Body() createGameDto: CreateGameDto) {
|
||||
return this.gamesService.create(createGameDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新游戏信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(@Param('id') id: string, @Body() updateGameDto: UpdateGameDto) {
|
||||
return this.gamesService.update(id, updateGameDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除游戏' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.gamesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
src/modules/games/games.module.ts
Normal file
13
src/modules/games/games.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GamesService } from './games.service';
|
||||
import { GamesController } from './games.controller';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Game])],
|
||||
controllers: [GamesController],
|
||||
providers: [GamesService],
|
||||
exports: [GamesService],
|
||||
})
|
||||
export class GamesModule {}
|
||||
301
src/modules/games/games.service.spec.ts
Normal file
301
src/modules/games/games.service.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { GamesService } from './games.service';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
|
||||
describe('GamesService', () => {
|
||||
let service: GamesService;
|
||||
let repository: Repository<Game>;
|
||||
|
||||
const mockGame = {
|
||||
id: 'game-id-1',
|
||||
name: '王者荣耀',
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
description: '5v5竞技游戏',
|
||||
maxPlayers: 10,
|
||||
minPlayers: 1,
|
||||
platform: 'iOS/Android',
|
||||
tags: ['MOBA', '5v5'],
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GamesService,
|
||||
{
|
||||
provide: getRepositoryToken(Game),
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GamesService>(GamesService);
|
||||
repository = module.get<Repository<Game>>(getRepositoryToken(Game));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建游戏', async () => {
|
||||
const createDto = {
|
||||
name: '原神',
|
||||
coverUrl: 'https://example.com/genshin.jpg',
|
||||
description: '开放世界冒险游戏',
|
||||
maxPlayers: 4,
|
||||
minPlayers: 1,
|
||||
platform: 'PC/iOS/Android',
|
||||
tags: ['RPG', '开放世界'],
|
||||
};
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(null); // 游戏名称不存在
|
||||
mockRepository.create.mockReturnValue({ ...createDto, id: 'new-game-id' });
|
||||
mockRepository.save.mockResolvedValue({ ...createDto, id: 'new-game-id' });
|
||||
|
||||
const result = await service.create(createDto);
|
||||
|
||||
expect(result).toHaveProperty('id', 'new-game-id');
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { name: createDto.name },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在游戏名称已存在时抛出异常', async () => {
|
||||
const createDto = {
|
||||
name: '王者荣耀',
|
||||
maxPlayers: 10,
|
||||
};
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
|
||||
await expect(service.create(createDto as any)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回游戏列表', async () => {
|
||||
const searchDto = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.findAll(searchDto);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('应该支持关键词搜索', async () => {
|
||||
const searchDto = {
|
||||
keyword: '王者',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.findAll(searchDto);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该支持平台筛选', async () => {
|
||||
const searchDto = {
|
||||
platform: 'iOS',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
await service.findAll(searchDto);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回游戏详情', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
|
||||
const result = await service.findOne('game-id-1');
|
||||
|
||||
expect(result).toEqual(mockGame);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'game-id-1', isActive: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在游戏不存在时抛出异常', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新游戏', async () => {
|
||||
const updateDto = {
|
||||
description: '更新后的描述',
|
||||
maxPlayers: 12,
|
||||
};
|
||||
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(mockGame) // findOne调用
|
||||
.mockResolvedValueOnce(null); // 名称检查
|
||||
|
||||
mockRepository.save.mockResolvedValue({
|
||||
...mockGame,
|
||||
...updateDto,
|
||||
});
|
||||
|
||||
const result = await service.update('game-id-1', updateDto);
|
||||
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.maxPlayers).toBe(updateDto.maxPlayers);
|
||||
});
|
||||
|
||||
it('应该在更新名称时检查重名', async () => {
|
||||
const updateDto = {
|
||||
name: '已存在的游戏名',
|
||||
};
|
||||
|
||||
const anotherGame = {
|
||||
...mockGame,
|
||||
id: 'another-game-id',
|
||||
name: '已存在的游戏名',
|
||||
};
|
||||
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(mockGame) // 第一次:获取要更新的游戏
|
||||
.mockResolvedValueOnce(anotherGame); // 第二次:检查名称是否存在
|
||||
|
||||
await expect(
|
||||
service.update('game-id-1', updateDto),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该软删除游戏', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
mockRepository.save.mockResolvedValue({
|
||||
...mockGame,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = await service.remove('game-id-1');
|
||||
|
||||
expect(result).toHaveProperty('message', '游戏已删除');
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isActive: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPopular', () => {
|
||||
it('应该返回热门游戏列表', async () => {
|
||||
mockRepository.find.mockResolvedValue([mockGame]);
|
||||
|
||||
const result = await service.findPopular(5);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: { isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTags', () => {
|
||||
it('应该返回所有游戏标签', async () => {
|
||||
const games = [
|
||||
{ ...mockGame, tags: ['MOBA', '5v5'] },
|
||||
{ ...mockGame, tags: ['FPS', 'RPG'] },
|
||||
];
|
||||
|
||||
mockRepository.find.mockResolvedValue(games);
|
||||
|
||||
const result = await service.getTags();
|
||||
|
||||
expect(result).toContain('MOBA');
|
||||
expect(result).toContain('FPS');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatforms', () => {
|
||||
it('应该返回所有游戏平台', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ platform: 'iOS/Android' },
|
||||
{ platform: 'PC' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getPlatforms();
|
||||
|
||||
expect(result).toContain('iOS/Android');
|
||||
expect(result).toContain('PC');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/modules/games/games.service.ts
Normal file
190
src/modules/games/games.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like } from 'typeorm';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
@Injectable()
|
||||
export class GamesService {
|
||||
constructor(
|
||||
@InjectRepository(Game)
|
||||
private gameRepository: Repository<Game>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建游戏
|
||||
*/
|
||||
async create(createGameDto: CreateGameDto) {
|
||||
// 检查游戏名称是否已存在
|
||||
const existingGame = await this.gameRepository.findOne({
|
||||
where: { name: createGameDto.name },
|
||||
});
|
||||
|
||||
if (existingGame) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GAME_EXISTS,
|
||||
message: ErrorMessage[ErrorCode.GAME_EXISTS],
|
||||
});
|
||||
}
|
||||
|
||||
const game = this.gameRepository.create({
|
||||
...createGameDto,
|
||||
minPlayers: createGameDto.minPlayers || 1,
|
||||
});
|
||||
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏列表
|
||||
*/
|
||||
async findAll(searchDto: SearchGameDto) {
|
||||
const { keyword, platform, tag, page = 1, limit = 10 } = searchDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.gameRepository
|
||||
.createQueryBuilder('game')
|
||||
.where('game.isActive = :isActive', { isActive: true });
|
||||
|
||||
// 关键词搜索(游戏名称和描述)
|
||||
if (keyword) {
|
||||
queryBuilder.andWhere(
|
||||
'(game.name LIKE :keyword OR game.description LIKE :keyword)',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}
|
||||
|
||||
// 平台筛选
|
||||
if (platform) {
|
||||
queryBuilder.andWhere('game.platform LIKE :platform', {
|
||||
platform: `%${platform}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (tag) {
|
||||
queryBuilder.andWhere('game.tags LIKE :tag', { tag: `%${tag}%` });
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('game.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const game = await this.gameRepository.findOne({
|
||||
where: { id, isActive: true },
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GAME_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新游戏信息
|
||||
*/
|
||||
async update(id: string, updateGameDto: UpdateGameDto) {
|
||||
const game = await this.findOne(id);
|
||||
|
||||
// 如果要修改游戏名称,检查是否与其他游戏重名
|
||||
if (updateGameDto.name && updateGameDto.name !== game.name) {
|
||||
const existingGame = await this.gameRepository.findOne({
|
||||
where: { name: updateGameDto.name },
|
||||
});
|
||||
|
||||
if (existingGame) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GAME_EXISTS,
|
||||
message: '游戏名称已存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(game, updateGameDto);
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除游戏(软删除)
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const game = await this.findOne(id);
|
||||
|
||||
game.isActive = false;
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return { message: '游戏已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门游戏(可根据实际需求调整排序逻辑)
|
||||
*/
|
||||
async findPopular(limit: number = 10) {
|
||||
const games = await this.gameRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有游戏标签
|
||||
*/
|
||||
async getTags() {
|
||||
const games = await this.gameRepository.find({
|
||||
where: { isActive: true },
|
||||
select: ['tags'],
|
||||
});
|
||||
|
||||
const tagsSet = new Set<string>();
|
||||
games.forEach((game) => {
|
||||
if (game.tags && game.tags.length > 0) {
|
||||
game.tags.forEach((tag) => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tagsSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有游戏平台
|
||||
*/
|
||||
async getPlatforms() {
|
||||
const games = await this.gameRepository
|
||||
.createQueryBuilder('game')
|
||||
.select('DISTINCT game.platform', 'platform')
|
||||
.where('game.isActive = :isActive', { isActive: true })
|
||||
.andWhere('game.platform IS NOT NULL')
|
||||
.getRawMany();
|
||||
|
||||
return games.map((item) => item.platform);
|
||||
}
|
||||
}
|
||||
99
src/modules/groups/dto/group.dto.ts
Normal file
99
src/modules/groups/dto/group.dto.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateGroupDto {
|
||||
@ApiProperty({ description: '小组名称', example: '王者荣耀固定队' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '小组描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '小组头像', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ description: '小组类型', example: 'normal', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
type?: string;
|
||||
|
||||
@ApiProperty({ description: '父组ID(创建子组时使用)', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
parentId?: string;
|
||||
|
||||
@ApiProperty({ description: '最大成员数', example: 50, required: false })
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(500)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxMembers?: number;
|
||||
}
|
||||
|
||||
export class UpdateGroupDto {
|
||||
@ApiProperty({ description: '小组名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '小组描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '小组头像', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ description: '公示信息', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
announcement?: string;
|
||||
|
||||
@ApiProperty({ description: '最大成员数', required: false })
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(500)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxMembers?: number;
|
||||
}
|
||||
|
||||
export class JoinGroupDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '组内昵称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class UpdateMemberRoleDto {
|
||||
@ApiProperty({ description: '成员ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '成员ID不能为空' })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '角色', example: 'admin', enum: ['owner', 'admin', 'member'] })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '角色不能为空' })
|
||||
role: string;
|
||||
}
|
||||
|
||||
export class KickMemberDto {
|
||||
@ApiProperty({ description: '成员ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '成员ID不能为空' })
|
||||
userId: string;
|
||||
}
|
||||
110
src/modules/groups/groups.controller.ts
Normal file
110
src/modules/groups/groups.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GroupsService } from './groups.service';
|
||||
import {
|
||||
CreateGroupDto,
|
||||
UpdateGroupDto,
|
||||
JoinGroupDto,
|
||||
UpdateMemberRoleDto,
|
||||
KickMemberDto,
|
||||
} from './dto/group.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@ApiTags('groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('groups')
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(@CurrentUser() user: User, @Body() createGroupDto: CreateGroupDto) {
|
||||
return this.groupsService.create(user.id, createGroupDto);
|
||||
}
|
||||
|
||||
@Post('join')
|
||||
@ApiOperation({ summary: '加入小组' })
|
||||
@ApiResponse({ status: 200, description: '加入成功' })
|
||||
async join(@CurrentUser() user: User, @Body() joinGroupDto: JoinGroupDto) {
|
||||
return this.groupsService.join(user.id, joinGroupDto);
|
||||
}
|
||||
|
||||
@Delete(':id/leave')
|
||||
@ApiOperation({ summary: '退出小组' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async leave(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return this.groupsService.leave(user.id, id);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: '获取我的小组列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findMy(@CurrentUser() user: User) {
|
||||
return this.groupsService.findUserGroups(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取小组详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.groupsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新小组信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() updateGroupDto: UpdateGroupDto,
|
||||
) {
|
||||
return this.groupsService.update(user.id, id, updateGroupDto);
|
||||
}
|
||||
|
||||
@Put(':id/members/role')
|
||||
@ApiOperation({ summary: '设置成员角色' })
|
||||
@ApiResponse({ status: 200, description: '设置成功' })
|
||||
async updateMemberRole(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
|
||||
) {
|
||||
return this.groupsService.updateMemberRole(
|
||||
user.id,
|
||||
id,
|
||||
updateMemberRoleDto.userId,
|
||||
updateMemberRoleDto.role as any,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/members')
|
||||
@ApiOperation({ summary: '踢出成员' })
|
||||
@ApiResponse({ status: 200, description: '移除成功' })
|
||||
async kickMember(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() kickMemberDto: KickMemberDto,
|
||||
) {
|
||||
return this.groupsService.kickMember(user.id, id, kickMemberDto.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '解散小组' })
|
||||
@ApiResponse({ status: 200, description: '解散成功' })
|
||||
async disband(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return this.groupsService.disband(user.id, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/groups/groups.module.ts
Normal file
15
src/modules/groups/groups.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { GroupsController } from './groups.controller';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Group, GroupMember, User])],
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
290
src/modules/groups/groups.service.spec.ts
Normal file
290
src/modules/groups/groups.service.spec.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
describe('GroupsService', () => {
|
||||
let service: GroupsService;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
let mockUserRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
description: '描述',
|
||||
ownerId: 'user-1',
|
||||
maxMembers: 10,
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGroupRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
count: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
clearByPrefix: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GroupsService,
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: mockCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GroupsService>(GroupsService);
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建小组', async () => {
|
||||
mockGroupRepository.count.mockResolvedValue(2);
|
||||
mockGroupRepository.create.mockReturnValue(mockGroup);
|
||||
mockGroupRepository.save.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.create.mockReturnValue(mockMember);
|
||||
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
|
||||
mockGroupRepository.findOne.mockResolvedValue({
|
||||
...mockGroup,
|
||||
owner: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
name: '测试小组',
|
||||
description: '描述',
|
||||
maxMembers: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.name).toBe('测试小组');
|
||||
expect(mockGroupRepository.save).toHaveBeenCalled();
|
||||
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该mock在创建小组数量超限时抛出异常', async () => {
|
||||
mockGroupRepository.count.mockResolvedValue(5);
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
name: '测试小组',
|
||||
maxMembers: 10,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取小组详情', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue({
|
||||
...mockGroup,
|
||||
owner: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('group-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('group-1');
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('group-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新小组', async () => {
|
||||
mockGroupRepository.findOne
|
||||
.mockResolvedValueOnce(mockGroup)
|
||||
.mockResolvedValueOnce({
|
||||
...mockGroup,
|
||||
name: '更新后的名称',
|
||||
owner: mockUser,
|
||||
});
|
||||
mockGroupRepository.save.mockResolvedValue({
|
||||
...mockGroup,
|
||||
name: '更新后的名称',
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'group-1', {
|
||||
name: '更新后的名称',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('更新后的名称');
|
||||
});
|
||||
|
||||
it('应该在非所有者更新时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'group-1', { name: '新名称' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
it('应该成功加入小组', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
mockGroupMemberRepository.count
|
||||
.mockResolvedValueOnce(3) // 用户已加入的小组数
|
||||
.mockResolvedValueOnce(5); // 小组当前成员数
|
||||
mockGroupMemberRepository.create.mockReturnValue(mockMember);
|
||||
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
|
||||
|
||||
const result = await service.join('user-2', { groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在已加入时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
|
||||
|
||||
await expect(service.join('user-1', { groupId: 'group-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在小组已满时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
mockGroupMemberRepository.count
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(10);
|
||||
|
||||
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leave', () => {
|
||||
it('应该成功离开小组', async () => {
|
||||
const memberNotOwner = { ...mockMember, role: 'member' };
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(memberNotOwner);
|
||||
mockGroupMemberRepository.save.mockResolvedValue({
|
||||
...memberNotOwner,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = await service.leave('user-2', 'group-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在小组所有者尝试离开时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
|
||||
|
||||
await expect(service.leave('user-1', 'group-1')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemberRole', () => {
|
||||
it('应该成功更新成员角色', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMember,
|
||||
role: 'member',
|
||||
});
|
||||
mockGroupMemberRepository.save.mockResolvedValue({
|
||||
...mockMember,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
const result = await service.updateMemberRole(
|
||||
'user-1',
|
||||
'group-1',
|
||||
'user-2',
|
||||
'admin' as any,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在非所有者更新角色时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
|
||||
await expect(
|
||||
service.updateMemberRole('user-2', 'group-1', 'user-3', 'admin' as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
441
src/modules/groups/groups.service.ts
Normal file
441
src/modules/groups/groups.service.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CreateGroupDto, UpdateGroupDto, JoinGroupDto } from './dto/group.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
private readonly CACHE_PREFIX = 'group';
|
||||
private readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组
|
||||
*/
|
||||
async create(userId: string, createGroupDto: CreateGroupDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户创建的小组数量
|
||||
const ownedGroupsCount = await this.groupRepository.count({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
if (!user.isMember && ownedGroupsCount >= 1) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
|
||||
message: '非会员最多只能创建1个小组',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isMember && ownedGroupsCount >= 10) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
|
||||
message: '会员最多只能创建10个小组',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果是创建子组,检查父组是否存在且用户是否为会员
|
||||
if (createGroupDto.parentId) {
|
||||
if (!user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '非会员不能创建子组',
|
||||
});
|
||||
}
|
||||
|
||||
const parentGroup = await this.groupRepository.findOne({
|
||||
where: { id: createGroupDto.parentId },
|
||||
});
|
||||
|
||||
if (!parentGroup) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: '父组不存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建小组
|
||||
const group = this.groupRepository.create({
|
||||
...createGroupDto,
|
||||
ownerId: userId,
|
||||
maxMembers: createGroupDto.maxMembers || 50,
|
||||
});
|
||||
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
// 将创建者添加为小组成员(角色为 owner)
|
||||
const member = this.groupMemberRepository.create({
|
||||
groupId: group.id,
|
||||
userId: userId,
|
||||
role: GroupMemberRole.OWNER,
|
||||
});
|
||||
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return this.findOne(group.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入小组(使用原子更新防止并发竞态条件)
|
||||
*/
|
||||
async join(userId: string, joinGroupDto: JoinGroupDto) {
|
||||
const { groupId, nickname } = joinGroupDto;
|
||||
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已经是成员
|
||||
const existingMember = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.ALREADY_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.ALREADY_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户加入的小组数量
|
||||
const joinedGroupsCount = await this.groupMemberRepository.count({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!user.isMember && joinedGroupsCount >= 3) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED,
|
||||
message: ErrorMessage[ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED],
|
||||
});
|
||||
}
|
||||
|
||||
// 使用原子更新:只有当当前成员数小于最大成员数时才成功
|
||||
const updateResult = await this.groupRepository
|
||||
.createQueryBuilder()
|
||||
.update(Group)
|
||||
.set({
|
||||
currentMembers: () => 'currentMembers + 1',
|
||||
})
|
||||
.where('id = :id', { id: groupId })
|
||||
.andWhere('currentMembers < maxMembers')
|
||||
.execute();
|
||||
|
||||
// 如果影响的行数为0,说明小组已满
|
||||
if (updateResult.affected === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_FULL,
|
||||
message: ErrorMessage[ErrorCode.GROUP_FULL],
|
||||
});
|
||||
}
|
||||
|
||||
// 添加成员记录
|
||||
const member = this.groupMemberRepository.create({
|
||||
groupId,
|
||||
userId,
|
||||
nickname,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return this.findOne(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出小组
|
||||
*/
|
||||
async leave(userId: string, groupId: string) {
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 组长不能直接退出
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '组长不能退出小组,请先转让组长或解散小组',
|
||||
});
|
||||
}
|
||||
|
||||
await this.groupMemberRepository.remove(member);
|
||||
|
||||
// 更新小组成员数
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (group) {
|
||||
group.currentMembers = Math.max(0, group.currentMembers - 1);
|
||||
await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
return { message: '退出成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小组详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
// 尝试从缓存获取
|
||||
const cached = this.cacheService.get<any>(id, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['owner', 'members', 'members.user'],
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
...group,
|
||||
members: group.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
username: member.user.username,
|
||||
avatar: member.user.avatar,
|
||||
nickname: member.nickname,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
})),
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
this.cacheService.set(id, result, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
ttl: this.CACHE_TTL,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的小组列表
|
||||
*/
|
||||
async findUserGroups(userId: string) {
|
||||
const members = await this.groupMemberRepository.find({
|
||||
where: { userId },
|
||||
relations: ['group', 'group.owner'],
|
||||
});
|
||||
|
||||
return members.map((member) => ({
|
||||
...member.group,
|
||||
myRole: member.role,
|
||||
myNickname: member.nickname,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组信息
|
||||
*/
|
||||
async update(userId: string, groupId: string, updateGroupDto: UpdateGroupDto) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限(只有组长和管理员可以修改)
|
||||
await this.checkPermission(userId, groupId, [
|
||||
GroupMemberRole.OWNER,
|
||||
GroupMemberRole.ADMIN,
|
||||
]);
|
||||
|
||||
Object.assign(group, updateGroupDto);
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
// 清除缓存
|
||||
this.cacheService.del(groupId, { prefix: this.CACHE_PREFIX });
|
||||
|
||||
return this.findOne(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置成员角色
|
||||
*/
|
||||
async updateMemberRole(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
targetUserId: string,
|
||||
role: GroupMemberRole,
|
||||
) {
|
||||
// 只有组长可以设置管理员
|
||||
await this.checkPermission(userId, groupId, [GroupMemberRole.OWNER]);
|
||||
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: targetUserId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: '该用户不在小组中',
|
||||
});
|
||||
}
|
||||
|
||||
// 不能修改组长角色
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '不能修改组长角色',
|
||||
});
|
||||
}
|
||||
|
||||
member.role = role;
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return { message: '角色设置成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出成员
|
||||
*/
|
||||
async kickMember(userId: string, groupId: string, targetUserId: string) {
|
||||
// 组长和管理员可以踢人
|
||||
await this.checkPermission(userId, groupId, [
|
||||
GroupMemberRole.OWNER,
|
||||
GroupMemberRole.ADMIN,
|
||||
]);
|
||||
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: targetUserId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: '该用户不在小组中',
|
||||
});
|
||||
}
|
||||
|
||||
// 不能踢出组长
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '不能踢出组长',
|
||||
});
|
||||
}
|
||||
|
||||
await this.groupMemberRepository.remove(member);
|
||||
|
||||
// 更新小组成员数
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (group) {
|
||||
group.currentMembers = Math.max(0, group.currentMembers - 1);
|
||||
await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
return { message: '成员已移除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解散小组
|
||||
*/
|
||||
async disband(userId: string, groupId: string) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 只有组长可以解散
|
||||
if (group.ownerId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '只有组长可以解散小组',
|
||||
});
|
||||
}
|
||||
|
||||
group.isActive = false;
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
return { message: '小组已解散' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
allowedRoles: GroupMemberRole[],
|
||||
) {
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(member.role)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/modules/honors/dto/honor.dto.ts
Normal file
71
src/modules/honors/dto/honor.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateHonorDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉标题', example: '首次五连胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
@MaxLength(100)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '媒体文件URL列表(图片/视频)', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
mediaUrls?: string[];
|
||||
|
||||
@ApiProperty({ description: '荣誉获得日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
achievedDate?: Date;
|
||||
}
|
||||
|
||||
export class UpdateHonorDto {
|
||||
@ApiProperty({ description: '荣誉标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '媒体文件URL列表', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
mediaUrls?: string[];
|
||||
|
||||
@ApiProperty({ description: '事件日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
eventDate?: Date;
|
||||
}
|
||||
|
||||
export class QueryHonorsDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '年份筛选', required: false, example: 2024 })
|
||||
@IsOptional()
|
||||
year?: number;
|
||||
}
|
||||
64
src/modules/honors/honors.controller.ts
Normal file
64
src/modules/honors/honors.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('honors')
|
||||
@Controller('honors')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class HonorsController {
|
||||
constructor(private readonly honorsService: HonorsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建荣誉记录' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateHonorDto) {
|
||||
return this.honorsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询荣誉列表' })
|
||||
findAll(@Query() query: QueryHonorsDto) {
|
||||
return this.honorsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('timeline/:groupId')
|
||||
@ApiOperation({ summary: '获取小组荣誉时间轴' })
|
||||
getTimeline(@Param('groupId') groupId: string) {
|
||||
return this.honorsService.getTimeline(groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询单个荣誉记录' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.honorsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新荣誉记录' })
|
||||
update(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateHonorDto,
|
||||
) {
|
||||
return this.honorsService.update(user.id, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除荣誉记录' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.honorsService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/honors/honors.module.ts
Normal file
15
src/modules/honors/honors.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HonorsController } from './honors.controller';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Honor, Group, GroupMember])],
|
||||
controllers: [HonorsController],
|
||||
providers: [HonorsService],
|
||||
exports: [HonorsService],
|
||||
})
|
||||
export class HonorsModule {}
|
||||
313
src/modules/honors/honors.service.spec.ts
Normal file
313
src/modules/honors/honors.service.spec.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('HonorsService', () => {
|
||||
let service: HonorsService;
|
||||
let honorRepository: Repository<Honor>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockHonor = {
|
||||
id: 'honor-1',
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
description: '获得比赛冠军',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
media: ['image1.jpg'],
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
ownerId: 'user-1',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HonorsService,
|
||||
{
|
||||
provide: getRepositoryToken(Honor),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HonorsService>(HonorsService);
|
||||
honorRepository = module.get<Repository<Honor>>(getRepositoryToken(Honor));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建荣誉记录(管理员)', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
description: '获得比赛冠军',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
media: ['image1.jpg'],
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(honorRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('非管理员创建时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('组长应该可以创建荣誉记录', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.OWNER,
|
||||
} as any);
|
||||
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回荣誉列表', async () => {
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockHonor]);
|
||||
|
||||
const result = await service.findAll({ groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(honorRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeline', () => {
|
||||
it('应该返回按年份分组的时间轴', async () => {
|
||||
const mockHonors = [
|
||||
{ ...mockHonor, eventDate: new Date('2025-01-01') },
|
||||
{ ...mockHonor, id: 'honor-2', eventDate: new Date('2024-06-01') },
|
||||
];
|
||||
|
||||
jest.spyOn(honorRepository, 'find').mockResolvedValue(mockHonors as any);
|
||||
|
||||
const result = await service.getTimeline('group-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result[2025]).toHaveLength(1);
|
||||
expect(result[2024]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('空荣誉列表应该返回空对象', async () => {
|
||||
jest.spyOn(honorRepository, 'find').mockResolvedValue([]);
|
||||
|
||||
const result = await service.getTimeline('group-1');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回单个荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.findOne('honor-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('honor-1');
|
||||
});
|
||||
|
||||
it('记录不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('创建者应该可以更新荣誉记录', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue({
|
||||
...mockHonor,
|
||||
...updateDto,
|
||||
} as any);
|
||||
|
||||
const result = await service.update('user-1', 'honor-1', updateDto);
|
||||
|
||||
expect(result.title).toBe('更新后的标题');
|
||||
});
|
||||
|
||||
it('管理员应该可以更新任何荣誉记录', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue({
|
||||
...mockHonor,
|
||||
...updateDto,
|
||||
} as any);
|
||||
|
||||
const result = await service.update('user-1', 'honor-1', updateDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.update('user-1', 'honor-1', updateDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('创建者应该可以删除自己的荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.remove('user-1', 'honor-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(honorRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('管理员应该可以删除任何荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.remove('user-1', 'honor-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.remove('user-1', 'honor-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
198
src/modules/honors/honors.service.ts
Normal file
198
src/modules/honors/honors.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import {
|
||||
ErrorCode,
|
||||
ErrorMessage,
|
||||
} from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class HonorsService {
|
||||
constructor(
|
||||
@InjectRepository(Honor)
|
||||
private honorRepository: Repository<Honor>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建荣誉记录
|
||||
*/
|
||||
async create(userId: string, createDto: CreateHonorDto) {
|
||||
const { groupId, ...rest } = createDto;
|
||||
|
||||
// 验证小组存在
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限(需要是小组管理员或组长)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const honor = this.honorRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await this.honorRepository.save(honor);
|
||||
|
||||
return this.findOne(honor.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询荣誉列表
|
||||
*/
|
||||
async findAll(query: QueryHonorsDto) {
|
||||
const qb = this.honorRepository
|
||||
.createQueryBuilder('honor')
|
||||
.leftJoinAndSelect('honor.group', 'group')
|
||||
.leftJoinAndSelect('honor.creator', 'creator');
|
||||
|
||||
if (query.groupId) {
|
||||
qb.andWhere('honor.groupId = :groupId', { groupId: query.groupId });
|
||||
}
|
||||
|
||||
if (query.year) {
|
||||
const startDate = new Date(`${query.year}-01-01`);
|
||||
const endDate = new Date(`${query.year}-12-31`);
|
||||
qb.andWhere('honor.eventDate BETWEEN :startDate AND :endDate', {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}
|
||||
|
||||
qb.orderBy('honor.eventDate', 'DESC');
|
||||
|
||||
const honors = await qb.getMany();
|
||||
|
||||
return honors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间轴数据(按年份分组)
|
||||
*/
|
||||
async getTimeline(groupId: string) {
|
||||
const honors = await this.honorRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['creator'],
|
||||
order: { eventDate: 'DESC' },
|
||||
});
|
||||
|
||||
// 按年份分组
|
||||
const timeline = honors.reduce((acc, honor) => {
|
||||
const year = new Date(honor.eventDate).getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(honor);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个荣誉记录
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const honor = await this.honorRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'creator'],
|
||||
});
|
||||
|
||||
if (!honor) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.HONOR_NOT_FOUND,
|
||||
message: '荣誉记录不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return honor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新荣誉记录
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateHonorDto) {
|
||||
const honor = await this.findOne(id);
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: honor.groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
honor.creatorId !== userId &&
|
||||
(!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER))
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(honor, updateDto);
|
||||
await this.honorRepository.save(honor);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除荣誉记录
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const honor = await this.findOne(id);
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: honor.groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
honor.creatorId !== userId &&
|
||||
(!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER))
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.honorRepository.remove(honor);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
143
src/modules/ledgers/dto/ledger.dto.ts
Normal file
143
src/modules/ledgers/dto/ledger.dto.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { LedgerType } from '../../../common/enums';
|
||||
|
||||
export class CreateLedgerDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType })
|
||||
@IsEnum(LedgerType)
|
||||
type: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '金额', example: 100.5 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: '账目描述' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '账目描述不能为空' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '账目日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateLedgerDto {
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
|
||||
@IsEnum(LedgerType)
|
||||
@IsOptional()
|
||||
type?: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '金额', required: false })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
amount?: number;
|
||||
|
||||
@ApiProperty({ description: '账目描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '账目日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class QueryLedgersDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
|
||||
@IsEnum(LedgerType)
|
||||
@IsOptional()
|
||||
type?: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '开始日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startDate?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endDate?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class MonthlyStatisticsDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '年份', example: 2024 })
|
||||
@IsNumber()
|
||||
@Min(2000)
|
||||
@Type(() => Number)
|
||||
year: number;
|
||||
|
||||
@ApiProperty({ description: '月份', example: 1 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
month: number;
|
||||
}
|
||||
110
src/modules/ledgers/ledgers.controller.ts
Normal file
110
src/modules/ledgers/ledgers.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import {
|
||||
CreateLedgerDto,
|
||||
UpdateLedgerDto,
|
||||
QueryLedgersDto,
|
||||
MonthlyStatisticsDto,
|
||||
} from './dto/ledger.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('ledgers')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('ledgers')
|
||||
export class LedgersController {
|
||||
constructor(private readonly ledgersService: LedgersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建账目' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateLedgerDto,
|
||||
) {
|
||||
return this.ledgersService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取账目列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'type', required: false, description: '账目类型' })
|
||||
@ApiQuery({ name: 'category', required: false, description: '分类' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryLedgersDto,
|
||||
) {
|
||||
return this.ledgersService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get('statistics/monthly')
|
||||
@ApiOperation({ summary: '月度统计' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getMonthlyStatistics(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() statsDto: MonthlyStatisticsDto,
|
||||
) {
|
||||
return this.ledgersService.getMonthlyStatistics(userId, statsDto);
|
||||
}
|
||||
|
||||
@Get('statistics/hierarchical/:groupId')
|
||||
@ApiOperation({ summary: '层级汇总' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getHierarchicalSummary(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
) {
|
||||
return this.ledgersService.getHierarchicalSummary(userId, groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取账目详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.ledgersService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新账目' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateLedgerDto,
|
||||
) {
|
||||
return this.ledgersService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除账目' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.ledgersService.remove(userId, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/ledgers/ledgers.module.ts
Normal file
15
src/modules/ledgers/ledgers.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import { LedgersController } from './ledgers.controller';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Ledger, Group, GroupMember])],
|
||||
controllers: [LedgersController],
|
||||
providers: [LedgersService],
|
||||
exports: [LedgersService],
|
||||
})
|
||||
export class LedgersModule {}
|
||||
369
src/modules/ledgers/ledgers.service.spec.ts
Normal file
369
src/modules/ledgers/ledgers.service.spec.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
enum LedgerType {
|
||||
INCOME = 'income',
|
||||
EXPENSE = 'expense',
|
||||
}
|
||||
|
||||
describe('LedgersService', () => {
|
||||
let service: LedgersService;
|
||||
let mockLedgerRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
isActive: true,
|
||||
parentId: null,
|
||||
};
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockLedger = {
|
||||
id: 'ledger-1',
|
||||
groupId: 'group-1',
|
||||
creatorId: 'user-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '周末聚餐',
|
||||
createdAt: new Date('2024-01-20T10:00:00Z'),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockLedgerRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LedgersService,
|
||||
{
|
||||
provide: getRepositoryToken(Ledger),
|
||||
useValue: mockLedgerRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LedgersService>(LedgersService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建账目', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockLedgerRepository.create.mockReturnValue(mockLedger);
|
||||
mockLedgerRepository.save.mockResolvedValue(mockLedger);
|
||||
mockLedgerRepository.findOne.mockResolvedValue({
|
||||
...mockLedger,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '周末聚餐',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(mockLedgerRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在金额无效时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: -100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取账目列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该支持按类型筛选', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取账目详情', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue({
|
||||
...mockLedger,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('ledger-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('ledger-1');
|
||||
});
|
||||
|
||||
it('应该在账目不存在时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('ledger-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新账目', async () => {
|
||||
mockLedgerRepository.findOne
|
||||
.mockResolvedValueOnce(mockLedger)
|
||||
.mockResolvedValueOnce({
|
||||
...mockLedger,
|
||||
amount: 200,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'admin',
|
||||
});
|
||||
mockLedgerRepository.save.mockResolvedValue({
|
||||
...mockLedger,
|
||||
amount: 200,
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'ledger-1', {
|
||||
amount: 200,
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(200);
|
||||
});
|
||||
|
||||
it('应该在账目不存在时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', 'ledger-1', { amount: 200 }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在无权限时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'member',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'ledger-1', { amount: 200 }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除账目', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'admin',
|
||||
});
|
||||
mockLedgerRepository.remove.mockResolvedValue(mockLedger);
|
||||
|
||||
const result = await service.remove('user-1', 'ledger-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在无权限时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'member',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('user-2', 'ledger-1'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthlyStatistics', () => {
|
||||
it('应该成功获取月度统计', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{ ...mockLedger, type: LedgerType.INCOME, amount: 100 },
|
||||
{ ...mockLedger, type: LedgerType.EXPENSE, amount: 50 },
|
||||
]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.getMonthlyStatistics('user-1', {
|
||||
groupId: 'group-1',
|
||||
year: 2024,
|
||||
month: 1,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('income');
|
||||
expect(result).toHaveProperty('expense');
|
||||
expect(result).toHaveProperty('balance');
|
||||
expect(result).toHaveProperty('categories');
|
||||
});
|
||||
|
||||
it('应该在用户不在小组时抛出异常', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getMonthlyStatistics('user-1', {
|
||||
groupId: 'group-1',
|
||||
year: 2024,
|
||||
month: 1,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHierarchicalSummary', () => {
|
||||
it('应该成功获取层级汇总', async () => {
|
||||
const childGroup = { id: 'group-2', name: '子小组', parentId: 'group-1' };
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockLedger]),
|
||||
};
|
||||
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGroupRepository.find.mockResolvedValue([childGroup]);
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getHierarchicalSummary('user-1', 'group-1');
|
||||
|
||||
expect(result).toHaveProperty('groupId');
|
||||
expect(result).toHaveProperty('income');
|
||||
expect(result).toHaveProperty('expense');
|
||||
expect(result).toHaveProperty('balance');
|
||||
});
|
||||
});
|
||||
});
|
||||
419
src/modules/ledgers/ledgers.service.ts
Normal file
419
src/modules/ledgers/ledgers.service.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import {
|
||||
CreateLedgerDto,
|
||||
UpdateLedgerDto,
|
||||
QueryLedgersDto,
|
||||
MonthlyStatisticsDto,
|
||||
} from './dto/ledger.dto';
|
||||
import { LedgerType, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
@Injectable()
|
||||
export class LedgersService {
|
||||
constructor(
|
||||
@InjectRepository(Ledger)
|
||||
private ledgerRepository: Repository<Ledger>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建账目
|
||||
*/
|
||||
async create(userId: string, createDto: CreateLedgerDto) {
|
||||
const { groupId, date, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限(必须是小组成员)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建账目
|
||||
const ledger = this.ledgerRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await this.ledgerRepository.save(ledger);
|
||||
|
||||
return this.findOne(ledger.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账目列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QueryLedgersDto) {
|
||||
const {
|
||||
groupId,
|
||||
type,
|
||||
category,
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.ledgerRepository
|
||||
.createQueryBuilder('ledger')
|
||||
.leftJoinAndSelect('ledger.group', 'group')
|
||||
.leftJoinAndSelect('ledger.user', 'user');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
// 验证用户是否在小组中
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
queryBuilder.andWhere('ledger.groupId = :groupId', { groupId });
|
||||
} else {
|
||||
// 如果没有指定小组,只返回用户所在小组的账目
|
||||
const memberGroups = await this.groupMemberRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
select: ['groupId'],
|
||||
});
|
||||
const groupIds = memberGroups.map((m) => m.groupId);
|
||||
if (groupIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
queryBuilder.andWhere('ledger.groupId IN (:...groupIds)', { groupIds });
|
||||
}
|
||||
|
||||
if (type) {
|
||||
queryBuilder.andWhere('ledger.type = :type', { type });
|
||||
}
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('ledger.category = :category', { category });
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt BETWEEN :startDate AND :endDate', {
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
} else if (startDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt >= :startDate', {
|
||||
startDate: new Date(startDate),
|
||||
});
|
||||
} else if (endDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt <= :endDate', {
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('ledger.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账目详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'user'],
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账目
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateLedgerDto) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
|
||||
|
||||
Object.assign(ledger, updateDto);
|
||||
await this.ledgerRepository.save(ledger);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账目
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
|
||||
|
||||
await this.ledgerRepository.remove(ledger);
|
||||
|
||||
return { message: '账目已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 月度统计
|
||||
*/
|
||||
async getMonthlyStatistics(userId: string, statsDto: MonthlyStatisticsDto) {
|
||||
const { groupId, year, month } = statsDto;
|
||||
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 计算月份起止时间
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0, 23, 59, 59);
|
||||
|
||||
// 查询该月所有账目
|
||||
const ledgers = await this.ledgerRepository.find({
|
||||
where: {
|
||||
groupId,
|
||||
createdAt: Between(startDate, endDate),
|
||||
},
|
||||
});
|
||||
|
||||
// 统计收入和支出
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
const categoryStats: Record<
|
||||
string,
|
||||
{ income: number; expense: number; count: number }
|
||||
> = {};
|
||||
|
||||
ledgers.forEach((ledger) => {
|
||||
const amount = Number(ledger.amount);
|
||||
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
totalIncome += amount;
|
||||
} else {
|
||||
totalExpense += amount;
|
||||
}
|
||||
|
||||
// 分类统计
|
||||
const category = ledger.category || '未分类';
|
||||
if (!categoryStats[category]) {
|
||||
categoryStats[category] = { income: 0, expense: 0, count: 0 };
|
||||
}
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
categoryStats[category].income += amount;
|
||||
} else {
|
||||
categoryStats[category].expense += amount;
|
||||
}
|
||||
categoryStats[category].count++;
|
||||
});
|
||||
|
||||
return {
|
||||
groupId,
|
||||
year,
|
||||
month,
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense,
|
||||
categoryStats,
|
||||
recordCount: ledgers.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级汇总(大组->子组)
|
||||
*/
|
||||
async getHierarchicalSummary(userId: string, groupId: string) {
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 获取大组信息
|
||||
const parentGroup = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
|
||||
if (!parentGroup) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有子组
|
||||
const childGroups = await this.groupRepository.find({
|
||||
where: { parentId: groupId, isActive: true },
|
||||
});
|
||||
|
||||
// 统计大组账目
|
||||
const parentLedgers = await this.ledgerRepository.find({
|
||||
where: { groupId },
|
||||
});
|
||||
|
||||
const parentStats = this.calculateStats(parentLedgers);
|
||||
|
||||
// 统计各子组账目
|
||||
const childStats = await Promise.all(
|
||||
childGroups.map(async (child) => {
|
||||
const ledgers = await this.ledgerRepository.find({
|
||||
where: { groupId: child.id },
|
||||
});
|
||||
return {
|
||||
groupId: child.id,
|
||||
groupName: child.name,
|
||||
...this.calculateStats(ledgers),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
parent: {
|
||||
groupId: parentGroup.id,
|
||||
groupName: parentGroup.name,
|
||||
...parentStats,
|
||||
},
|
||||
children: childStats,
|
||||
total: {
|
||||
income:
|
||||
parentStats.totalIncome +
|
||||
childStats.reduce((sum, c) => sum + c.totalIncome, 0),
|
||||
expense:
|
||||
parentStats.totalExpense +
|
||||
childStats.reduce((sum, c) => sum + c.totalExpense, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查小组成员身份
|
||||
*/
|
||||
private async checkGroupMembership(userId: string, groupId: string) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
creatorId: string,
|
||||
): Promise<void> {
|
||||
// 如果是创建者,直接通过
|
||||
if (userId === creatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是小组管理员或组长
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计数据
|
||||
*/
|
||||
private calculateStats(ledgers: Ledger[]) {
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
|
||||
ledgers.forEach((ledger) => {
|
||||
const amount = Number(ledger.amount);
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
totalIncome += amount;
|
||||
} else {
|
||||
totalExpense += amount;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense,
|
||||
recordCount: ledgers.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
src/modules/points/dto/point.dto.ts
Normal file
52
src/modules/points/dto/point.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AddPointDto {
|
||||
@ApiProperty({ description: '用户ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '用户ID不能为空' })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '积分数量', example: 10 })
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: '原因', example: '参与预约' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '原因不能为空' })
|
||||
@MaxLength(100)
|
||||
reason: string;
|
||||
|
||||
@ApiProperty({ description: '详细说明', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '关联ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
relatedId?: string;
|
||||
}
|
||||
|
||||
export class QueryPointsDto {
|
||||
@ApiProperty({ description: '用户ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
}
|
||||
52
src/modules/points/points.controller.ts
Normal file
52
src/modules/points/points.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PointsService } from './points.service';
|
||||
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('points')
|
||||
@Controller('points')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PointsController {
|
||||
constructor(private readonly pointsService: PointsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '添加积分记录(管理员)' })
|
||||
addPoint(@CurrentUser() user, @Body() addDto: AddPointDto) {
|
||||
return this.pointsService.addPoint(user.id, addDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询积分流水' })
|
||||
findAll(@Query() query: QueryPointsDto) {
|
||||
return this.pointsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('balance/:userId/:groupId')
|
||||
@ApiOperation({ summary: '查询用户在小组的积分余额' })
|
||||
getUserBalance(
|
||||
@Param('userId') userId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
) {
|
||||
return this.pointsService.getUserBalance(userId, groupId);
|
||||
}
|
||||
|
||||
@Get('ranking/:groupId')
|
||||
@ApiOperation({ summary: '获取小组积分排行榜' })
|
||||
getGroupRanking(
|
||||
@Param('groupId') groupId: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.pointsService.getGroupRanking(groupId, limit);
|
||||
}
|
||||
}
|
||||
16
src/modules/points/points.module.ts
Normal file
16
src/modules/points/points.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PointsController } from './points.controller';
|
||||
import { PointsService } from './points.service';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Point, User, Group, GroupMember])],
|
||||
controllers: [PointsController],
|
||||
providers: [PointsService],
|
||||
exports: [PointsService],
|
||||
})
|
||||
export class PointsModule {}
|
||||
229
src/modules/points/points.service.spec.ts
Normal file
229
src/modules/points/points.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { PointsService } from './points.service';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('PointsService', () => {
|
||||
let service: PointsService;
|
||||
let pointRepository: Repository<Point>;
|
||||
let userRepository: Repository<User>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockPoint = {
|
||||
id: 'point-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
description: '测试说明',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: '测试用户',
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getRawOne: jest.fn(),
|
||||
getRawMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PointsService,
|
||||
{
|
||||
provide: getRepositoryToken(Point),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PointsService>(PointsService);
|
||||
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('addPoint', () => {
|
||||
it('应该成功添加积分记录', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue(mockPoint as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue(mockPoint as any);
|
||||
|
||||
const result = await service.addPoint('user-1', addDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('用户不存在时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回积分流水列表', async () => {
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockPoint]);
|
||||
|
||||
const result = await service.findAll({ groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(pointRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserBalance', () => {
|
||||
it('应该返回用户积分余额', async () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
|
||||
const result = await service.getUserBalance('user-1', 'group-1');
|
||||
|
||||
expect(result.balance).toBe(100);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.groupId).toBe('group-1');
|
||||
});
|
||||
|
||||
it('没有积分记录时应该返回0', async () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: null });
|
||||
|
||||
const result = await service.getUserBalance('user-1', 'group-1');
|
||||
|
||||
expect(result.balance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupRanking', () => {
|
||||
it('应该返回小组积分排行榜', async () => {
|
||||
const mockRanking = [
|
||||
{ userId: 'user-1', username: '用户1', totalPoints: '100' },
|
||||
{ userId: 'user-2', username: '用户2', totalPoints: '80' },
|
||||
];
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue(mockRanking);
|
||||
|
||||
const result = await service.getGroupRanking('group-1', 10);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].rank).toBe(1);
|
||||
expect(result[0].totalPoints).toBe(100);
|
||||
expect(result[1].rank).toBe(2);
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.getGroupRanking('group-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/modules/points/points.service.ts
Normal file
150
src/modules/points/points.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PointsService {
|
||||
constructor(
|
||||
@InjectRepository(Point)
|
||||
private pointRepository: Repository<Point>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 添加积分记录
|
||||
*/
|
||||
async addPoint(operatorId: string, addDto: AddPointDto) {
|
||||
const { userId, groupId, ...rest } = addDto;
|
||||
|
||||
// 验证小组存在
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户存在
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证操作者权限(需要管理员)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: operatorId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const point = this.pointRepository.create({
|
||||
...rest,
|
||||
userId,
|
||||
groupId,
|
||||
});
|
||||
|
||||
await this.pointRepository.save(point);
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分流水
|
||||
*/
|
||||
async findAll(query: QueryPointsDto) {
|
||||
const qb = this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.leftJoinAndSelect('point.user', 'user')
|
||||
.leftJoinAndSelect('point.group', 'group');
|
||||
|
||||
if (query.userId) {
|
||||
qb.andWhere('point.userId = :userId', { userId: query.userId });
|
||||
}
|
||||
|
||||
if (query.groupId) {
|
||||
qb.andWhere('point.groupId = :groupId', { groupId: query.groupId });
|
||||
}
|
||||
|
||||
qb.orderBy('point.createdAt', 'DESC');
|
||||
|
||||
const points = await qb.getMany();
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在小组的积分总和
|
||||
*/
|
||||
async getUserBalance(userId: string, groupId: string) {
|
||||
const result = await this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.select('SUM(point.amount)', 'total')
|
||||
.where('point.userId = :userId', { userId })
|
||||
.andWhere('point.groupId = :groupId', { groupId })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
userId,
|
||||
groupId,
|
||||
balance: parseInt(result.total || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小组积分排行榜
|
||||
*/
|
||||
async getGroupRanking(groupId: string, limit: number = 10) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const ranking = await this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.select('point.userId', 'userId')
|
||||
.addSelect('SUM(point.amount)', 'totalPoints')
|
||||
.leftJoin('point.user', 'user')
|
||||
.addSelect('user.username', 'username')
|
||||
.where('point.groupId = :groupId', { groupId })
|
||||
.groupBy('point.userId')
|
||||
.orderBy('totalPoints', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany();
|
||||
|
||||
return ranking.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
userId: item.userId,
|
||||
username: item.username,
|
||||
totalPoints: parseInt(item.totalPoints),
|
||||
}));
|
||||
}
|
||||
}
|
||||
127
src/modules/schedules/dto/schedule.dto.ts
Normal file
127
src/modules/schedules/dto/schedule.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TimeSlotDto {
|
||||
@ApiProperty({ description: '开始时间', example: '2024-01-20T19:00:00Z' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', example: '2024-01-20T23:00:00Z' })
|
||||
@IsDateString()
|
||||
endTime: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class CreateScheduleDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '标题', example: '本周空闲时间' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimeSlotDto)
|
||||
availableSlots: TimeSlotDto[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiProperty({ description: '标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto], required: false })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimeSlotDto)
|
||||
@IsOptional()
|
||||
availableSlots?: TimeSlotDto[];
|
||||
}
|
||||
|
||||
export class QuerySchedulesDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '用户ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class FindCommonSlotsDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '开始时间' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间' })
|
||||
@IsDateString()
|
||||
endTime: Date;
|
||||
|
||||
@ApiProperty({ description: '最少参与人数', example: 3, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minParticipants?: number;
|
||||
}
|
||||
99
src/modules/schedules/schedules.controller.ts
Normal file
99
src/modules/schedules/schedules.controller.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
QuerySchedulesDto,
|
||||
FindCommonSlotsDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('schedules')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private readonly schedulesService: SchedulesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建排班' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateScheduleDto,
|
||||
) {
|
||||
return this.schedulesService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取排班列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'userId', required: false, description: '用户ID' })
|
||||
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
|
||||
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QuerySchedulesDto,
|
||||
) {
|
||||
return this.schedulesService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Post('common-slots')
|
||||
@ApiOperation({ summary: '查找共同空闲时间' })
|
||||
@ApiResponse({ status: 200, description: '查询成功' })
|
||||
async findCommonSlots(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() findDto: FindCommonSlotsDto,
|
||||
) {
|
||||
return this.schedulesService.findCommonSlots(userId, findDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取排班详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.schedulesService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新排班' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateScheduleDto,
|
||||
) {
|
||||
return this.schedulesService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除排班' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.schedulesService.remove(userId, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/schedules/schedules.module.ts
Normal file
15
src/modules/schedules/schedules.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Schedule, Group, GroupMember])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
394
src/modules/schedules/schedules.service.spec.ts
Normal file
394
src/modules/schedules/schedules.service.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { TimeSlotDto } from './dto/schedule.dto';
|
||||
|
||||
describe('SchedulesService', () => {
|
||||
let service: SchedulesService;
|
||||
let mockScheduleRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockTimeSlots: TimeSlotDto[] = [
|
||||
{
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
endTime: new Date('2024-01-20T21:00:00Z'),
|
||||
note: '晚上空闲',
|
||||
},
|
||||
{
|
||||
startTime: new Date('2024-01-21T14:00:00Z'),
|
||||
endTime: new Date('2024-01-21T17:00:00Z'),
|
||||
note: '下午空闲',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSchedule = {
|
||||
id: 'schedule-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
availableSlots: mockTimeSlots,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockScheduleRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(Schedule),
|
||||
useValue: mockScheduleRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SchedulesService>(SchedulesService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建排班', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.create.mockReturnValue(mockSchedule);
|
||||
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
|
||||
mockScheduleRepository.findOne.mockResolvedValue({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(mockScheduleRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在时间段为空时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: [],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在时间段无效时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: [
|
||||
{
|
||||
startTime: new Date('2024-01-20T21:00:00Z'),
|
||||
endTime: new Date('2024-01-20T19:00:00Z'), // 结束时间早于开始时间
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取排班列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('应该在指定小组且用户不在小组时抛出异常', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在无小组ID时返回用户所在所有小组的排班', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.find.mockResolvedValue([
|
||||
{ groupId: 'group-1' },
|
||||
{ groupId: 'group-2' },
|
||||
]);
|
||||
|
||||
const result = await service.findAll('user-1', {});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mockGroupMemberRepository.find).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取排班详情', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
|
||||
const result = await service.findOne('schedule-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('schedule-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新排班', async () => {
|
||||
mockScheduleRepository.findOne
|
||||
.mockResolvedValueOnce(mockSchedule)
|
||||
.mockResolvedValueOnce({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
|
||||
|
||||
const result = await service.update('user-1', 'schedule-1', {
|
||||
availableSlots: mockTimeSlots,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(mockScheduleRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', 'schedule-1', { availableSlots: mockTimeSlots }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在非创建者更新时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'schedule-1', { availableSlots: mockTimeSlots }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除排班', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
mockScheduleRepository.remove.mockResolvedValue(mockSchedule);
|
||||
|
||||
const result = await service.remove('user-1', 'schedule-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockScheduleRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove('user-1', 'schedule-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在非创建者删除时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
|
||||
await expect(service.remove('user-2', 'schedule-1')).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCommonSlots', () => {
|
||||
it('应该成功查找共同空闲时间', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.find.mockResolvedValue([
|
||||
{
|
||||
...mockSchedule,
|
||||
userId: 'user-1',
|
||||
user: { id: 'user-1' },
|
||||
},
|
||||
{
|
||||
...mockSchedule,
|
||||
id: 'schedule-2',
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2' },
|
||||
availableSlots: [
|
||||
{
|
||||
startTime: new Date('2024-01-20T19:30:00Z'),
|
||||
endTime: new Date('2024-01-20T22:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
minParticipants: 2,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('commonSlots');
|
||||
expect(result).toHaveProperty('totalParticipants');
|
||||
expect(result.totalParticipants).toBe(2);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组时抛出异常', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在没有排班数据时返回空结果', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
});
|
||||
|
||||
expect(result.commonSlots).toEqual([]);
|
||||
expect(result.message).toBe('暂无排班数据');
|
||||
});
|
||||
});
|
||||
});
|
||||
446
src/modules/schedules/schedules.service.ts
Normal file
446
src/modules/schedules/schedules.service.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
QuerySchedulesDto,
|
||||
FindCommonSlotsDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
export interface TimeSlot {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CommonSlot {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
participants: string[];
|
||||
participantCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
constructor(
|
||||
@InjectRepository(Schedule)
|
||||
private scheduleRepository: Repository<Schedule>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建排班
|
||||
*/
|
||||
async create(userId: string, createDto: CreateScheduleDto) {
|
||||
const { groupId, availableSlots, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证时间段
|
||||
this.validateTimeSlots(availableSlots);
|
||||
|
||||
// 创建排班
|
||||
const schedule = this.scheduleRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
userId,
|
||||
availableSlots: availableSlots as any,
|
||||
});
|
||||
|
||||
await this.scheduleRepository.save(schedule);
|
||||
|
||||
return this.findOne(schedule.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排班列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QuerySchedulesDto) {
|
||||
const {
|
||||
groupId,
|
||||
userId: targetUserId,
|
||||
startTime,
|
||||
endTime,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.scheduleRepository
|
||||
.createQueryBuilder('schedule')
|
||||
.leftJoinAndSelect('schedule.group', 'group')
|
||||
.leftJoinAndSelect('schedule.user', 'user');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
// 验证用户是否在小组中
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
queryBuilder.andWhere('schedule.groupId = :groupId', { groupId });
|
||||
} else {
|
||||
// 如果没有指定小组,只返回用户所在小组的排班
|
||||
const memberGroups = await this.groupMemberRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
select: ['groupId'],
|
||||
});
|
||||
const groupIds = memberGroups.map((m) => m.groupId);
|
||||
if (groupIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
queryBuilder.andWhere('schedule.groupId IN (:...groupIds)', { groupIds });
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
queryBuilder.andWhere('schedule.userId = :userId', { userId: targetUserId });
|
||||
}
|
||||
|
||||
if (startTime && endTime) {
|
||||
queryBuilder.andWhere('schedule.createdAt BETWEEN :startTime AND :endTime', {
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime),
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('schedule.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
// 解析 availableSlots
|
||||
const formattedItems = items.map((item) => ({
|
||||
...item,
|
||||
availableSlots: this.normalizeAvailableSlots(item.availableSlots),
|
||||
}));
|
||||
|
||||
return {
|
||||
items: formattedItems,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排班详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'user'],
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
availableSlots: this.normalizeAvailableSlots(schedule.availableSlots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新排班
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateScheduleDto) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 只有创建者可以修改
|
||||
if (schedule.userId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateDto.availableSlots) {
|
||||
this.validateTimeSlots(updateDto.availableSlots);
|
||||
updateDto.availableSlots = updateDto.availableSlots as any;
|
||||
}
|
||||
|
||||
Object.assign(schedule, updateDto);
|
||||
await this.scheduleRepository.save(schedule);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除排班
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 只有创建者可以删除
|
||||
if (schedule.userId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.scheduleRepository.remove(schedule);
|
||||
|
||||
return { message: '排班已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找共同空闲时间
|
||||
*/
|
||||
async findCommonSlots(userId: string, findDto: FindCommonSlotsDto) {
|
||||
const { groupId, startTime, endTime, minParticipants = 2 } = findDto;
|
||||
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 获取时间范围内的所有排班
|
||||
const schedules = await this.scheduleRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return {
|
||||
commonSlots: [],
|
||||
message: '暂无排班数据',
|
||||
};
|
||||
}
|
||||
|
||||
// 解析所有时间段
|
||||
const userSlots: Map<string, TimeSlot[]> = new Map();
|
||||
schedules.forEach((schedule) => {
|
||||
const slots = this.normalizeAvailableSlots(schedule.availableSlots);
|
||||
const filteredSlots = slots.filter((slot) => {
|
||||
const slotStart = new Date(slot.startTime);
|
||||
const slotEnd = new Date(slot.endTime);
|
||||
const rangeStart = new Date(startTime);
|
||||
const rangeEnd = new Date(endTime);
|
||||
return slotStart >= rangeStart && slotEnd <= rangeEnd;
|
||||
});
|
||||
userSlots.set(schedule.userId, filteredSlots);
|
||||
});
|
||||
|
||||
// 计算时间交集
|
||||
const commonSlots = this.calculateCommonSlots(userSlots, minParticipants);
|
||||
|
||||
// 按参与人数排序
|
||||
commonSlots.sort((a, b) => b.participantCount - a.participantCount);
|
||||
|
||||
return {
|
||||
commonSlots,
|
||||
totalParticipants: schedules.length,
|
||||
minParticipants,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算共同空闲时间
|
||||
*/
|
||||
private calculateCommonSlots(
|
||||
userSlots: Map<string, TimeSlot[]>,
|
||||
minParticipants: number,
|
||||
): CommonSlot[] {
|
||||
const allSlots: Array<{ time: Date; userId: string; type: 'start' | 'end' }> = [];
|
||||
|
||||
// 收集所有时间点
|
||||
userSlots.forEach((slots, userId) => {
|
||||
slots.forEach((slot) => {
|
||||
allSlots.push({
|
||||
time: new Date(slot.startTime),
|
||||
userId,
|
||||
type: 'start',
|
||||
});
|
||||
allSlots.push({
|
||||
time: new Date(slot.endTime),
|
||||
userId,
|
||||
type: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
allSlots.sort((a, b) => a.time.getTime() - b.time.getTime());
|
||||
|
||||
// 扫描线算法计算重叠区间
|
||||
const commonSlots: CommonSlot[] = [];
|
||||
const activeUsers = new Set<string>();
|
||||
let lastTime: Date | null = null;
|
||||
|
||||
allSlots.forEach((event) => {
|
||||
if (lastTime && activeUsers.size >= minParticipants) {
|
||||
// 记录共同空闲时间段
|
||||
if (event.time.getTime() > lastTime.getTime()) {
|
||||
commonSlots.push({
|
||||
startTime: lastTime,
|
||||
endTime: event.time,
|
||||
participants: Array.from(activeUsers),
|
||||
participantCount: activeUsers.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'start') {
|
||||
activeUsers.add(event.userId);
|
||||
} else {
|
||||
activeUsers.delete(event.userId);
|
||||
}
|
||||
|
||||
lastTime = event.time;
|
||||
});
|
||||
|
||||
// 合并相邻的时间段
|
||||
return this.mergeAdjacentSlots(commonSlots);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并相邻的时间段
|
||||
*/
|
||||
private mergeAdjacentSlots(slots: CommonSlot[]): CommonSlot[] {
|
||||
if (slots.length === 0) return [];
|
||||
|
||||
const merged: CommonSlot[] = [];
|
||||
let current = slots[0];
|
||||
|
||||
for (let i = 1; i < slots.length; i++) {
|
||||
const next = slots[i];
|
||||
|
||||
// 如果参与者相同且时间连续,则合并
|
||||
if (
|
||||
current.endTime.getTime() === next.startTime.getTime() &&
|
||||
this.arraysEqual(current.participants, next.participants)
|
||||
) {
|
||||
current.endTime = next.endTime;
|
||||
} else {
|
||||
merged.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时间段
|
||||
*/
|
||||
private validateTimeSlots(slots: TimeSlot[]): void {
|
||||
if (slots.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: '至少需要一个时间段',
|
||||
});
|
||||
}
|
||||
|
||||
slots.forEach((slot, index) => {
|
||||
const start = new Date(slot.startTime);
|
||||
const end = new Date(slot.endTime);
|
||||
|
||||
if (start >= end) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: `时间段${index + 1}的结束时间必须大于开始时间`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化时间段数据
|
||||
*/
|
||||
private normalizeAvailableSlots(slots: any): TimeSlot[] {
|
||||
if (Array.isArray(slots)) {
|
||||
return slots;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个数组是否相同
|
||||
*/
|
||||
private async checkGroupMembership(userId: string, groupId: string) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个数组是否相同
|
||||
*/
|
||||
private arraysEqual(arr1: string[], arr2: string[]): boolean {
|
||||
if (arr1.length !== arr2.length) return false;
|
||||
const sorted1 = [...arr1].sort();
|
||||
const sorted2 = [...arr2].sort();
|
||||
return sorted1.every((val, index) => val === sorted2[index]);
|
||||
}
|
||||
}
|
||||
31
src/modules/users/dto/user.dto.ts
Normal file
31
src/modules/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiProperty({ description: '邮箱', required: false })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: '头像URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: '旧密码' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
oldPassword: string;
|
||||
|
||||
@ApiProperty({ description: '新密码' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: '密码至少6个字符' })
|
||||
newPassword: string;
|
||||
}
|
||||
46
src/modules/users/users.controller.ts
Normal file
46
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@ApiTags('users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getProfile(@CurrentUser() user: User) {
|
||||
return this.usersService.findOne(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Put('me')
|
||||
@ApiOperation({ summary: '更新当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(@CurrentUser() user: User, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(user.id, updateUserDto);
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
@ApiOperation({ summary: '修改密码' })
|
||||
@ApiResponse({ status: 200, description: '修改成功' })
|
||||
async changePassword(
|
||||
@CurrentUser() user: User,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
) {
|
||||
return this.usersService.changePassword(user.id, changePasswordDto);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user