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

- 基于 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,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;
}