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

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