初始化游戏小组管理系统后端项目

- 基于 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:
UGREEN USER
2026-01-28 10:42:06 +08:00
commit b25aa5b143
134 changed files with 30536 additions and 0 deletions

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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 {}

View 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;
},
);

View 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);

View 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
View 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', // 输
}

View 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,
});
}
}

View 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;
}
}

View 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;
}
}

View 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}`,
);
},
}),
);
}
}

View 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(),
};
}),
);
}
}

View 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]: '缓存错误',
};

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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
View 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',
}));

View 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,
}));

View 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
View 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',
}));

View 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,
}));

View 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),
}));

View 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;
}

View 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[];
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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();

View 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);
}
}

View 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 {}

View 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);
});
});
});

View 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,
};
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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();
});
});
});

View 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: '删除成功' };
}
}

View 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;
}

View 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');
});
});
});
});

View 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);
}
}

View 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 {}

View 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();
});
});
});

View 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,
};
}
}

View 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;
}

View 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;
}
}

View 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);
}
}

View 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 {}

View 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();
});
});
});

View 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();
}
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
});
});
});

View 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: '删除成功' };
}
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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');
});
});
});

View 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);
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
});
});
});

View 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],
});
}
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
});
});
});

View 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: '删除成功' };
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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');
});
});
});

View 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,
};
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
});
});
});

View 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),
}));
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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('暂无排班数据');
});
});
});

View 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]);
}
}

View 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;
}

View 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