初始化游戏小组管理系统后端项目
- 基于 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:
146
src/modules/appointments/appointments.controller.ts
Normal file
146
src/modules/appointments/appointments.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import {
|
||||
CreateAppointmentDto,
|
||||
UpdateAppointmentDto,
|
||||
QueryAppointmentsDto,
|
||||
JoinAppointmentDto,
|
||||
} from './dto/appointment.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('appointments')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('appointments')
|
||||
export class AppointmentsController {
|
||||
constructor(private readonly appointmentsService: AppointmentsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建预约' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取预约列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'gameId', required: false, description: '游戏ID' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '状态' })
|
||||
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
|
||||
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryAppointmentsDto,
|
||||
) {
|
||||
return this.appointmentsService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: '获取我参与的预约' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findMyAppointments(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryAppointmentsDto,
|
||||
) {
|
||||
return this.appointmentsService.findMyAppointments(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取预约详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.findOne(id, userId);
|
||||
}
|
||||
|
||||
@Post('join')
|
||||
@ApiOperation({ summary: '加入预约' })
|
||||
@ApiResponse({ status: 200, description: '加入成功' })
|
||||
async join(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() joinDto: JoinAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.join(userId, joinDto.appointmentId);
|
||||
}
|
||||
|
||||
@Delete(':id/leave')
|
||||
@ApiOperation({ summary: '退出预约' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async leave(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.leave(userId, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新预约' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateAppointmentDto,
|
||||
) {
|
||||
return this.appointmentsService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Put(':id/confirm')
|
||||
@ApiOperation({ summary: '确认预约' })
|
||||
@ApiResponse({ status: 200, description: '确认成功' })
|
||||
async confirm(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.confirm(userId, id);
|
||||
}
|
||||
|
||||
@Put(':id/complete')
|
||||
@ApiOperation({ summary: '完成预约' })
|
||||
@ApiResponse({ status: 200, description: '完成成功' })
|
||||
async complete(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.complete(userId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '取消预约' })
|
||||
@ApiResponse({ status: 200, description: '取消成功' })
|
||||
async cancel(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.appointmentsService.cancel(userId, id);
|
||||
}
|
||||
}
|
||||
27
src/modules/appointments/appointments.module.ts
Normal file
27
src/modules/appointments/appointments.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { AppointmentsController } from './appointments.controller';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Appointment,
|
||||
AppointmentParticipant,
|
||||
Group,
|
||||
GroupMember,
|
||||
Game,
|
||||
User,
|
||||
]),
|
||||
],
|
||||
controllers: [AppointmentsController],
|
||||
providers: [AppointmentsService],
|
||||
exports: [AppointmentsService],
|
||||
})
|
||||
export class AppointmentsModule {}
|
||||
396
src/modules/appointments/appointments.service.spec.ts
Normal file
396
src/modules/appointments/appointments.service.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
enum AppointmentStatus {
|
||||
PENDING = 'pending',
|
||||
CONFIRMED = 'confirmed',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
describe('AppointmentsService', () => {
|
||||
let service: AppointmentsService;
|
||||
let mockAppointmentRepository: any;
|
||||
let mockParticipantRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
let mockGameRepository: any;
|
||||
let mockUserRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
|
||||
const mockGame = { id: 'game-1', name: '测试游戏' };
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockAppointment = {
|
||||
id: 'appointment-1',
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
creatorId: 'user-1',
|
||||
title: '周末开黑',
|
||||
description: '描述',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
endTime: new Date('2024-01-20T23:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
status: AppointmentStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockParticipant = {
|
||||
id: 'participant-1',
|
||||
appointmentId: 'appointment-1',
|
||||
userId: 'user-1',
|
||||
status: 'accepted',
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockAppointmentRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockParticipantRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
count: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
mockGameRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
clearByPrefix: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AppointmentsService,
|
||||
{
|
||||
provide: getRepositoryToken(Appointment),
|
||||
useValue: mockAppointmentRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppointmentParticipant),
|
||||
useValue: mockParticipantRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Game),
|
||||
useValue: mockGameRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: mockCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AppointmentsService>(AppointmentsService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建预约', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGameRepository.findOne.mockResolvedValue(mockGame);
|
||||
mockAppointmentRepository.create.mockReturnValue(mockAppointment);
|
||||
mockAppointmentRepository.save.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.create.mockReturnValue(mockParticipant);
|
||||
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
|
||||
mockAppointmentRepository.findOne.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
participants: [mockParticipant],
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.title).toBe('周末开黑');
|
||||
expect(mockAppointmentRepository.save).toHaveBeenCalled();
|
||||
expect(mockParticipantRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在游戏不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGameRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
gameId: 'game-1',
|
||||
title: '周末开黑',
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
maxParticipants: 5,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取预约列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockAppointment], 1]),
|
||||
};
|
||||
|
||||
mockAppointmentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取预约详情', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('appointment-1');
|
||||
});
|
||||
|
||||
it('应该在预约不存在时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('appointment-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新预约', async () => {
|
||||
mockAppointmentRepository.findOne
|
||||
.mockResolvedValueOnce(mockAppointment)
|
||||
.mockResolvedValueOnce({
|
||||
...mockAppointment,
|
||||
title: '更新后的标题',
|
||||
group: mockGroup,
|
||||
game: mockGame,
|
||||
creator: mockUser,
|
||||
});
|
||||
mockAppointmentRepository.save.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
title: '更新后的标题',
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'appointment-1', {
|
||||
title: '更新后的标题',
|
||||
});
|
||||
|
||||
expect(result.title).toBe('更新后的标题');
|
||||
});
|
||||
|
||||
it('应该在非创建者更新时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'appointment-1', { title: '新标题' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('应该成功取消预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockAppointmentRepository.save.mockResolvedValue({
|
||||
...mockAppointment,
|
||||
status: AppointmentStatus.CANCELLED,
|
||||
});
|
||||
|
||||
const result = await service.cancel('user-1', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在非创建者取消时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.cancel('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
it('应该成功加入预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
mockParticipantRepository.count.mockResolvedValue(3);
|
||||
mockParticipantRepository.create.mockReturnValue(mockParticipant);
|
||||
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
|
||||
|
||||
const result = await service.join('user-2', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockParticipantRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在预约已满时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
mockParticipantRepository.count.mockResolvedValue(5);
|
||||
|
||||
await expect(
|
||||
service.join('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在已加入时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
|
||||
|
||||
await expect(
|
||||
service.join('user-1', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leave', () => {
|
||||
it('应该成功离开预约', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
|
||||
mockParticipantRepository.remove.mockResolvedValue(mockParticipant);
|
||||
|
||||
const result = await service.leave('user-1', 'appointment-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockParticipantRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在创建者尝试离开时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
await expect(
|
||||
service.leave('user-1', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在未加入时抛出异常', async () => {
|
||||
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
|
||||
mockParticipantRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.leave('user-2', 'appointment-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
512
src/modules/appointments/appointments.service.ts
Normal file
512
src/modules/appointments/appointments.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between, LessThan, MoreThan } from 'typeorm';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import {
|
||||
CreateAppointmentDto,
|
||||
UpdateAppointmentDto,
|
||||
QueryAppointmentsDto,
|
||||
} from './dto/appointment.dto';
|
||||
import { AppointmentStatus, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppointmentsService {
|
||||
private readonly CACHE_PREFIX = 'appointment';
|
||||
private readonly CACHE_TTL = 300; // 5分钟
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Appointment)
|
||||
private appointmentRepository: Repository<Appointment>,
|
||||
@InjectRepository(AppointmentParticipant)
|
||||
private participantRepository: Repository<AppointmentParticipant>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
@InjectRepository(Game)
|
||||
private gameRepository: Repository<Game>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建预约
|
||||
*/
|
||||
async create(userId: string, createDto: CreateAppointmentDto) {
|
||||
const { groupId, gameId, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户是否在小组中
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证游戏是否存在
|
||||
const game = await this.gameRepository.findOne({
|
||||
where: { id: gameId, isActive: true },
|
||||
});
|
||||
if (!game) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GAME_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建预约
|
||||
const appointment = this.appointmentRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
gameId,
|
||||
initiatorId: userId,
|
||||
status: AppointmentStatus.OPEN,
|
||||
});
|
||||
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
// 创建者自动加入预约
|
||||
const participant = this.participantRepository.create({
|
||||
appointmentId: appointment.id,
|
||||
userId,
|
||||
});
|
||||
await this.participantRepository.save(participant);
|
||||
|
||||
return this.findOne(appointment.id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预约列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QueryAppointmentsDto) {
|
||||
const {
|
||||
groupId,
|
||||
gameId,
|
||||
status,
|
||||
startTime,
|
||||
endTime,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.appointmentRepository
|
||||
.createQueryBuilder('appointment')
|
||||
.leftJoinAndSelect('appointment.group', 'group')
|
||||
.leftJoinAndSelect('appointment.game', 'game')
|
||||
.leftJoinAndSelect('appointment.creator', 'creator')
|
||||
.leftJoinAndSelect('appointment.participants', 'participants')
|
||||
.leftJoinAndSelect('participants.user', 'participantUser');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
queryBuilder.andWhere('appointment.groupId = :groupId', { groupId });
|
||||
}
|
||||
|
||||
if (gameId) {
|
||||
queryBuilder.andWhere('appointment.gameId = :gameId', { gameId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('appointment.status = :status', { status });
|
||||
}
|
||||
|
||||
if (startTime && endTime) {
|
||||
queryBuilder.andWhere('appointment.startTime BETWEEN :startTime AND :endTime', {
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
} else if (startTime) {
|
||||
queryBuilder.andWhere('appointment.startTime >= :startTime', { startTime });
|
||||
} else if (endTime) {
|
||||
queryBuilder.andWhere('appointment.startTime <= :endTime', { endTime });
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('appointment.startTime', 'ASC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.formatAppointment(item, userId)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我参与的预约
|
||||
*/
|
||||
async findMyAppointments(userId: string, queryDto: QueryAppointmentsDto) {
|
||||
const { status, page = 1, limit = 10 } = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.appointmentRepository
|
||||
.createQueryBuilder('appointment')
|
||||
.innerJoin('appointment.participants', 'participant', 'participant.userId = :userId', {
|
||||
userId,
|
||||
})
|
||||
.leftJoinAndSelect('appointment.group', 'group')
|
||||
.leftJoinAndSelect('appointment.game', 'game')
|
||||
.leftJoinAndSelect('appointment.creator', 'creator')
|
||||
.leftJoinAndSelect('appointment.participants', 'participants')
|
||||
.leftJoinAndSelect('participants.user', 'participantUser');
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('appointment.status = :status', { status });
|
||||
}
|
||||
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('appointment.startTime', 'ASC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.formatAppointment(item, userId)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预约详情
|
||||
*/
|
||||
async findOne(id: string, userId?: string) {
|
||||
// 先查缓存
|
||||
const cacheKey = userId ? `${id}_${userId}` : id;
|
||||
const cached = this.cacheService.get<any>(cacheKey, { prefix: this.CACHE_PREFIX });
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'game', 'creator', 'participants', 'participants.user'],
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const result = this.formatAppointment(appointment, userId);
|
||||
|
||||
// 写入缓存
|
||||
this.cacheService.set(cacheKey, result, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
ttl: this.CACHE_TTL,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入预约(使用原子更新防止并发竞态条件)
|
||||
*/
|
||||
async join(userId: string, appointmentId: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查预约状态
|
||||
if (appointment.status === AppointmentStatus.CANCELLED) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_CLOSED,
|
||||
message: '预约已取消',
|
||||
});
|
||||
}
|
||||
|
||||
if (appointment.status === AppointmentStatus.FINISHED) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_CLOSED,
|
||||
message: '预约已完成',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否在小组中
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: appointment.groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已经参与
|
||||
const existingParticipant = await this.participantRepository.findOne({
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
if (existingParticipant) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.ALREADY_JOINED,
|
||||
message: ErrorMessage[ErrorCode.ALREADY_JOINED],
|
||||
});
|
||||
}
|
||||
|
||||
// 使用原子更新:只有当当前参与人数小于最大人数时才成功
|
||||
const updateResult = await this.appointmentRepository
|
||||
.createQueryBuilder()
|
||||
.update(Appointment)
|
||||
.set({
|
||||
currentParticipants: () => 'currentParticipants + 1',
|
||||
})
|
||||
.where('id = :id', { id: appointmentId })
|
||||
.andWhere('currentParticipants < maxParticipants')
|
||||
.execute();
|
||||
|
||||
// 如果影响的行数为0,说明预约已满
|
||||
if (updateResult.affected === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.APPOINTMENT_FULL,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_FULL],
|
||||
});
|
||||
}
|
||||
|
||||
// 加入预约
|
||||
const participant = this.participantRepository.create({
|
||||
appointmentId,
|
||||
userId,
|
||||
});
|
||||
await this.participantRepository.save(participant);
|
||||
|
||||
return this.findOne(appointmentId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出预约
|
||||
*/
|
||||
async leave(userId: string, appointmentId: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建者不能退出
|
||||
if (appointment.initiatorId === userId) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '创建者不能退出预约',
|
||||
});
|
||||
}
|
||||
|
||||
const participant = await this.participantRepository.findOne({
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NOT_JOINED,
|
||||
message: ErrorMessage[ErrorCode.NOT_JOINED],
|
||||
});
|
||||
}
|
||||
|
||||
await this.participantRepository.remove(participant);
|
||||
|
||||
return { message: '已退出预约' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预约
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateAppointmentDto) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
Object.assign(appointment, updateDto);
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
// 清除缓存(包括有userId和无userId的两种情况)
|
||||
this.cacheService.clearByPrefix(`${this.CACHE_PREFIX}:${id}`);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
*/
|
||||
async cancel(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
appointment.status = AppointmentStatus.CANCELLED;
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return { message: '预约已取消' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认预约
|
||||
*/
|
||||
async confirm(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['participants'],
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
// 检查是否已满员
|
||||
if (appointment.participants.length >= appointment.maxParticipants) {
|
||||
appointment.status = AppointmentStatus.FULL;
|
||||
}
|
||||
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成预约
|
||||
*/
|
||||
async complete(userId: string, id: string) {
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
|
||||
|
||||
appointment.status = AppointmentStatus.FINISHED;
|
||||
await this.appointmentRepository.save(appointment);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
initiatorId: string,
|
||||
): Promise<void> {
|
||||
// 如果是创建者,直接通过
|
||||
if (userId === initiatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是小组管理员或组长
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化预约数据
|
||||
*/
|
||||
private formatAppointment(appointment: Appointment, userId?: string) {
|
||||
const participantCount = appointment.participants?.length || 0;
|
||||
const isParticipant = userId
|
||||
? appointment.participants?.some((p) => p.userId === userId)
|
||||
: false;
|
||||
const isCreator = userId ? appointment.initiatorId === userId : false;
|
||||
|
||||
return {
|
||||
...appointment,
|
||||
participantCount,
|
||||
isParticipant,
|
||||
isCreator,
|
||||
isFull: participantCount >= appointment.maxParticipants,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
src/modules/appointments/dto/appointment.dto.ts
Normal file
189
src/modules/appointments/dto/appointment.dto.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { AppointmentStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateAppointmentDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏ID不能为空' })
|
||||
gameId: string;
|
||||
|
||||
@ApiProperty({ description: '预约标题' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '预约描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '预约开始时间' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '预约结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '最大参与人数', example: 5 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
maxParticipants: number;
|
||||
}
|
||||
|
||||
export class UpdateAppointmentDto {
|
||||
@ApiProperty({ description: '预约标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '预约描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '预约开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '预约结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '最大参与人数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxParticipants?: number;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
|
||||
@IsEnum(AppointmentStatus)
|
||||
@IsOptional()
|
||||
status?: AppointmentStatus;
|
||||
}
|
||||
|
||||
export class JoinAppointmentDto {
|
||||
@ApiProperty({ description: '预约ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约ID不能为空' })
|
||||
appointmentId: string;
|
||||
}
|
||||
|
||||
export class QueryAppointmentsDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
gameId?: string;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
|
||||
@IsEnum(AppointmentStatus)
|
||||
@IsOptional()
|
||||
status?: AppointmentStatus;
|
||||
|
||||
@ApiProperty({ description: '开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PollOptionDto {
|
||||
@ApiProperty({ description: '选项时间' })
|
||||
@IsDateString()
|
||||
time: Date;
|
||||
|
||||
@ApiProperty({ description: '选项描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreatePollDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '游戏ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏ID不能为空' })
|
||||
gameId: string;
|
||||
|
||||
@ApiProperty({ description: '投票标题' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '投票标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '投票描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '投票选项', type: [PollOptionDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PollOptionDto)
|
||||
options: PollOptionDto[];
|
||||
|
||||
@ApiProperty({ description: '投票截止时间' })
|
||||
@IsDateString()
|
||||
deadline: Date;
|
||||
}
|
||||
|
||||
export class VoteDto {
|
||||
@ApiProperty({ description: '投票ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '投票ID不能为空' })
|
||||
pollId: string;
|
||||
|
||||
@ApiProperty({ description: '选项索引' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
optionIndex: number;
|
||||
}
|
||||
84
src/modules/assets/assets.controller.ts
Normal file
84
src/modules/assets/assets.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto, ReturnAssetDto } from './dto/asset.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('assets')
|
||||
@Controller('assets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建资产(管理员)' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateAssetDto) {
|
||||
return this.assetsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get('group/:groupId')
|
||||
@ApiOperation({ summary: '查询小组资产列表' })
|
||||
findAll(@Param('groupId') groupId: string) {
|
||||
return this.assetsService.findAll(groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询资产详情' })
|
||||
findOne(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.findOne(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新资产(管理员)' })
|
||||
update(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateAssetDto,
|
||||
) {
|
||||
return this.assetsService.update(user.id, id, updateDto);
|
||||
}
|
||||
|
||||
@Post(':id/borrow')
|
||||
@ApiOperation({ summary: '借用资产' })
|
||||
borrow(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() borrowDto: BorrowAssetDto,
|
||||
) {
|
||||
return this.assetsService.borrow(user.id, id, borrowDto);
|
||||
}
|
||||
|
||||
@Post(':id/return')
|
||||
@ApiOperation({ summary: '归还资产' })
|
||||
returnAsset(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() returnDto: ReturnAssetDto,
|
||||
) {
|
||||
return this.assetsService.return(user.id, id, returnDto.note);
|
||||
}
|
||||
|
||||
@Get(':id/logs')
|
||||
@ApiOperation({ summary: '查询资产借还记录' })
|
||||
getLogs(@Param('id') id: string) {
|
||||
return this.assetsService.getLogs(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除资产(管理员)' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
16
src/modules/assets/assets.module.ts
Normal file
16
src/modules/assets/assets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
exports: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
242
src/modules/assets/assets.service.spec.ts
Normal file
242
src/modules/assets/assets.service.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { AssetType, AssetStatus, GroupMemberRole, AssetLogAction } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('AssetsService', () => {
|
||||
let service: AssetsService;
|
||||
let assetRepository: Repository<Asset>;
|
||||
let assetLogRepository: Repository<AssetLog>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockAsset = {
|
||||
id: 'asset-1',
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
accountCredentials: 'encrypted-data',
|
||||
quantity: 1,
|
||||
status: AssetStatus.AVAILABLE,
|
||||
currentBorrowerId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssetsService,
|
||||
{
|
||||
provide: getRepositoryToken(Asset),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AssetLog),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssetsService>(AssetsService);
|
||||
assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset));
|
||||
assetLogRepository = module.get<Repository<AssetLog>>(getRepositoryToken(AssetLog));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建资产', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'create').mockReturnValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(groupRepository.findOne).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
expect(groupMemberRepository.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回资产列表', async () => {
|
||||
jest.spyOn(assetRepository, 'find').mockResolvedValue([mockAsset] as any);
|
||||
|
||||
const result = await service.findAll('group-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].accountCredentials).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('borrow', () => {
|
||||
it('应该成功借用资产', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue({ ...mockAsset, status: AssetStatus.IN_USE } as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.borrow('user-1', 'asset-1', borrowDto);
|
||||
|
||||
expect(result.message).toBe('借用成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
expect(assetLogRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('资产不可用时应该抛出异常', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
status: AssetStatus.IN_USE,
|
||||
} as any);
|
||||
|
||||
await expect(service.borrow('user-1', 'asset-1', borrowDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return', () => {
|
||||
it('应该成功归还资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-1',
|
||||
} as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.return('user-1', 'asset-1', '已归还');
|
||||
|
||||
expect(result.message).toBe('归还成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非借用人归还时应该抛出异常', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-2',
|
||||
} as any);
|
||||
|
||||
await expect(service.return('user-1', 'asset-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'remove').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.remove('user-1', 'asset-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(assetRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
355
src/modules/assets/assets.service.ts
Normal file
355
src/modules/assets/assets.service.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto } from './dto/asset.dto';
|
||||
import { AssetStatus, AssetLogAction, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
private readonly ENCRYPTION_KEY = process.env.ASSET_ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Asset)
|
||||
private assetRepository: Repository<Asset>,
|
||||
@InjectRepository(AssetLog)
|
||||
private assetLogRepository: Repository<AssetLog>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 加密账号凭据
|
||||
*/
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账号凭据
|
||||
*/
|
||||
private decrypt(encrypted: string): string {
|
||||
const parts = encrypted.split(':');
|
||||
const ivStr = parts.shift();
|
||||
if (!ivStr) throw new Error('Invalid encrypted data');
|
||||
const iv = Buffer.from(ivStr, 'hex');
|
||||
const encryptedText = parts.join(':');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资产
|
||||
*/
|
||||
async create(userId: string, createDto: CreateAssetDto) {
|
||||
const { groupId, accountCredentials, ...rest } = createDto;
|
||||
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const asset = this.assetRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
accountCredentials: accountCredentials ? this.encrypt(accountCredentials) : undefined,
|
||||
});
|
||||
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(asset.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询资产列表
|
||||
*/
|
||||
async findAll(groupId: string) {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['group'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return assets.map((asset) => ({
|
||||
...asset,
|
||||
accountCredentials: undefined, // 不返回加密凭据
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个资产详情(包含解密后的凭据,需管理员权限)
|
||||
*/
|
||||
async findOne(id: string, userId?: string) {
|
||||
const asset = await this.assetRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group'],
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果提供了userId,验证权限后返回解密凭据
|
||||
if (userId) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (membership && (membership.role === GroupMemberRole.ADMIN || membership.role === GroupMemberRole.OWNER)) {
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: asset.accountCredentials ? this.decrypt(asset.accountCredentials) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资产
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateDto.accountCredentials) {
|
||||
updateDto.accountCredentials = this.encrypt(updateDto.accountCredentials);
|
||||
}
|
||||
|
||||
Object.assign(asset, updateDto);
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 借用资产(使用事务和悲观锁防止并发问题)
|
||||
*/
|
||||
async borrow(userId: string, id: string, borrowDto: BorrowAssetDto) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发借用
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.status !== AssetStatus.AVAILABLE) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '资产不可用',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户在小组中
|
||||
const membership = await queryRunner.manager.findOne(GroupMember, {
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.IN_USE;
|
||||
asset.currentBorrowerId = userId;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.BORROW,
|
||||
note: borrowDto.reason,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '借用成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归还资产(使用事务确保数据一致性)
|
||||
*/
|
||||
async return(userId: string, id: string, note?: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发问题
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.currentBorrowerId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '无权归还此资产',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.AVAILABLE;
|
||||
asset.currentBorrowerId = null;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.RETURN,
|
||||
note,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '归还成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产借还记录
|
||||
*/
|
||||
async getLogs(id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await this.assetLogRepository.find({
|
||||
where: { assetId: id },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资产
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.assetRepository.remove(asset);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
84
src/modules/assets/dto/asset.dto.ts
Normal file
84
src/modules/assets/dto/asset.dto.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetType, AssetStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '资产类型', enum: AssetType })
|
||||
@IsEnum(AssetType)
|
||||
type: AssetType;
|
||||
|
||||
@ApiProperty({ description: '资产名称', example: '公用游戏账号' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据(将加密存储)', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@ApiProperty({ description: '资产名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AssetStatus, required: false })
|
||||
@IsEnum(AssetStatus)
|
||||
@IsOptional()
|
||||
status?: AssetStatus;
|
||||
}
|
||||
|
||||
export class BorrowAssetDto {
|
||||
@ApiProperty({ description: '借用理由', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ReturnAssetDto {
|
||||
@ApiProperty({ description: '归还备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
note?: string;
|
||||
}
|
||||
140
src/modules/auth/auth.controller.spec.ts
Normal file
140
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
describe('AuthController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let authService: AuthService;
|
||||
|
||||
const mockAuthService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
await app.init();
|
||||
|
||||
authService = moduleFixture.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/api/auth/register (POST)', () => {
|
||||
it('应该成功注册并返回用户信息和Token', () => {
|
||||
const registerDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.register.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send(registerDto)
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('user');
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在缺少必填字段时返回400', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
// 缺少密码
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/login (POST)', () => {
|
||||
it('应该成功登录', () => {
|
||||
const loginDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.login.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send(loginDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/refresh (POST)', () => {
|
||||
it('应该成功刷新Token', () => {
|
||||
const refreshDto = {
|
||||
refreshToken: 'valid-refresh-token',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.refreshToken.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send(refreshDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
37
src/modules/auth/auth.controller.ts
Normal file
37
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiResponse({ status: 201, description: '注册成功' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
async login(@Body() loginDto: LoginDto, @Ip() ip: string) {
|
||||
return this.authService.login(loginDto, ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
}
|
||||
30
src/modules/auth/auth.module.ts
Normal file
30
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('jwt.secret'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('jwt.expiresIn'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
312
src/modules/auth/auth.service.spec.ts
Normal file
312
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userRepository: Repository<User>;
|
||||
let jwtService: JwtService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
password: 'hashedPassword',
|
||||
role: UserRole.USER,
|
||||
isMember: false,
|
||||
memberExpiredAt: null,
|
||||
lastLoginAt: null,
|
||||
lastLoginIp: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
signAsync: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config = {
|
||||
'jwt.secret': 'test-secret',
|
||||
'jwt.accessExpiresIn': '15m',
|
||||
'jwt.refreshExpiresIn': '7d',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: mockJwtService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('应该成功注册新用户', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'new@example.com',
|
||||
phone: '13900139000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱检查
|
||||
.mockResolvedValueOnce(null); // 手机号检查
|
||||
|
||||
mockUserRepository.create.mockReturnValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
password: 'hashedPassword',
|
||||
});
|
||||
|
||||
mockUserRepository.save.mockResolvedValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.register(registerDto);
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在邮箱已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'existing@example.com',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValueOnce(mockUser);
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在手机号已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
phone: '13800138000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱不存在
|
||||
.mockResolvedValueOnce(mockUser); // 手机号已存在
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在缺少邮箱和手机号时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('应该使用用户名成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { username: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该使用邮箱成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'nonexistent',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在密码错误时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'WrongPassword',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('CorrectPassword'),
|
||||
});
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('应该成功刷新Token', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'test-user-id',
|
||||
username: 'testuser',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('new-access-token')
|
||||
.mockResolvedValueOnce('new-refresh-token');
|
||||
|
||||
const result = await service.refreshToken(refreshToken);
|
||||
|
||||
expect(result).toHaveProperty('accessToken', 'new-access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'new-refresh-token');
|
||||
expect(mockJwtService.verify).toHaveBeenCalledWith('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('应该在Token无效时抛出异常', async () => {
|
||||
const refreshToken = 'invalid-token';
|
||||
|
||||
mockJwtService.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'nonexistent-user-id',
|
||||
username: 'nonexistent',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUser', () => {
|
||||
it('应该返回用户信息(排除密码)', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.validateUser('test-user-id');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('test-user-id');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('应该在用户不存在时返回null', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateUser('nonexistent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/modules/auth/auth.service.ts
Normal file
233
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException, HttpException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(registerDto: RegisterDto) {
|
||||
const { username, password, email, phone } = registerDto;
|
||||
|
||||
// 验证邮箱和手机号至少有一个
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: '邮箱和手机号至少填写一个',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [
|
||||
{ username },
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phone }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.username === username) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '用户名已存在',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (email && existingUser.email === email) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '邮箱已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (phone && existingUser.phone === phone) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '手机号已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await CryptoUtil.hashPassword(password);
|
||||
|
||||
// 创建用户
|
||||
const user = this.userRepository.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email,
|
||||
phone,
|
||||
});
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
isMember: user.isMember,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(loginDto: LoginDto, ip?: string) {
|
||||
const { account, password } = loginDto;
|
||||
|
||||
// 查找用户(支持用户名、邮箱、手机号登录)
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.username = :account', { account })
|
||||
.orWhere('user.email = :account', { account })
|
||||
.orWhere('user.phone = :account', { account })
|
||||
.addSelect('user.password')
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await CryptoUtil.comparePassword(
|
||||
password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.PASSWORD_ERROR,
|
||||
message: ErrorMessage[ErrorCode.PASSWORD_ERROR],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
user.lastLoginIp = ip || null;
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
isMember: user.isMember,
|
||||
memberExpireAt: user.memberExpireAt,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
});
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return this.generateTokens(user);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.TOKEN_INVALID,
|
||||
message: ErrorMessage[ErrorCode.TOKEN_INVALID],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户
|
||||
*/
|
||||
async validateUser(userId: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 access token 和 refresh token
|
||||
*/
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.secret'),
|
||||
expiresIn: this.configService.get('jwt.expiresIn'),
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
45
src/modules/auth/dto/auth.dto.ts
Normal file
45
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '用户名', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '用户名不能为空' })
|
||||
@MinLength(3, { message: '用户名至少3个字符' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
@MinLength(6, { message: '密码至少6个字符' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: '邮箱', example: 'john@example.com', required: false })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800138000', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '用户名/邮箱/手机号', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '账号不能为空' })
|
||||
account: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refreshToken: string;
|
||||
}
|
||||
33
src/modules/auth/jwt.strategy.ts
Normal file
33
src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret') || 'default-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.UNAUTHORIZED,
|
||||
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
49
src/modules/bets/bets.controller.ts
Normal file
49
src/modules/bets/bets.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BetsService } from './bets.service';
|
||||
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('bets')
|
||||
@Controller('bets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class BetsController {
|
||||
constructor(private readonly betsService: BetsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建竞猜下注' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateBetDto) {
|
||||
return this.betsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get('appointment/:appointmentId')
|
||||
@ApiOperation({ summary: '查询预约的所有竞猜' })
|
||||
findAll(@Param('appointmentId') appointmentId: string) {
|
||||
return this.betsService.findAll(appointmentId);
|
||||
}
|
||||
|
||||
@Post('appointment/:appointmentId/settle')
|
||||
@ApiOperation({ summary: '结算竞猜(管理员)' })
|
||||
settle(
|
||||
@CurrentUser() user,
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Body() settleDto: SettleBetDto,
|
||||
) {
|
||||
return this.betsService.settle(user.id, appointmentId, settleDto);
|
||||
}
|
||||
|
||||
@Post('appointment/:appointmentId/cancel')
|
||||
@ApiOperation({ summary: '取消竞猜' })
|
||||
cancel(@Param('appointmentId') appointmentId: string) {
|
||||
return this.betsService.cancel(appointmentId);
|
||||
}
|
||||
}
|
||||
16
src/modules/bets/bets.module.ts
Normal file
16
src/modules/bets/bets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BetsController } from './bets.controller';
|
||||
import { BetsService } from './bets.service';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Bet, Appointment, Point, GroupMember])],
|
||||
controllers: [BetsController],
|
||||
providers: [BetsService],
|
||||
exports: [BetsService],
|
||||
})
|
||||
export class BetsModule {}
|
||||
283
src/modules/bets/bets.service.spec.ts
Normal file
283
src/modules/bets/bets.service.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { BetsService } from './bets.service';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
|
||||
import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('BetsService', () => {
|
||||
let service: BetsService;
|
||||
let betRepository: Repository<Bet>;
|
||||
let appointmentRepository: Repository<Appointment>;
|
||||
let pointRepository: Repository<Point>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockAppointment = {
|
||||
id: 'appointment-1',
|
||||
groupId: 'group-1',
|
||||
title: '测试预约',
|
||||
status: AppointmentStatus.PENDING,
|
||||
};
|
||||
|
||||
const mockBet = {
|
||||
id: 'bet-1',
|
||||
appointmentId: 'appointment-1',
|
||||
userId: 'user-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
status: BetStatus.PENDING,
|
||||
winAmount: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BetsService,
|
||||
{
|
||||
provide: getRepositoryToken(Bet),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Appointment),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Point),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BetsService>(BetsService);
|
||||
betRepository = module.get<Repository<Bet>>(getRepositoryToken(Bet));
|
||||
appointmentRepository = module.get<Repository<Appointment>>(getRepositoryToken(Appointment));
|
||||
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建竞猜下注', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
jest.spyOn(betRepository, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(betRepository, 'create').mockReturnValue(mockBet as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue(mockBet as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(betRepository.save).toHaveBeenCalled();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('预约不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('预约已结束时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue({
|
||||
...mockAppointment,
|
||||
status: AppointmentStatus.FINISHED,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('积分不足时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 100,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '50' });
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('重复下注时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
appointmentId: 'appointment-1',
|
||||
betOption: '胜',
|
||||
amount: 10,
|
||||
};
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
jest.spyOn(betRepository, 'findOne').mockResolvedValue(mockBet as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回竞猜列表及统计', async () => {
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 10 },
|
||||
{ ...mockBet, id: 'bet-2', betOption: '胜', amount: 20 },
|
||||
{ ...mockBet, id: 'bet-3', betOption: '负', amount: 15 },
|
||||
];
|
||||
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
|
||||
const result = await service.findAll('appointment-1');
|
||||
|
||||
expect(result.bets).toHaveLength(3);
|
||||
expect(result.totalBets).toBe(3);
|
||||
expect(result.totalAmount).toBe(45);
|
||||
expect(result.stats['胜']).toBeDefined();
|
||||
expect(result.stats['胜'].count).toBe(2);
|
||||
expect(result.stats['胜'].totalAmount).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settle', () => {
|
||||
it('应该成功结算竞猜', async () => {
|
||||
const settleDto = { winningOption: '胜' };
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 30 },
|
||||
{ ...mockBet, id: 'bet-2', betOption: '负', amount: 20 },
|
||||
];
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.settle('user-1', 'appointment-1', settleDto);
|
||||
|
||||
expect(result.message).toBe('结算成功');
|
||||
expect(result.winners).toBe(1);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const settleDto = { winningOption: '胜' };
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('没有人下注该选项时应该抛出异常', async () => {
|
||||
const settleDto = { winningOption: '平' };
|
||||
const bets = [
|
||||
{ ...mockBet, betOption: '胜', amount: 30 },
|
||||
];
|
||||
|
||||
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
|
||||
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('应该成功取消竞猜并退还积分', async () => {
|
||||
const bets = [
|
||||
{ ...mockBet, status: BetStatus.PENDING, appointment: mockAppointment },
|
||||
];
|
||||
|
||||
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
|
||||
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.cancel('appointment-1');
|
||||
|
||||
expect(result.message).toBe('竞猜已取消,积分已退还');
|
||||
expect(betRepository.save).toHaveBeenCalled();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
302
src/modules/bets/bets.service.ts
Normal file
302
src/modules/bets/bets.service.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Bet } from '../../entities/bet.entity';
|
||||
import { Appointment } from '../../entities/appointment.entity';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
|
||||
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BetsService {
|
||||
constructor(
|
||||
@InjectRepository(Bet)
|
||||
private betRepository: Repository<Bet>,
|
||||
@InjectRepository(Appointment)
|
||||
private appointmentRepository: Repository<Appointment>,
|
||||
@InjectRepository(Point)
|
||||
private pointRepository: Repository<Point>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建竞猜下注
|
||||
*/
|
||||
async create(userId: string, createDto: CreateBetDto) {
|
||||
const { appointmentId, amount, betOption } = createDto;
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 验证预约存在
|
||||
const appointment = await queryRunner.manager.findOne(Appointment, {
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证预约状态
|
||||
if (appointment.status !== AppointmentStatus.PENDING) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '预约已结束,无法下注',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户积分是否足够
|
||||
const balance = await queryRunner.manager
|
||||
.createQueryBuilder(Point, 'point')
|
||||
.select('SUM(point.amount)', 'total')
|
||||
.where('point.userId = :userId', { userId })
|
||||
.andWhere('point.groupId = :groupId', { groupId: appointment.groupId })
|
||||
.getRawOne();
|
||||
|
||||
const currentBalance = parseInt(balance.total || '0');
|
||||
if (currentBalance < amount) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INSUFFICIENT_POINTS,
|
||||
message: '积分不足',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已下注
|
||||
const existingBet = await queryRunner.manager.findOne(Bet, {
|
||||
where: { appointmentId, userId },
|
||||
});
|
||||
|
||||
if (existingBet) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '已下注,不能重复下注',
|
||||
});
|
||||
}
|
||||
|
||||
const bet = queryRunner.manager.create(Bet, {
|
||||
appointmentId,
|
||||
userId,
|
||||
betOption,
|
||||
amount,
|
||||
});
|
||||
|
||||
const savedBet = await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 扣除积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: -amount,
|
||||
reason: '竞猜下注',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: savedBet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return savedBet;
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询预约的所有竞猜
|
||||
*/
|
||||
async findAll(appointmentId: string) {
|
||||
const bets = await this.betRepository.find({
|
||||
where: { appointmentId },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// 统计各选项的下注情况
|
||||
const stats = bets.reduce((acc, bet) => {
|
||||
if (!acc[bet.betOption]) {
|
||||
acc[bet.betOption] = { count: 0, totalAmount: 0 };
|
||||
}
|
||||
acc[bet.betOption].count++;
|
||||
acc[bet.betOption].totalAmount += bet.amount;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
bets,
|
||||
stats,
|
||||
totalBets: bets.length,
|
||||
totalAmount: bets.reduce((sum, bet) => sum + bet.amount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算竞猜(管理员)
|
||||
*/
|
||||
async settle(userId: string, appointmentId: string, settleDto: SettleBetDto) {
|
||||
const { winningOption } = settleDto;
|
||||
|
||||
// 验证预约存在
|
||||
const appointment = await this.appointmentRepository.findOne({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.APPOINTMENT_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: appointment.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 获取所有下注
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
});
|
||||
|
||||
// 计算总奖池和赢家总下注
|
||||
const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
const winningBets = bets.filter((bet) => bet.betOption === winningOption);
|
||||
const winningTotal = winningBets.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
|
||||
if (winningTotal === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '没有人下注该选项',
|
||||
});
|
||||
}
|
||||
|
||||
// 按比例分配奖池,修复精度损失问题
|
||||
let distributedAmount = 0;
|
||||
|
||||
for (let i = 0; i < winningBets.length; i++) {
|
||||
const bet = winningBets[i];
|
||||
let winAmount: number;
|
||||
|
||||
if (i === winningBets.length - 1) {
|
||||
// 最后一个赢家获得剩余所有积分,避免精度损失
|
||||
winAmount = totalPool - distributedAmount;
|
||||
} else {
|
||||
winAmount = Math.floor((bet.amount / winningTotal) * totalPool);
|
||||
distributedAmount += winAmount;
|
||||
}
|
||||
|
||||
bet.winAmount = winAmount;
|
||||
bet.status = BetStatus.WON;
|
||||
|
||||
// 返还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: appointment.groupId,
|
||||
amount: winAmount,
|
||||
reason: '竞猜获胜',
|
||||
description: `预约: ${appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
|
||||
// 更新输家状态
|
||||
for (const bet of bets) {
|
||||
if (bet.betOption !== winningOption) {
|
||||
bet.status = BetStatus.LOST;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
message: '结算成功',
|
||||
winningOption,
|
||||
totalPool,
|
||||
winners: winningBets.length,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消竞猜(预约取消时)
|
||||
*/
|
||||
async cancel(appointmentId: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const bets = await queryRunner.manager.find(Bet, {
|
||||
where: { appointmentId },
|
||||
relations: ['appointment'],
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
if (bet.status === BetStatus.PENDING) {
|
||||
bet.status = BetStatus.CANCELLED;
|
||||
await queryRunner.manager.save(Bet, bet);
|
||||
|
||||
// 退还积分
|
||||
const pointRecord = queryRunner.manager.create(Point, {
|
||||
userId: bet.userId,
|
||||
groupId: bet.appointment.groupId,
|
||||
amount: bet.amount,
|
||||
reason: '竞猜取消退款',
|
||||
description: `预约: ${bet.appointment.title}`,
|
||||
relatedId: bet.id,
|
||||
});
|
||||
|
||||
await queryRunner.manager.save(Point, pointRecord);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '竞猜已取消,积分已退还' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/modules/bets/dto/bet.dto.ts
Normal file
31
src/modules/bets/dto/bet.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateBetDto {
|
||||
@ApiProperty({ description: '预约ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '预约ID不能为空' })
|
||||
appointmentId: string;
|
||||
|
||||
@ApiProperty({ description: '下注选项', example: '胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '下注选项不能为空' })
|
||||
betOption: string;
|
||||
|
||||
@ApiProperty({ description: '下注积分', example: 10 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class SettleBetDto {
|
||||
@ApiProperty({ description: '胜利选项', example: '胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '胜利选项不能为空' })
|
||||
winningOption: string;
|
||||
}
|
||||
68
src/modules/blacklist/blacklist.controller.ts
Normal file
68
src/modules/blacklist/blacklist.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import {
|
||||
CreateBlacklistDto,
|
||||
ReviewBlacklistDto,
|
||||
QueryBlacklistDto,
|
||||
} from './dto/blacklist.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('blacklist')
|
||||
@Controller('blacklist')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class BlacklistController {
|
||||
constructor(private readonly blacklistService: BlacklistService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '提交黑名单举报' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateBlacklistDto) {
|
||||
return this.blacklistService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询黑名单列表' })
|
||||
findAll(@Query() query: QueryBlacklistDto) {
|
||||
return this.blacklistService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('check/:targetGameId')
|
||||
@ApiOperation({ summary: '检查游戏ID是否在黑名单中' })
|
||||
checkBlacklist(@Param('targetGameId') targetGameId: string) {
|
||||
return this.blacklistService.checkBlacklist(targetGameId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询单个黑名单记录' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.blacklistService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id/review')
|
||||
@ApiOperation({ summary: '审核黑名单(管理员)' })
|
||||
review(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() reviewDto: ReviewBlacklistDto,
|
||||
) {
|
||||
return this.blacklistService.review(user.id, id, reviewDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除黑名单记录' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.blacklistService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
14
src/modules/blacklist/blacklist.module.ts
Normal file
14
src/modules/blacklist/blacklist.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlacklistController } from './blacklist.controller';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Blacklist, User])],
|
||||
controllers: [BlacklistController],
|
||||
providers: [BlacklistService],
|
||||
exports: [BlacklistService],
|
||||
})
|
||||
export class BlacklistModule {}
|
||||
272
src/modules/blacklist/blacklist.service.spec.ts
Normal file
272
src/modules/blacklist/blacklist.service.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlacklistService } from './blacklist.service';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { BlacklistStatus } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('BlacklistService', () => {
|
||||
let service: BlacklistService;
|
||||
let blacklistRepository: Repository<Blacklist>;
|
||||
let userRepository: Repository<User>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockBlacklist = {
|
||||
id: 'blacklist-1',
|
||||
reporterId: 'user-1',
|
||||
targetGameId: 'game-123',
|
||||
targetNickname: '违规玩家',
|
||||
reason: '恶意行为',
|
||||
proofImages: ['image1.jpg'],
|
||||
status: BlacklistStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: '举报人',
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BlacklistService,
|
||||
{
|
||||
provide: getRepositoryToken(Blacklist),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BlacklistService>(BlacklistService);
|
||||
blacklistRepository = module.get<Repository<Blacklist>>(getRepositoryToken(Blacklist));
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建黑名单举报', async () => {
|
||||
const createDto = {
|
||||
targetGameId: 'game-123',
|
||||
targetNickname: '违规玩家',
|
||||
reason: '恶意行为',
|
||||
proofImages: ['image1.jpg'],
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'create').mockReturnValue(mockBlacklist as any);
|
||||
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(mockBlacklist as any);
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(blacklistRepository.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
reporterId: 'user-1',
|
||||
status: BlacklistStatus.PENDING,
|
||||
});
|
||||
expect(blacklistRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回黑名单列表', async () => {
|
||||
const query = { status: BlacklistStatus.APPROVED };
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
|
||||
|
||||
const result = await service.findAll(query);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(blacklistRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该支持按状态筛选', async () => {
|
||||
const query = { status: BlacklistStatus.PENDING };
|
||||
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
|
||||
|
||||
await service.findAll(query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'blacklist.status = :status',
|
||||
{ status: BlacklistStatus.PENDING }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回单个黑名单记录', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.findOne('blacklist-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('blacklist-1');
|
||||
});
|
||||
|
||||
it('记录不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review', () => {
|
||||
it('应该成功审核黑名单(会员权限)', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
reviewNote: '确认违规',
|
||||
};
|
||||
|
||||
const updatedBlacklist = {
|
||||
...mockBlacklist,
|
||||
...reviewDto,
|
||||
reviewerId: 'user-1',
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'findOne')
|
||||
.mockResolvedValueOnce(mockBlacklist as any) // First call in review method
|
||||
.mockResolvedValueOnce(updatedBlacklist as any); // Second call in findOne at the end
|
||||
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(updatedBlacklist as any);
|
||||
|
||||
const result = await service.review('user-1', 'blacklist-1', reviewDto);
|
||||
|
||||
expect(result.status).toBe(BlacklistStatus.APPROVED);
|
||||
expect(blacklistRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非会员审核时应该抛出异常', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
|
||||
...mockUser,
|
||||
isMember: false,
|
||||
} as any);
|
||||
|
||||
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('用户不存在时应该抛出异常', async () => {
|
||||
const reviewDto = {
|
||||
status: BlacklistStatus.APPROVED,
|
||||
};
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBlacklist', () => {
|
||||
it('应该正确检查玩家是否在黑名单', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
status: BlacklistStatus.APPROVED,
|
||||
} as any);
|
||||
|
||||
const result = await service.checkBlacklist('game-123');
|
||||
|
||||
expect(result.isBlacklisted).toBe(true);
|
||||
expect(result.blacklist).toBeDefined();
|
||||
expect(blacklistRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
targetGameId: 'game-123',
|
||||
status: BlacklistStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('玩家不在黑名单时应该返回false', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const result = await service.checkBlacklist('game-123');
|
||||
|
||||
expect(result.isBlacklisted).toBe(false);
|
||||
expect(result.blacklist).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('举报人应该可以删除自己的举报', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.remove('user-1', 'blacklist-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(blacklistRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('会员应该可以删除任何举报', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
reporterId: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
|
||||
|
||||
const result = await service.remove('user-1', 'blacklist-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
});
|
||||
|
||||
it('非举报人且非会员删除时应该抛出异常', async () => {
|
||||
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
|
||||
...mockBlacklist,
|
||||
reporterId: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
|
||||
...mockUser,
|
||||
isMember: false,
|
||||
} as any);
|
||||
|
||||
await expect(service.remove('user-1', 'blacklist-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
src/modules/blacklist/blacklist.service.ts
Normal file
175
src/modules/blacklist/blacklist.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Blacklist } from '../../entities/blacklist.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import {
|
||||
CreateBlacklistDto,
|
||||
ReviewBlacklistDto,
|
||||
QueryBlacklistDto,
|
||||
} from './dto/blacklist.dto';
|
||||
import { BlacklistStatus } from '../../common/enums';
|
||||
import {
|
||||
ErrorCode,
|
||||
ErrorMessage,
|
||||
} from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BlacklistService {
|
||||
constructor(
|
||||
@InjectRepository(Blacklist)
|
||||
private blacklistRepository: Repository<Blacklist>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 提交黑名单举报
|
||||
*/
|
||||
async create(userId: string, createDto: CreateBlacklistDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = this.blacklistRepository.create({
|
||||
...createDto,
|
||||
reporterId: userId,
|
||||
status: BlacklistStatus.PENDING,
|
||||
});
|
||||
|
||||
await this.blacklistRepository.save(blacklist);
|
||||
|
||||
return this.findOne(blacklist.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询黑名单列表
|
||||
*/
|
||||
async findAll(query: QueryBlacklistDto) {
|
||||
const qb = this.blacklistRepository
|
||||
.createQueryBuilder('blacklist')
|
||||
.leftJoinAndSelect('blacklist.reporter', 'reporter')
|
||||
.leftJoinAndSelect('blacklist.reviewer', 'reviewer');
|
||||
|
||||
if (query.targetGameId) {
|
||||
qb.andWhere('blacklist.targetGameId LIKE :targetGameId', {
|
||||
targetGameId: `%${query.targetGameId}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('blacklist.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
qb.orderBy('blacklist.createdAt', 'DESC');
|
||||
|
||||
const blacklists = await qb.getMany();
|
||||
|
||||
return blacklists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个黑名单记录
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const blacklist = await this.blacklistRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['reporter', 'reviewer'],
|
||||
});
|
||||
|
||||
if (!blacklist) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.BLACKLIST_NOT_FOUND,
|
||||
message: '黑名单记录不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return blacklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核黑名单(管理员权限)
|
||||
*/
|
||||
async review(userId: string, id: string, reviewDto: ReviewBlacklistDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user || !user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要会员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = await this.findOne(id);
|
||||
|
||||
if (blacklist.status !== BlacklistStatus.PENDING) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '该记录已审核',
|
||||
});
|
||||
}
|
||||
|
||||
blacklist.status = reviewDto.status;
|
||||
if (reviewDto.reviewNote) {
|
||||
blacklist.reviewNote = reviewDto.reviewNote;
|
||||
}
|
||||
blacklist.reviewerId = userId;
|
||||
|
||||
await this.blacklistRepository.save(blacklist);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查游戏ID是否在黑名单中
|
||||
*/
|
||||
async checkBlacklist(targetGameId: string) {
|
||||
const blacklist = await this.blacklistRepository.findOne({
|
||||
where: {
|
||||
targetGameId,
|
||||
status: BlacklistStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isBlacklisted: !!blacklist,
|
||||
blacklist: blacklist || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除黑名单记录(仅举报人或管理员)
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const blacklist = await this.findOne(id);
|
||||
|
||||
if (blacklist.reporterId !== userId && !user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.blacklistRepository.remove(blacklist);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
59
src/modules/blacklist/dto/blacklist.dto.ts
Normal file
59
src/modules/blacklist/dto/blacklist.dto.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BlacklistStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateBlacklistDto {
|
||||
@ApiProperty({ description: '目标游戏ID或用户名', example: 'PlayerXXX#1234' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '目标游戏ID不能为空' })
|
||||
@MaxLength(100)
|
||||
targetGameId: string;
|
||||
|
||||
@ApiProperty({ description: '举报原因' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '举报原因不能为空' })
|
||||
reason: string;
|
||||
|
||||
@ApiProperty({ description: '证据图片URL列表', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
proofImages?: string[];
|
||||
}
|
||||
|
||||
export class ReviewBlacklistDto {
|
||||
@ApiProperty({
|
||||
description: '审核状态',
|
||||
enum: BlacklistStatus,
|
||||
example: BlacklistStatus.APPROVED,
|
||||
})
|
||||
@IsEnum(BlacklistStatus)
|
||||
status: BlacklistStatus;
|
||||
|
||||
@ApiProperty({ description: '审核意见', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reviewNote?: string;
|
||||
}
|
||||
|
||||
export class QueryBlacklistDto {
|
||||
@ApiProperty({ description: '目标游戏ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
targetGameId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态',
|
||||
enum: BlacklistStatus,
|
||||
required: false,
|
||||
})
|
||||
@IsEnum(BlacklistStatus)
|
||||
@IsOptional()
|
||||
status?: BlacklistStatus;
|
||||
}
|
||||
117
src/modules/games/dto/game.dto.ts
Normal file
117
src/modules/games/dto/game.dto.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, IsArray } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateGameDto {
|
||||
@ApiProperty({ description: '游戏名称', example: '王者荣耀' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '游戏名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '游戏封面URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverUrl?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '最大玩家数', example: 5 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
maxPlayers: number;
|
||||
|
||||
@ApiProperty({ description: '最小玩家数', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', example: 'PC/iOS/Android', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', example: ['MOBA', '5v5'], required: false, type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class UpdateGameDto {
|
||||
@ApiProperty({ description: '游戏名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏封面URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverUrl?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '最大玩家数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '最小玩家数', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minPlayers?: number;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', required: false, type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class SearchGameDto {
|
||||
@ApiProperty({ description: '搜索关键词', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyword?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏平台', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ description: '游戏标签', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tag?: string;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
95
src/modules/games/games.controller.ts
Normal file
95
src/modules/games/games.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { GamesService } from './games.service';
|
||||
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('games')
|
||||
@Controller('games')
|
||||
export class GamesController {
|
||||
constructor(private readonly gamesService: GamesService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取游戏列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'keyword', required: false, description: '搜索关键词' })
|
||||
@ApiQuery({ name: 'platform', required: false, description: '游戏平台' })
|
||||
@ApiQuery({ name: 'tag', required: false, description: '游戏标签' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(@Query() searchDto: SearchGameDto) {
|
||||
return this.gamesService.findAll(searchDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('popular')
|
||||
@ApiOperation({ summary: '获取热门游戏' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '数量限制' })
|
||||
async findPopular(@Query('limit') limit?: number) {
|
||||
return this.gamesService.findPopular(limit);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: '获取所有游戏标签' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getTags() {
|
||||
return this.gamesService.getTags();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('platforms')
|
||||
@ApiOperation({ summary: '获取所有游戏平台' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getPlatforms() {
|
||||
return this.gamesService.getPlatforms();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取游戏详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.gamesService.findOne(id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建游戏' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(@Body() createGameDto: CreateGameDto) {
|
||||
return this.gamesService.create(createGameDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新游戏信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(@Param('id') id: string, @Body() updateGameDto: UpdateGameDto) {
|
||||
return this.gamesService.update(id, updateGameDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除游戏' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.gamesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
src/modules/games/games.module.ts
Normal file
13
src/modules/games/games.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GamesService } from './games.service';
|
||||
import { GamesController } from './games.controller';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Game])],
|
||||
controllers: [GamesController],
|
||||
providers: [GamesService],
|
||||
exports: [GamesService],
|
||||
})
|
||||
export class GamesModule {}
|
||||
301
src/modules/games/games.service.spec.ts
Normal file
301
src/modules/games/games.service.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { GamesService } from './games.service';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
|
||||
describe('GamesService', () => {
|
||||
let service: GamesService;
|
||||
let repository: Repository<Game>;
|
||||
|
||||
const mockGame = {
|
||||
id: 'game-id-1',
|
||||
name: '王者荣耀',
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
description: '5v5竞技游戏',
|
||||
maxPlayers: 10,
|
||||
minPlayers: 1,
|
||||
platform: 'iOS/Android',
|
||||
tags: ['MOBA', '5v5'],
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GamesService,
|
||||
{
|
||||
provide: getRepositoryToken(Game),
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GamesService>(GamesService);
|
||||
repository = module.get<Repository<Game>>(getRepositoryToken(Game));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建游戏', async () => {
|
||||
const createDto = {
|
||||
name: '原神',
|
||||
coverUrl: 'https://example.com/genshin.jpg',
|
||||
description: '开放世界冒险游戏',
|
||||
maxPlayers: 4,
|
||||
minPlayers: 1,
|
||||
platform: 'PC/iOS/Android',
|
||||
tags: ['RPG', '开放世界'],
|
||||
};
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(null); // 游戏名称不存在
|
||||
mockRepository.create.mockReturnValue({ ...createDto, id: 'new-game-id' });
|
||||
mockRepository.save.mockResolvedValue({ ...createDto, id: 'new-game-id' });
|
||||
|
||||
const result = await service.create(createDto);
|
||||
|
||||
expect(result).toHaveProperty('id', 'new-game-id');
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { name: createDto.name },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在游戏名称已存在时抛出异常', async () => {
|
||||
const createDto = {
|
||||
name: '王者荣耀',
|
||||
maxPlayers: 10,
|
||||
};
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
|
||||
await expect(service.create(createDto as any)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回游戏列表', async () => {
|
||||
const searchDto = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.findAll(searchDto);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('应该支持关键词搜索', async () => {
|
||||
const searchDto = {
|
||||
keyword: '王者',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.findAll(searchDto);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该支持平台筛选', async () => {
|
||||
const searchDto = {
|
||||
platform: 'iOS',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
await service.findAll(searchDto);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回游戏详情', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
|
||||
const result = await service.findOne('game-id-1');
|
||||
|
||||
expect(result).toEqual(mockGame);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'game-id-1', isActive: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在游戏不存在时抛出异常', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新游戏', async () => {
|
||||
const updateDto = {
|
||||
description: '更新后的描述',
|
||||
maxPlayers: 12,
|
||||
};
|
||||
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(mockGame) // findOne调用
|
||||
.mockResolvedValueOnce(null); // 名称检查
|
||||
|
||||
mockRepository.save.mockResolvedValue({
|
||||
...mockGame,
|
||||
...updateDto,
|
||||
});
|
||||
|
||||
const result = await service.update('game-id-1', updateDto);
|
||||
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.maxPlayers).toBe(updateDto.maxPlayers);
|
||||
});
|
||||
|
||||
it('应该在更新名称时检查重名', async () => {
|
||||
const updateDto = {
|
||||
name: '已存在的游戏名',
|
||||
};
|
||||
|
||||
const anotherGame = {
|
||||
...mockGame,
|
||||
id: 'another-game-id',
|
||||
name: '已存在的游戏名',
|
||||
};
|
||||
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(mockGame) // 第一次:获取要更新的游戏
|
||||
.mockResolvedValueOnce(anotherGame); // 第二次:检查名称是否存在
|
||||
|
||||
await expect(
|
||||
service.update('game-id-1', updateDto),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该软删除游戏', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockGame);
|
||||
mockRepository.save.mockResolvedValue({
|
||||
...mockGame,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = await service.remove('game-id-1');
|
||||
|
||||
expect(result).toHaveProperty('message', '游戏已删除');
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isActive: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPopular', () => {
|
||||
it('应该返回热门游戏列表', async () => {
|
||||
mockRepository.find.mockResolvedValue([mockGame]);
|
||||
|
||||
const result = await service.findPopular(5);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: { isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTags', () => {
|
||||
it('应该返回所有游戏标签', async () => {
|
||||
const games = [
|
||||
{ ...mockGame, tags: ['MOBA', '5v5'] },
|
||||
{ ...mockGame, tags: ['FPS', 'RPG'] },
|
||||
];
|
||||
|
||||
mockRepository.find.mockResolvedValue(games);
|
||||
|
||||
const result = await service.getTags();
|
||||
|
||||
expect(result).toContain('MOBA');
|
||||
expect(result).toContain('FPS');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatforms', () => {
|
||||
it('应该返回所有游戏平台', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ platform: 'iOS/Android' },
|
||||
{ platform: 'PC' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getPlatforms();
|
||||
|
||||
expect(result).toContain('iOS/Android');
|
||||
expect(result).toContain('PC');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/modules/games/games.service.ts
Normal file
190
src/modules/games/games.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like } from 'typeorm';
|
||||
import { Game } from '../../entities/game.entity';
|
||||
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
@Injectable()
|
||||
export class GamesService {
|
||||
constructor(
|
||||
@InjectRepository(Game)
|
||||
private gameRepository: Repository<Game>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建游戏
|
||||
*/
|
||||
async create(createGameDto: CreateGameDto) {
|
||||
// 检查游戏名称是否已存在
|
||||
const existingGame = await this.gameRepository.findOne({
|
||||
where: { name: createGameDto.name },
|
||||
});
|
||||
|
||||
if (existingGame) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GAME_EXISTS,
|
||||
message: ErrorMessage[ErrorCode.GAME_EXISTS],
|
||||
});
|
||||
}
|
||||
|
||||
const game = this.gameRepository.create({
|
||||
...createGameDto,
|
||||
minPlayers: createGameDto.minPlayers || 1,
|
||||
});
|
||||
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏列表
|
||||
*/
|
||||
async findAll(searchDto: SearchGameDto) {
|
||||
const { keyword, platform, tag, page = 1, limit = 10 } = searchDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.gameRepository
|
||||
.createQueryBuilder('game')
|
||||
.where('game.isActive = :isActive', { isActive: true });
|
||||
|
||||
// 关键词搜索(游戏名称和描述)
|
||||
if (keyword) {
|
||||
queryBuilder.andWhere(
|
||||
'(game.name LIKE :keyword OR game.description LIKE :keyword)',
|
||||
{ keyword: `%${keyword}%` },
|
||||
);
|
||||
}
|
||||
|
||||
// 平台筛选
|
||||
if (platform) {
|
||||
queryBuilder.andWhere('game.platform LIKE :platform', {
|
||||
platform: `%${platform}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (tag) {
|
||||
queryBuilder.andWhere('game.tags LIKE :tag', { tag: `%${tag}%` });
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('game.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const game = await this.gameRepository.findOne({
|
||||
where: { id, isActive: true },
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GAME_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新游戏信息
|
||||
*/
|
||||
async update(id: string, updateGameDto: UpdateGameDto) {
|
||||
const game = await this.findOne(id);
|
||||
|
||||
// 如果要修改游戏名称,检查是否与其他游戏重名
|
||||
if (updateGameDto.name && updateGameDto.name !== game.name) {
|
||||
const existingGame = await this.gameRepository.findOne({
|
||||
where: { name: updateGameDto.name },
|
||||
});
|
||||
|
||||
if (existingGame) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GAME_EXISTS,
|
||||
message: '游戏名称已存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(game, updateGameDto);
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除游戏(软删除)
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const game = await this.findOne(id);
|
||||
|
||||
game.isActive = false;
|
||||
await this.gameRepository.save(game);
|
||||
|
||||
return { message: '游戏已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门游戏(可根据实际需求调整排序逻辑)
|
||||
*/
|
||||
async findPopular(limit: number = 10) {
|
||||
const games = await this.gameRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有游戏标签
|
||||
*/
|
||||
async getTags() {
|
||||
const games = await this.gameRepository.find({
|
||||
where: { isActive: true },
|
||||
select: ['tags'],
|
||||
});
|
||||
|
||||
const tagsSet = new Set<string>();
|
||||
games.forEach((game) => {
|
||||
if (game.tags && game.tags.length > 0) {
|
||||
game.tags.forEach((tag) => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tagsSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有游戏平台
|
||||
*/
|
||||
async getPlatforms() {
|
||||
const games = await this.gameRepository
|
||||
.createQueryBuilder('game')
|
||||
.select('DISTINCT game.platform', 'platform')
|
||||
.where('game.isActive = :isActive', { isActive: true })
|
||||
.andWhere('game.platform IS NOT NULL')
|
||||
.getRawMany();
|
||||
|
||||
return games.map((item) => item.platform);
|
||||
}
|
||||
}
|
||||
99
src/modules/groups/dto/group.dto.ts
Normal file
99
src/modules/groups/dto/group.dto.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateGroupDto {
|
||||
@ApiProperty({ description: '小组名称', example: '王者荣耀固定队' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '小组描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '小组头像', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ description: '小组类型', example: 'normal', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
type?: string;
|
||||
|
||||
@ApiProperty({ description: '父组ID(创建子组时使用)', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
parentId?: string;
|
||||
|
||||
@ApiProperty({ description: '最大成员数', example: 50, required: false })
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(500)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxMembers?: number;
|
||||
}
|
||||
|
||||
export class UpdateGroupDto {
|
||||
@ApiProperty({ description: '小组名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '小组描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '小组头像', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ description: '公示信息', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
announcement?: string;
|
||||
|
||||
@ApiProperty({ description: '最大成员数', required: false })
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(500)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
maxMembers?: number;
|
||||
}
|
||||
|
||||
export class JoinGroupDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '组内昵称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class UpdateMemberRoleDto {
|
||||
@ApiProperty({ description: '成员ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '成员ID不能为空' })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '角色', example: 'admin', enum: ['owner', 'admin', 'member'] })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '角色不能为空' })
|
||||
role: string;
|
||||
}
|
||||
|
||||
export class KickMemberDto {
|
||||
@ApiProperty({ description: '成员ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '成员ID不能为空' })
|
||||
userId: string;
|
||||
}
|
||||
110
src/modules/groups/groups.controller.ts
Normal file
110
src/modules/groups/groups.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GroupsService } from './groups.service';
|
||||
import {
|
||||
CreateGroupDto,
|
||||
UpdateGroupDto,
|
||||
JoinGroupDto,
|
||||
UpdateMemberRoleDto,
|
||||
KickMemberDto,
|
||||
} from './dto/group.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@ApiTags('groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('groups')
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(@CurrentUser() user: User, @Body() createGroupDto: CreateGroupDto) {
|
||||
return this.groupsService.create(user.id, createGroupDto);
|
||||
}
|
||||
|
||||
@Post('join')
|
||||
@ApiOperation({ summary: '加入小组' })
|
||||
@ApiResponse({ status: 200, description: '加入成功' })
|
||||
async join(@CurrentUser() user: User, @Body() joinGroupDto: JoinGroupDto) {
|
||||
return this.groupsService.join(user.id, joinGroupDto);
|
||||
}
|
||||
|
||||
@Delete(':id/leave')
|
||||
@ApiOperation({ summary: '退出小组' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async leave(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return this.groupsService.leave(user.id, id);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: '获取我的小组列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findMy(@CurrentUser() user: User) {
|
||||
return this.groupsService.findUserGroups(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取小组详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.groupsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新小组信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() updateGroupDto: UpdateGroupDto,
|
||||
) {
|
||||
return this.groupsService.update(user.id, id, updateGroupDto);
|
||||
}
|
||||
|
||||
@Put(':id/members/role')
|
||||
@ApiOperation({ summary: '设置成员角色' })
|
||||
@ApiResponse({ status: 200, description: '设置成功' })
|
||||
async updateMemberRole(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
|
||||
) {
|
||||
return this.groupsService.updateMemberRole(
|
||||
user.id,
|
||||
id,
|
||||
updateMemberRoleDto.userId,
|
||||
updateMemberRoleDto.role as any,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/members')
|
||||
@ApiOperation({ summary: '踢出成员' })
|
||||
@ApiResponse({ status: 200, description: '移除成功' })
|
||||
async kickMember(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() kickMemberDto: KickMemberDto,
|
||||
) {
|
||||
return this.groupsService.kickMember(user.id, id, kickMemberDto.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '解散小组' })
|
||||
@ApiResponse({ status: 200, description: '解散成功' })
|
||||
async disband(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return this.groupsService.disband(user.id, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/groups/groups.module.ts
Normal file
15
src/modules/groups/groups.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { GroupsController } from './groups.controller';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Group, GroupMember, User])],
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
290
src/modules/groups/groups.service.spec.ts
Normal file
290
src/modules/groups/groups.service.spec.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
describe('GroupsService', () => {
|
||||
let service: GroupsService;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
let mockUserRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
description: '描述',
|
||||
ownerId: 'user-1',
|
||||
maxMembers: 10,
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGroupRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
count: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
clearByPrefix: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GroupsService,
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: mockCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GroupsService>(GroupsService);
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建小组', async () => {
|
||||
mockGroupRepository.count.mockResolvedValue(2);
|
||||
mockGroupRepository.create.mockReturnValue(mockGroup);
|
||||
mockGroupRepository.save.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.create.mockReturnValue(mockMember);
|
||||
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
|
||||
mockGroupRepository.findOne.mockResolvedValue({
|
||||
...mockGroup,
|
||||
owner: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
name: '测试小组',
|
||||
description: '描述',
|
||||
maxMembers: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.name).toBe('测试小组');
|
||||
expect(mockGroupRepository.save).toHaveBeenCalled();
|
||||
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该mock在创建小组数量超限时抛出异常', async () => {
|
||||
mockGroupRepository.count.mockResolvedValue(5);
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
name: '测试小组',
|
||||
maxMembers: 10,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取小组详情', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue({
|
||||
...mockGroup,
|
||||
owner: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('group-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('group-1');
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('group-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新小组', async () => {
|
||||
mockGroupRepository.findOne
|
||||
.mockResolvedValueOnce(mockGroup)
|
||||
.mockResolvedValueOnce({
|
||||
...mockGroup,
|
||||
name: '更新后的名称',
|
||||
owner: mockUser,
|
||||
});
|
||||
mockGroupRepository.save.mockResolvedValue({
|
||||
...mockGroup,
|
||||
name: '更新后的名称',
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'group-1', {
|
||||
name: '更新后的名称',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('更新后的名称');
|
||||
});
|
||||
|
||||
it('应该在非所有者更新时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'group-1', { name: '新名称' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
it('应该成功加入小组', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
mockGroupMemberRepository.count
|
||||
.mockResolvedValueOnce(3) // 用户已加入的小组数
|
||||
.mockResolvedValueOnce(5); // 小组当前成员数
|
||||
mockGroupMemberRepository.create.mockReturnValue(mockMember);
|
||||
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
|
||||
|
||||
const result = await service.join('user-2', { groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在已加入时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
|
||||
|
||||
await expect(service.join('user-1', { groupId: 'group-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在小组已满时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
mockGroupMemberRepository.count
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(10);
|
||||
|
||||
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leave', () => {
|
||||
it('应该成功离开小组', async () => {
|
||||
const memberNotOwner = { ...mockMember, role: 'member' };
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(memberNotOwner);
|
||||
mockGroupMemberRepository.save.mockResolvedValue({
|
||||
...memberNotOwner,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = await service.leave('user-2', 'group-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在小组所有者尝试离开时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
|
||||
|
||||
await expect(service.leave('user-1', 'group-1')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemberRole', () => {
|
||||
it('应该成功更新成员角色', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMember,
|
||||
role: 'member',
|
||||
});
|
||||
mockGroupMemberRepository.save.mockResolvedValue({
|
||||
...mockMember,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
const result = await service.updateMemberRole(
|
||||
'user-1',
|
||||
'group-1',
|
||||
'user-2',
|
||||
'admin' as any,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在非所有者更新角色时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
|
||||
await expect(
|
||||
service.updateMemberRole('user-2', 'group-1', 'user-3', 'admin' as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
441
src/modules/groups/groups.service.ts
Normal file
441
src/modules/groups/groups.service.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CreateGroupDto, UpdateGroupDto, JoinGroupDto } from './dto/group.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
private readonly CACHE_PREFIX = 'group';
|
||||
private readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组
|
||||
*/
|
||||
async create(userId: string, createGroupDto: CreateGroupDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户创建的小组数量
|
||||
const ownedGroupsCount = await this.groupRepository.count({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
if (!user.isMember && ownedGroupsCount >= 1) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
|
||||
message: '非会员最多只能创建1个小组',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isMember && ownedGroupsCount >= 10) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
|
||||
message: '会员最多只能创建10个小组',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果是创建子组,检查父组是否存在且用户是否为会员
|
||||
if (createGroupDto.parentId) {
|
||||
if (!user.isMember) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '非会员不能创建子组',
|
||||
});
|
||||
}
|
||||
|
||||
const parentGroup = await this.groupRepository.findOne({
|
||||
where: { id: createGroupDto.parentId },
|
||||
});
|
||||
|
||||
if (!parentGroup) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: '父组不存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建小组
|
||||
const group = this.groupRepository.create({
|
||||
...createGroupDto,
|
||||
ownerId: userId,
|
||||
maxMembers: createGroupDto.maxMembers || 50,
|
||||
});
|
||||
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
// 将创建者添加为小组成员(角色为 owner)
|
||||
const member = this.groupMemberRepository.create({
|
||||
groupId: group.id,
|
||||
userId: userId,
|
||||
role: GroupMemberRole.OWNER,
|
||||
});
|
||||
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return this.findOne(group.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入小组(使用原子更新防止并发竞态条件)
|
||||
*/
|
||||
async join(userId: string, joinGroupDto: JoinGroupDto) {
|
||||
const { groupId, nickname } = joinGroupDto;
|
||||
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已经是成员
|
||||
const existingMember = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.ALREADY_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.ALREADY_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户加入的小组数量
|
||||
const joinedGroupsCount = await this.groupMemberRepository.count({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!user.isMember && joinedGroupsCount >= 3) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED,
|
||||
message: ErrorMessage[ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED],
|
||||
});
|
||||
}
|
||||
|
||||
// 使用原子更新:只有当当前成员数小于最大成员数时才成功
|
||||
const updateResult = await this.groupRepository
|
||||
.createQueryBuilder()
|
||||
.update(Group)
|
||||
.set({
|
||||
currentMembers: () => 'currentMembers + 1',
|
||||
})
|
||||
.where('id = :id', { id: groupId })
|
||||
.andWhere('currentMembers < maxMembers')
|
||||
.execute();
|
||||
|
||||
// 如果影响的行数为0,说明小组已满
|
||||
if (updateResult.affected === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.GROUP_FULL,
|
||||
message: ErrorMessage[ErrorCode.GROUP_FULL],
|
||||
});
|
||||
}
|
||||
|
||||
// 添加成员记录
|
||||
const member = this.groupMemberRepository.create({
|
||||
groupId,
|
||||
userId,
|
||||
nickname,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return this.findOne(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出小组
|
||||
*/
|
||||
async leave(userId: string, groupId: string) {
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 组长不能直接退出
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '组长不能退出小组,请先转让组长或解散小组',
|
||||
});
|
||||
}
|
||||
|
||||
await this.groupMemberRepository.remove(member);
|
||||
|
||||
// 更新小组成员数
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (group) {
|
||||
group.currentMembers = Math.max(0, group.currentMembers - 1);
|
||||
await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
return { message: '退出成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小组详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
// 尝试从缓存获取
|
||||
const cached = this.cacheService.get<any>(id, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['owner', 'members', 'members.user'],
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
...group,
|
||||
members: group.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
username: member.user.username,
|
||||
avatar: member.user.avatar,
|
||||
nickname: member.nickname,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
})),
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
this.cacheService.set(id, result, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
ttl: this.CACHE_TTL,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的小组列表
|
||||
*/
|
||||
async findUserGroups(userId: string) {
|
||||
const members = await this.groupMemberRepository.find({
|
||||
where: { userId },
|
||||
relations: ['group', 'group.owner'],
|
||||
});
|
||||
|
||||
return members.map((member) => ({
|
||||
...member.group,
|
||||
myRole: member.role,
|
||||
myNickname: member.nickname,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组信息
|
||||
*/
|
||||
async update(userId: string, groupId: string, updateGroupDto: UpdateGroupDto) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限(只有组长和管理员可以修改)
|
||||
await this.checkPermission(userId, groupId, [
|
||||
GroupMemberRole.OWNER,
|
||||
GroupMemberRole.ADMIN,
|
||||
]);
|
||||
|
||||
Object.assign(group, updateGroupDto);
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
// 清除缓存
|
||||
this.cacheService.del(groupId, { prefix: this.CACHE_PREFIX });
|
||||
|
||||
return this.findOne(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置成员角色
|
||||
*/
|
||||
async updateMemberRole(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
targetUserId: string,
|
||||
role: GroupMemberRole,
|
||||
) {
|
||||
// 只有组长可以设置管理员
|
||||
await this.checkPermission(userId, groupId, [GroupMemberRole.OWNER]);
|
||||
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: targetUserId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: '该用户不在小组中',
|
||||
});
|
||||
}
|
||||
|
||||
// 不能修改组长角色
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '不能修改组长角色',
|
||||
});
|
||||
}
|
||||
|
||||
member.role = role;
|
||||
await this.groupMemberRepository.save(member);
|
||||
|
||||
return { message: '角色设置成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出成员
|
||||
*/
|
||||
async kickMember(userId: string, groupId: string, targetUserId: string) {
|
||||
// 组长和管理员可以踢人
|
||||
await this.checkPermission(userId, groupId, [
|
||||
GroupMemberRole.OWNER,
|
||||
GroupMemberRole.ADMIN,
|
||||
]);
|
||||
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: targetUserId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: '该用户不在小组中',
|
||||
});
|
||||
}
|
||||
|
||||
// 不能踢出组长
|
||||
if (member.role === GroupMemberRole.OWNER) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '不能踢出组长',
|
||||
});
|
||||
}
|
||||
|
||||
await this.groupMemberRepository.remove(member);
|
||||
|
||||
// 更新小组成员数
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (group) {
|
||||
group.currentMembers = Math.max(0, group.currentMembers - 1);
|
||||
await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
return { message: '成员已移除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解散小组
|
||||
*/
|
||||
async disband(userId: string, groupId: string) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 只有组长可以解散
|
||||
if (group.ownerId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '只有组长可以解散小组',
|
||||
});
|
||||
}
|
||||
|
||||
group.isActive = false;
|
||||
await this.groupRepository.save(group);
|
||||
|
||||
return { message: '小组已解散' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
allowedRoles: GroupMemberRole[],
|
||||
) {
|
||||
const member = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(member.role)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/modules/honors/dto/honor.dto.ts
Normal file
71
src/modules/honors/dto/honor.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateHonorDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉标题', example: '首次五连胜' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
@MaxLength(100)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '媒体文件URL列表(图片/视频)', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
mediaUrls?: string[];
|
||||
|
||||
@ApiProperty({ description: '荣誉获得日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
achievedDate?: Date;
|
||||
}
|
||||
|
||||
export class UpdateHonorDto {
|
||||
@ApiProperty({ description: '荣誉标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '荣誉描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '媒体文件URL列表', required: false })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
mediaUrls?: string[];
|
||||
|
||||
@ApiProperty({ description: '事件日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
eventDate?: Date;
|
||||
}
|
||||
|
||||
export class QueryHonorsDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '年份筛选', required: false, example: 2024 })
|
||||
@IsOptional()
|
||||
year?: number;
|
||||
}
|
||||
64
src/modules/honors/honors.controller.ts
Normal file
64
src/modules/honors/honors.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('honors')
|
||||
@Controller('honors')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class HonorsController {
|
||||
constructor(private readonly honorsService: HonorsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建荣誉记录' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateHonorDto) {
|
||||
return this.honorsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询荣誉列表' })
|
||||
findAll(@Query() query: QueryHonorsDto) {
|
||||
return this.honorsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('timeline/:groupId')
|
||||
@ApiOperation({ summary: '获取小组荣誉时间轴' })
|
||||
getTimeline(@Param('groupId') groupId: string) {
|
||||
return this.honorsService.getTimeline(groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询单个荣誉记录' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.honorsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新荣誉记录' })
|
||||
update(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateHonorDto,
|
||||
) {
|
||||
return this.honorsService.update(user.id, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除荣誉记录' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.honorsService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/honors/honors.module.ts
Normal file
15
src/modules/honors/honors.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HonorsController } from './honors.controller';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Honor, Group, GroupMember])],
|
||||
controllers: [HonorsController],
|
||||
providers: [HonorsService],
|
||||
exports: [HonorsService],
|
||||
})
|
||||
export class HonorsModule {}
|
||||
313
src/modules/honors/honors.service.spec.ts
Normal file
313
src/modules/honors/honors.service.spec.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { HonorsService } from './honors.service';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('HonorsService', () => {
|
||||
let service: HonorsService;
|
||||
let honorRepository: Repository<Honor>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockHonor = {
|
||||
id: 'honor-1',
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
description: '获得比赛冠军',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
media: ['image1.jpg'],
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
ownerId: 'user-1',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HonorsService,
|
||||
{
|
||||
provide: getRepositoryToken(Honor),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HonorsService>(HonorsService);
|
||||
honorRepository = module.get<Repository<Honor>>(getRepositoryToken(Honor));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建荣誉记录(管理员)', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
description: '获得比赛冠军',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
media: ['image1.jpg'],
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(honorRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('非管理员创建时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('组长应该可以创建荣誉记录', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
title: '冠军荣誉',
|
||||
eventDate: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.OWNER,
|
||||
} as any);
|
||||
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回荣誉列表', async () => {
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockHonor]);
|
||||
|
||||
const result = await service.findAll({ groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(honorRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeline', () => {
|
||||
it('应该返回按年份分组的时间轴', async () => {
|
||||
const mockHonors = [
|
||||
{ ...mockHonor, eventDate: new Date('2025-01-01') },
|
||||
{ ...mockHonor, id: 'honor-2', eventDate: new Date('2024-06-01') },
|
||||
];
|
||||
|
||||
jest.spyOn(honorRepository, 'find').mockResolvedValue(mockHonors as any);
|
||||
|
||||
const result = await service.getTimeline('group-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result[2025]).toHaveLength(1);
|
||||
expect(result[2024]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('空荣誉列表应该返回空对象', async () => {
|
||||
jest.spyOn(honorRepository, 'find').mockResolvedValue([]);
|
||||
|
||||
const result = await service.getTimeline('group-1');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该返回单个荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.findOne('honor-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('honor-1');
|
||||
});
|
||||
|
||||
it('记录不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('创建者应该可以更新荣誉记录', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue({
|
||||
...mockHonor,
|
||||
...updateDto,
|
||||
} as any);
|
||||
|
||||
const result = await service.update('user-1', 'honor-1', updateDto);
|
||||
|
||||
expect(result.title).toBe('更新后的标题');
|
||||
});
|
||||
|
||||
it('管理员应该可以更新任何荣誉记录', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'save').mockResolvedValue({
|
||||
...mockHonor,
|
||||
...updateDto,
|
||||
} as any);
|
||||
|
||||
const result = await service.update('user-1', 'honor-1', updateDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const updateDto = {
|
||||
title: '更新后的标题',
|
||||
};
|
||||
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.update('user-1', 'honor-1', updateDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('创建者应该可以删除自己的荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.remove('user-1', 'honor-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(honorRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('管理员应该可以删除任何荣誉记录', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
|
||||
|
||||
const result = await service.remove('user-1', 'honor-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
|
||||
...mockHonor,
|
||||
createdBy: 'other-user',
|
||||
} as any);
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.remove('user-1', 'honor-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
198
src/modules/honors/honors.service.ts
Normal file
198
src/modules/honors/honors.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Honor } from '../../entities/honor.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import {
|
||||
ErrorCode,
|
||||
ErrorMessage,
|
||||
} from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class HonorsService {
|
||||
constructor(
|
||||
@InjectRepository(Honor)
|
||||
private honorRepository: Repository<Honor>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建荣誉记录
|
||||
*/
|
||||
async create(userId: string, createDto: CreateHonorDto) {
|
||||
const { groupId, ...rest } = createDto;
|
||||
|
||||
// 验证小组存在
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限(需要是小组管理员或组长)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const honor = this.honorRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await this.honorRepository.save(honor);
|
||||
|
||||
return this.findOne(honor.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询荣誉列表
|
||||
*/
|
||||
async findAll(query: QueryHonorsDto) {
|
||||
const qb = this.honorRepository
|
||||
.createQueryBuilder('honor')
|
||||
.leftJoinAndSelect('honor.group', 'group')
|
||||
.leftJoinAndSelect('honor.creator', 'creator');
|
||||
|
||||
if (query.groupId) {
|
||||
qb.andWhere('honor.groupId = :groupId', { groupId: query.groupId });
|
||||
}
|
||||
|
||||
if (query.year) {
|
||||
const startDate = new Date(`${query.year}-01-01`);
|
||||
const endDate = new Date(`${query.year}-12-31`);
|
||||
qb.andWhere('honor.eventDate BETWEEN :startDate AND :endDate', {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}
|
||||
|
||||
qb.orderBy('honor.eventDate', 'DESC');
|
||||
|
||||
const honors = await qb.getMany();
|
||||
|
||||
return honors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间轴数据(按年份分组)
|
||||
*/
|
||||
async getTimeline(groupId: string) {
|
||||
const honors = await this.honorRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['creator'],
|
||||
order: { eventDate: 'DESC' },
|
||||
});
|
||||
|
||||
// 按年份分组
|
||||
const timeline = honors.reduce((acc, honor) => {
|
||||
const year = new Date(honor.eventDate).getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(honor);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个荣誉记录
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const honor = await this.honorRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'creator'],
|
||||
});
|
||||
|
||||
if (!honor) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.HONOR_NOT_FOUND,
|
||||
message: '荣誉记录不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return honor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新荣誉记录
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateHonorDto) {
|
||||
const honor = await this.findOne(id);
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: honor.groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
honor.creatorId !== userId &&
|
||||
(!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER))
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(honor, updateDto);
|
||||
await this.honorRepository.save(honor);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除荣誉记录
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const honor = await this.findOne(id);
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: honor.groupId, userId },
|
||||
});
|
||||
|
||||
if (
|
||||
honor.creatorId !== userId &&
|
||||
(!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER))
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.honorRepository.remove(honor);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
143
src/modules/ledgers/dto/ledger.dto.ts
Normal file
143
src/modules/ledgers/dto/ledger.dto.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { LedgerType } from '../../../common/enums';
|
||||
|
||||
export class CreateLedgerDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType })
|
||||
@IsEnum(LedgerType)
|
||||
type: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '金额', example: 100.5 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: '账目描述' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '账目描述不能为空' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '账目日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateLedgerDto {
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
|
||||
@IsEnum(LedgerType)
|
||||
@IsOptional()
|
||||
type?: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '金额', required: false })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
amount?: number;
|
||||
|
||||
@ApiProperty({ description: '账目描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '账目日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class QueryLedgersDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
|
||||
@IsEnum(LedgerType)
|
||||
@IsOptional()
|
||||
type?: LedgerType;
|
||||
|
||||
@ApiProperty({ description: '分类', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ description: '开始日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startDate?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束日期', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endDate?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class MonthlyStatisticsDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '年份', example: 2024 })
|
||||
@IsNumber()
|
||||
@Min(2000)
|
||||
@Type(() => Number)
|
||||
year: number;
|
||||
|
||||
@ApiProperty({ description: '月份', example: 1 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
month: number;
|
||||
}
|
||||
110
src/modules/ledgers/ledgers.controller.ts
Normal file
110
src/modules/ledgers/ledgers.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import {
|
||||
CreateLedgerDto,
|
||||
UpdateLedgerDto,
|
||||
QueryLedgersDto,
|
||||
MonthlyStatisticsDto,
|
||||
} from './dto/ledger.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('ledgers')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('ledgers')
|
||||
export class LedgersController {
|
||||
constructor(private readonly ledgersService: LedgersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建账目' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateLedgerDto,
|
||||
) {
|
||||
return this.ledgersService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取账目列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'type', required: false, description: '账目类型' })
|
||||
@ApiQuery({ name: 'category', required: false, description: '分类' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QueryLedgersDto,
|
||||
) {
|
||||
return this.ledgersService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Get('statistics/monthly')
|
||||
@ApiOperation({ summary: '月度统计' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getMonthlyStatistics(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() statsDto: MonthlyStatisticsDto,
|
||||
) {
|
||||
return this.ledgersService.getMonthlyStatistics(userId, statsDto);
|
||||
}
|
||||
|
||||
@Get('statistics/hierarchical/:groupId')
|
||||
@ApiOperation({ summary: '层级汇总' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getHierarchicalSummary(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
) {
|
||||
return this.ledgersService.getHierarchicalSummary(userId, groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取账目详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.ledgersService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新账目' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateLedgerDto,
|
||||
) {
|
||||
return this.ledgersService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除账目' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.ledgersService.remove(userId, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/ledgers/ledgers.module.ts
Normal file
15
src/modules/ledgers/ledgers.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import { LedgersController } from './ledgers.controller';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Ledger, Group, GroupMember])],
|
||||
controllers: [LedgersController],
|
||||
providers: [LedgersService],
|
||||
exports: [LedgersService],
|
||||
})
|
||||
export class LedgersModule {}
|
||||
369
src/modules/ledgers/ledgers.service.spec.ts
Normal file
369
src/modules/ledgers/ledgers.service.spec.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { LedgersService } from './ledgers.service';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
enum LedgerType {
|
||||
INCOME = 'income',
|
||||
EXPENSE = 'expense',
|
||||
}
|
||||
|
||||
describe('LedgersService', () => {
|
||||
let service: LedgersService;
|
||||
let mockLedgerRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
isActive: true,
|
||||
parentId: null,
|
||||
};
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockLedger = {
|
||||
id: 'ledger-1',
|
||||
groupId: 'group-1',
|
||||
creatorId: 'user-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '周末聚餐',
|
||||
createdAt: new Date('2024-01-20T10:00:00Z'),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockLedgerRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LedgersService,
|
||||
{
|
||||
provide: getRepositoryToken(Ledger),
|
||||
useValue: mockLedgerRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LedgersService>(LedgersService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建账目', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockLedgerRepository.create.mockReturnValue(mockLedger);
|
||||
mockLedgerRepository.save.mockResolvedValue(mockLedger);
|
||||
mockLedgerRepository.findOne.mockResolvedValue({
|
||||
...mockLedger,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '周末聚餐',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(mockLedgerRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: 100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在金额无效时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
amount: -100,
|
||||
category: '聚餐费用',
|
||||
description: '测试',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取账目列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该支持按类型筛选', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
type: LedgerType.INCOME,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取账目详情', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue({
|
||||
...mockLedger,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
|
||||
const result = await service.findOne('ledger-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('ledger-1');
|
||||
});
|
||||
|
||||
it('应该在账目不存在时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('ledger-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新账目', async () => {
|
||||
mockLedgerRepository.findOne
|
||||
.mockResolvedValueOnce(mockLedger)
|
||||
.mockResolvedValueOnce({
|
||||
...mockLedger,
|
||||
amount: 200,
|
||||
group: mockGroup,
|
||||
creator: mockUser,
|
||||
});
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'admin',
|
||||
});
|
||||
mockLedgerRepository.save.mockResolvedValue({
|
||||
...mockLedger,
|
||||
amount: 200,
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', 'ledger-1', {
|
||||
amount: 200,
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(200);
|
||||
});
|
||||
|
||||
it('应该在账目不存在时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', 'ledger-1', { amount: 200 }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在无权限时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'member',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'ledger-1', { amount: 200 }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除账目', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'admin',
|
||||
});
|
||||
mockLedgerRepository.remove.mockResolvedValue(mockLedger);
|
||||
|
||||
const result = await service.remove('user-1', 'ledger-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('应该在无权限时抛出异常', async () => {
|
||||
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue({
|
||||
...mockMembership,
|
||||
role: 'member',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('user-2', 'ledger-1'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthlyStatistics', () => {
|
||||
it('应该成功获取月度统计', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{ ...mockLedger, type: LedgerType.INCOME, amount: 100 },
|
||||
{ ...mockLedger, type: LedgerType.EXPENSE, amount: 50 },
|
||||
]),
|
||||
};
|
||||
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.getMonthlyStatistics('user-1', {
|
||||
groupId: 'group-1',
|
||||
year: 2024,
|
||||
month: 1,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('income');
|
||||
expect(result).toHaveProperty('expense');
|
||||
expect(result).toHaveProperty('balance');
|
||||
expect(result).toHaveProperty('categories');
|
||||
});
|
||||
|
||||
it('应该在用户不在小组时抛出异常', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getMonthlyStatistics('user-1', {
|
||||
groupId: 'group-1',
|
||||
year: 2024,
|
||||
month: 1,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHierarchicalSummary', () => {
|
||||
it('应该成功获取层级汇总', async () => {
|
||||
const childGroup = { id: 'group-2', name: '子小组', parentId: 'group-1' };
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockLedger]),
|
||||
};
|
||||
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockGroupRepository.find.mockResolvedValue([childGroup]);
|
||||
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getHierarchicalSummary('user-1', 'group-1');
|
||||
|
||||
expect(result).toHaveProperty('groupId');
|
||||
expect(result).toHaveProperty('income');
|
||||
expect(result).toHaveProperty('expense');
|
||||
expect(result).toHaveProperty('balance');
|
||||
});
|
||||
});
|
||||
});
|
||||
419
src/modules/ledgers/ledgers.service.ts
Normal file
419
src/modules/ledgers/ledgers.service.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Ledger } from '../../entities/ledger.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import {
|
||||
CreateLedgerDto,
|
||||
UpdateLedgerDto,
|
||||
QueryLedgersDto,
|
||||
MonthlyStatisticsDto,
|
||||
} from './dto/ledger.dto';
|
||||
import { LedgerType, GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
@Injectable()
|
||||
export class LedgersService {
|
||||
constructor(
|
||||
@InjectRepository(Ledger)
|
||||
private ledgerRepository: Repository<Ledger>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建账目
|
||||
*/
|
||||
async create(userId: string, createDto: CreateLedgerDto) {
|
||||
const { groupId, date, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限(必须是小组成员)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建账目
|
||||
const ledger = this.ledgerRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await this.ledgerRepository.save(ledger);
|
||||
|
||||
return this.findOne(ledger.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账目列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QueryLedgersDto) {
|
||||
const {
|
||||
groupId,
|
||||
type,
|
||||
category,
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.ledgerRepository
|
||||
.createQueryBuilder('ledger')
|
||||
.leftJoinAndSelect('ledger.group', 'group')
|
||||
.leftJoinAndSelect('ledger.user', 'user');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
// 验证用户是否在小组中
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
queryBuilder.andWhere('ledger.groupId = :groupId', { groupId });
|
||||
} else {
|
||||
// 如果没有指定小组,只返回用户所在小组的账目
|
||||
const memberGroups = await this.groupMemberRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
select: ['groupId'],
|
||||
});
|
||||
const groupIds = memberGroups.map((m) => m.groupId);
|
||||
if (groupIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
queryBuilder.andWhere('ledger.groupId IN (:...groupIds)', { groupIds });
|
||||
}
|
||||
|
||||
if (type) {
|
||||
queryBuilder.andWhere('ledger.type = :type', { type });
|
||||
}
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('ledger.category = :category', { category });
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt BETWEEN :startDate AND :endDate', {
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
} else if (startDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt >= :startDate', {
|
||||
startDate: new Date(startDate),
|
||||
});
|
||||
} else if (endDate) {
|
||||
queryBuilder.andWhere('ledger.createdAt <= :endDate', {
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('ledger.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账目详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'user'],
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账目
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateLedgerDto) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
|
||||
|
||||
Object.assign(ledger, updateDto);
|
||||
await this.ledgerRepository.save(ledger);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账目
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const ledger = await this.ledgerRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '账目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查权限:创建者或小组管理员
|
||||
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
|
||||
|
||||
await this.ledgerRepository.remove(ledger);
|
||||
|
||||
return { message: '账目已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 月度统计
|
||||
*/
|
||||
async getMonthlyStatistics(userId: string, statsDto: MonthlyStatisticsDto) {
|
||||
const { groupId, year, month } = statsDto;
|
||||
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 计算月份起止时间
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0, 23, 59, 59);
|
||||
|
||||
// 查询该月所有账目
|
||||
const ledgers = await this.ledgerRepository.find({
|
||||
where: {
|
||||
groupId,
|
||||
createdAt: Between(startDate, endDate),
|
||||
},
|
||||
});
|
||||
|
||||
// 统计收入和支出
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
const categoryStats: Record<
|
||||
string,
|
||||
{ income: number; expense: number; count: number }
|
||||
> = {};
|
||||
|
||||
ledgers.forEach((ledger) => {
|
||||
const amount = Number(ledger.amount);
|
||||
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
totalIncome += amount;
|
||||
} else {
|
||||
totalExpense += amount;
|
||||
}
|
||||
|
||||
// 分类统计
|
||||
const category = ledger.category || '未分类';
|
||||
if (!categoryStats[category]) {
|
||||
categoryStats[category] = { income: 0, expense: 0, count: 0 };
|
||||
}
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
categoryStats[category].income += amount;
|
||||
} else {
|
||||
categoryStats[category].expense += amount;
|
||||
}
|
||||
categoryStats[category].count++;
|
||||
});
|
||||
|
||||
return {
|
||||
groupId,
|
||||
year,
|
||||
month,
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense,
|
||||
categoryStats,
|
||||
recordCount: ledgers.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级汇总(大组->子组)
|
||||
*/
|
||||
async getHierarchicalSummary(userId: string, groupId: string) {
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 获取大组信息
|
||||
const parentGroup = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
|
||||
if (!parentGroup) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有子组
|
||||
const childGroups = await this.groupRepository.find({
|
||||
where: { parentId: groupId, isActive: true },
|
||||
});
|
||||
|
||||
// 统计大组账目
|
||||
const parentLedgers = await this.ledgerRepository.find({
|
||||
where: { groupId },
|
||||
});
|
||||
|
||||
const parentStats = this.calculateStats(parentLedgers);
|
||||
|
||||
// 统计各子组账目
|
||||
const childStats = await Promise.all(
|
||||
childGroups.map(async (child) => {
|
||||
const ledgers = await this.ledgerRepository.find({
|
||||
where: { groupId: child.id },
|
||||
});
|
||||
return {
|
||||
groupId: child.id,
|
||||
groupName: child.name,
|
||||
...this.calculateStats(ledgers),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
parent: {
|
||||
groupId: parentGroup.id,
|
||||
groupName: parentGroup.name,
|
||||
...parentStats,
|
||||
},
|
||||
children: childStats,
|
||||
total: {
|
||||
income:
|
||||
parentStats.totalIncome +
|
||||
childStats.reduce((sum, c) => sum + c.totalIncome, 0),
|
||||
expense:
|
||||
parentStats.totalExpense +
|
||||
childStats.reduce((sum, c) => sum + c.totalExpense, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查小组成员身份
|
||||
*/
|
||||
private async checkGroupMembership(userId: string, groupId: string) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户权限
|
||||
*/
|
||||
private async checkPermission(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
creatorId: string,
|
||||
): Promise<void> {
|
||||
// 如果是创建者,直接通过
|
||||
if (userId === creatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是小组管理员或组长
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!membership ||
|
||||
(membership.role !== GroupMemberRole.ADMIN &&
|
||||
membership.role !== GroupMemberRole.OWNER)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计数据
|
||||
*/
|
||||
private calculateStats(ledgers: Ledger[]) {
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
|
||||
ledgers.forEach((ledger) => {
|
||||
const amount = Number(ledger.amount);
|
||||
if (ledger.type === LedgerType.INCOME) {
|
||||
totalIncome += amount;
|
||||
} else {
|
||||
totalExpense += amount;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense,
|
||||
recordCount: ledgers.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
src/modules/points/dto/point.dto.ts
Normal file
52
src/modules/points/dto/point.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AddPointDto {
|
||||
@ApiProperty({ description: '用户ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '用户ID不能为空' })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '积分数量', example: 10 })
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: '原因', example: '参与预约' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '原因不能为空' })
|
||||
@MaxLength(100)
|
||||
reason: string;
|
||||
|
||||
@ApiProperty({ description: '详细说明', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '关联ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
relatedId?: string;
|
||||
}
|
||||
|
||||
export class QueryPointsDto {
|
||||
@ApiProperty({ description: '用户ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
}
|
||||
52
src/modules/points/points.controller.ts
Normal file
52
src/modules/points/points.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PointsService } from './points.service';
|
||||
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('points')
|
||||
@Controller('points')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PointsController {
|
||||
constructor(private readonly pointsService: PointsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '添加积分记录(管理员)' })
|
||||
addPoint(@CurrentUser() user, @Body() addDto: AddPointDto) {
|
||||
return this.pointsService.addPoint(user.id, addDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询积分流水' })
|
||||
findAll(@Query() query: QueryPointsDto) {
|
||||
return this.pointsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('balance/:userId/:groupId')
|
||||
@ApiOperation({ summary: '查询用户在小组的积分余额' })
|
||||
getUserBalance(
|
||||
@Param('userId') userId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
) {
|
||||
return this.pointsService.getUserBalance(userId, groupId);
|
||||
}
|
||||
|
||||
@Get('ranking/:groupId')
|
||||
@ApiOperation({ summary: '获取小组积分排行榜' })
|
||||
getGroupRanking(
|
||||
@Param('groupId') groupId: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.pointsService.getGroupRanking(groupId, limit);
|
||||
}
|
||||
}
|
||||
16
src/modules/points/points.module.ts
Normal file
16
src/modules/points/points.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PointsController } from './points.controller';
|
||||
import { PointsService } from './points.service';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Point, User, Group, GroupMember])],
|
||||
controllers: [PointsController],
|
||||
providers: [PointsService],
|
||||
exports: [PointsService],
|
||||
})
|
||||
export class PointsModule {}
|
||||
229
src/modules/points/points.service.spec.ts
Normal file
229
src/modules/points/points.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { PointsService } from './points.service';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('PointsService', () => {
|
||||
let service: PointsService;
|
||||
let pointRepository: Repository<Point>;
|
||||
let userRepository: Repository<User>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockPoint = {
|
||||
id: 'point-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
description: '测试说明',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: '测试用户',
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getRawOne: jest.fn(),
|
||||
getRawMany: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PointsService,
|
||||
{
|
||||
provide: getRepositoryToken(Point),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PointsService>(PointsService);
|
||||
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('addPoint', () => {
|
||||
it('应该成功添加积分记录', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(pointRepository, 'create').mockReturnValue(mockPoint as any);
|
||||
jest.spyOn(pointRepository, 'save').mockResolvedValue(mockPoint as any);
|
||||
|
||||
const result = await service.addPoint('user-1', addDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(pointRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('用户不存在时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const addDto = {
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
amount: 10,
|
||||
reason: '参与预约',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回积分流水列表', async () => {
|
||||
mockQueryBuilder.getMany.mockResolvedValue([mockPoint]);
|
||||
|
||||
const result = await service.findAll({ groupId: 'group-1' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(pointRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserBalance', () => {
|
||||
it('应该返回用户积分余额', async () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
|
||||
|
||||
const result = await service.getUserBalance('user-1', 'group-1');
|
||||
|
||||
expect(result.balance).toBe(100);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.groupId).toBe('group-1');
|
||||
});
|
||||
|
||||
it('没有积分记录时应该返回0', async () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ total: null });
|
||||
|
||||
const result = await service.getUserBalance('user-1', 'group-1');
|
||||
|
||||
expect(result.balance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupRanking', () => {
|
||||
it('应该返回小组积分排行榜', async () => {
|
||||
const mockRanking = [
|
||||
{ userId: 'user-1', username: '用户1', totalPoints: '100' },
|
||||
{ userId: 'user-2', username: '用户2', totalPoints: '80' },
|
||||
];
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue(mockRanking);
|
||||
|
||||
const result = await service.getGroupRanking('group-1', 10);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].rank).toBe(1);
|
||||
expect(result[0].totalPoints).toBe(100);
|
||||
expect(result[1].rank).toBe(2);
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.getGroupRanking('group-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/modules/points/points.service.ts
Normal file
150
src/modules/points/points.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Point } from '../../entities/point.entity';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
|
||||
import { GroupMemberRole } from '../../common/enums';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PointsService {
|
||||
constructor(
|
||||
@InjectRepository(Point)
|
||||
private pointRepository: Repository<Point>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 添加积分记录
|
||||
*/
|
||||
async addPoint(operatorId: string, addDto: AddPointDto) {
|
||||
const { userId, groupId, ...rest } = addDto;
|
||||
|
||||
// 验证小组存在
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户存在
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证操作者权限(需要管理员)
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId: operatorId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const point = this.pointRepository.create({
|
||||
...rest,
|
||||
userId,
|
||||
groupId,
|
||||
});
|
||||
|
||||
await this.pointRepository.save(point);
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分流水
|
||||
*/
|
||||
async findAll(query: QueryPointsDto) {
|
||||
const qb = this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.leftJoinAndSelect('point.user', 'user')
|
||||
.leftJoinAndSelect('point.group', 'group');
|
||||
|
||||
if (query.userId) {
|
||||
qb.andWhere('point.userId = :userId', { userId: query.userId });
|
||||
}
|
||||
|
||||
if (query.groupId) {
|
||||
qb.andWhere('point.groupId = :groupId', { groupId: query.groupId });
|
||||
}
|
||||
|
||||
qb.orderBy('point.createdAt', 'DESC');
|
||||
|
||||
const points = await qb.getMany();
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在小组的积分总和
|
||||
*/
|
||||
async getUserBalance(userId: string, groupId: string) {
|
||||
const result = await this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.select('SUM(point.amount)', 'total')
|
||||
.where('point.userId = :userId', { userId })
|
||||
.andWhere('point.groupId = :groupId', { groupId })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
userId,
|
||||
groupId,
|
||||
balance: parseInt(result.total || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小组积分排行榜
|
||||
*/
|
||||
async getGroupRanking(groupId: string, limit: number = 10) {
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const ranking = await this.pointRepository
|
||||
.createQueryBuilder('point')
|
||||
.select('point.userId', 'userId')
|
||||
.addSelect('SUM(point.amount)', 'totalPoints')
|
||||
.leftJoin('point.user', 'user')
|
||||
.addSelect('user.username', 'username')
|
||||
.where('point.groupId = :groupId', { groupId })
|
||||
.groupBy('point.userId')
|
||||
.orderBy('totalPoints', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany();
|
||||
|
||||
return ranking.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
userId: item.userId,
|
||||
username: item.username,
|
||||
totalPoints: parseInt(item.totalPoints),
|
||||
}));
|
||||
}
|
||||
}
|
||||
127
src/modules/schedules/dto/schedule.dto.ts
Normal file
127
src/modules/schedules/dto/schedule.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TimeSlotDto {
|
||||
@ApiProperty({ description: '开始时间', example: '2024-01-20T19:00:00Z' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', example: '2024-01-20T23:00:00Z' })
|
||||
@IsDateString()
|
||||
endTime: Date;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class CreateScheduleDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '标题', example: '本周空闲时间' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimeSlotDto)
|
||||
availableSlots: TimeSlotDto[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiProperty({ description: '标题', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto], required: false })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimeSlotDto)
|
||||
@IsOptional()
|
||||
availableSlots?: TimeSlotDto[];
|
||||
}
|
||||
|
||||
export class QuerySchedulesDto {
|
||||
@ApiProperty({ description: '小组ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@ApiProperty({ description: '用户ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '开始时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endTime?: Date;
|
||||
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class FindCommonSlotsDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '开始时间' })
|
||||
@IsDateString()
|
||||
startTime: Date;
|
||||
|
||||
@ApiProperty({ description: '结束时间' })
|
||||
@IsDateString()
|
||||
endTime: Date;
|
||||
|
||||
@ApiProperty({ description: '最少参与人数', example: 3, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
minParticipants?: number;
|
||||
}
|
||||
99
src/modules/schedules/schedules.controller.ts
Normal file
99
src/modules/schedules/schedules.controller.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
QuerySchedulesDto,
|
||||
FindCommonSlotsDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('schedules')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private readonly schedulesService: SchedulesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建排班' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
async create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() createDto: CreateScheduleDto,
|
||||
) {
|
||||
return this.schedulesService.create(userId, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取排班列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
|
||||
@ApiQuery({ name: 'userId', required: false, description: '用户ID' })
|
||||
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
|
||||
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
async findAll(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() queryDto: QuerySchedulesDto,
|
||||
) {
|
||||
return this.schedulesService.findAll(userId, queryDto);
|
||||
}
|
||||
|
||||
@Post('common-slots')
|
||||
@ApiOperation({ summary: '查找共同空闲时间' })
|
||||
@ApiResponse({ status: 200, description: '查询成功' })
|
||||
async findCommonSlots(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() findDto: FindCommonSlotsDto,
|
||||
) {
|
||||
return this.schedulesService.findCommonSlots(userId, findDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取排班详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.schedulesService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新排班' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateScheduleDto,
|
||||
) {
|
||||
return this.schedulesService.update(userId, id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除排班' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
async remove(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.schedulesService.remove(userId, id);
|
||||
}
|
||||
}
|
||||
15
src/modules/schedules/schedules.module.ts
Normal file
15
src/modules/schedules/schedules.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Schedule, Group, GroupMember])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
394
src/modules/schedules/schedules.service.spec.ts
Normal file
394
src/modules/schedules/schedules.service.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { TimeSlotDto } from './dto/schedule.dto';
|
||||
|
||||
describe('SchedulesService', () => {
|
||||
let service: SchedulesService;
|
||||
let mockScheduleRepository: any;
|
||||
let mockGroupRepository: any;
|
||||
let mockGroupMemberRepository: any;
|
||||
|
||||
const mockUser = { id: 'user-1', username: 'testuser' };
|
||||
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
|
||||
const mockMembership = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockTimeSlots: TimeSlotDto[] = [
|
||||
{
|
||||
startTime: new Date('2024-01-20T19:00:00Z'),
|
||||
endTime: new Date('2024-01-20T21:00:00Z'),
|
||||
note: '晚上空闲',
|
||||
},
|
||||
{
|
||||
startTime: new Date('2024-01-21T14:00:00Z'),
|
||||
endTime: new Date('2024-01-21T17:00:00Z'),
|
||||
note: '下午空闲',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSchedule = {
|
||||
id: 'schedule-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
availableSlots: mockTimeSlots,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockScheduleRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
mockGroupMemberRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(Schedule),
|
||||
useValue: mockScheduleRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: mockGroupRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: mockGroupMemberRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SchedulesService>(SchedulesService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建排班', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.create.mockReturnValue(mockSchedule);
|
||||
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
|
||||
mockScheduleRepository.findOne.mockResolvedValue({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
|
||||
const result = await service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(mockScheduleRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在小组不存在时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组中时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: mockTimeSlots,
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在时间段为空时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: [],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在时间段无效时抛出异常', async () => {
|
||||
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
await expect(
|
||||
service.create('user-1', {
|
||||
groupId: 'group-1',
|
||||
title: '测试排班',
|
||||
availableSlots: [
|
||||
{
|
||||
startTime: new Date('2024-01-20T21:00:00Z'),
|
||||
endTime: new Date('2024-01-20T19:00:00Z'), // 结束时间早于开始时间
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该成功获取排班列表', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('应该在指定小组且用户不在小组时抛出异常', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findAll('user-1', {
|
||||
groupId: 'group-1',
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在无小组ID时返回用户所在所有小组的排班', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest
|
||||
.fn()
|
||||
.mockResolvedValue([[mockSchedule], 1]),
|
||||
};
|
||||
|
||||
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
mockGroupMemberRepository.find.mockResolvedValue([
|
||||
{ groupId: 'group-1' },
|
||||
{ groupId: 'group-2' },
|
||||
]);
|
||||
|
||||
const result = await service.findAll('user-1', {});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mockGroupMemberRepository.find).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取排班详情', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
|
||||
const result = await service.findOne('schedule-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('schedule-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新排班', async () => {
|
||||
mockScheduleRepository.findOne
|
||||
.mockResolvedValueOnce(mockSchedule)
|
||||
.mockResolvedValueOnce({
|
||||
...mockSchedule,
|
||||
user: mockUser,
|
||||
group: mockGroup,
|
||||
});
|
||||
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
|
||||
|
||||
const result = await service.update('user-1', 'schedule-1', {
|
||||
availableSlots: mockTimeSlots,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(mockScheduleRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', 'schedule-1', { availableSlots: mockTimeSlots }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在非创建者更新时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
|
||||
await expect(
|
||||
service.update('user-2', 'schedule-1', { availableSlots: mockTimeSlots }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除排班', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
mockScheduleRepository.remove.mockResolvedValue(mockSchedule);
|
||||
|
||||
const result = await service.remove('user-1', 'schedule-1');
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockScheduleRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在排班不存在时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove('user-1', 'schedule-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在非创建者删除时抛出异常', async () => {
|
||||
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
|
||||
|
||||
await expect(service.remove('user-2', 'schedule-1')).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCommonSlots', () => {
|
||||
it('应该成功查找共同空闲时间', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.find.mockResolvedValue([
|
||||
{
|
||||
...mockSchedule,
|
||||
userId: 'user-1',
|
||||
user: { id: 'user-1' },
|
||||
},
|
||||
{
|
||||
...mockSchedule,
|
||||
id: 'schedule-2',
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2' },
|
||||
availableSlots: [
|
||||
{
|
||||
startTime: new Date('2024-01-20T19:30:00Z'),
|
||||
endTime: new Date('2024-01-20T22:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
minParticipants: 2,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('commonSlots');
|
||||
expect(result).toHaveProperty('totalParticipants');
|
||||
expect(result.totalParticipants).toBe(2);
|
||||
});
|
||||
|
||||
it('应该在用户不在小组时抛出异常', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('应该在没有排班数据时返回空结果', async () => {
|
||||
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
|
||||
mockScheduleRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findCommonSlots('user-1', {
|
||||
groupId: 'group-1',
|
||||
startTime: new Date('2024-01-20T00:00:00Z'),
|
||||
endTime: new Date('2024-01-22T00:00:00Z'),
|
||||
});
|
||||
|
||||
expect(result.commonSlots).toEqual([]);
|
||||
expect(result.message).toBe('暂无排班数据');
|
||||
});
|
||||
});
|
||||
});
|
||||
446
src/modules/schedules/schedules.service.ts
Normal file
446
src/modules/schedules/schedules.service.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { Schedule } from '../../entities/schedule.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
QuerySchedulesDto,
|
||||
FindCommonSlotsDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { PaginationUtil } from '../../common/utils/pagination.util';
|
||||
|
||||
export interface TimeSlot {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CommonSlot {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
participants: string[];
|
||||
participantCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
constructor(
|
||||
@InjectRepository(Schedule)
|
||||
private scheduleRepository: Repository<Schedule>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建排班
|
||||
*/
|
||||
async create(userId: string, createDto: CreateScheduleDto) {
|
||||
const { groupId, availableSlots, ...rest } = createDto;
|
||||
|
||||
// 验证小组是否存在
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId, isActive: true },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证时间段
|
||||
this.validateTimeSlots(availableSlots);
|
||||
|
||||
// 创建排班
|
||||
const schedule = this.scheduleRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
userId,
|
||||
availableSlots: availableSlots as any,
|
||||
});
|
||||
|
||||
await this.scheduleRepository.save(schedule);
|
||||
|
||||
return this.findOne(schedule.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排班列表
|
||||
*/
|
||||
async findAll(userId: string, queryDto: QuerySchedulesDto) {
|
||||
const {
|
||||
groupId,
|
||||
userId: targetUserId,
|
||||
startTime,
|
||||
endTime,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = queryDto;
|
||||
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
|
||||
|
||||
const queryBuilder = this.scheduleRepository
|
||||
.createQueryBuilder('schedule')
|
||||
.leftJoinAndSelect('schedule.group', 'group')
|
||||
.leftJoinAndSelect('schedule.user', 'user');
|
||||
|
||||
// 筛选条件
|
||||
if (groupId) {
|
||||
// 验证用户是否在小组中
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
queryBuilder.andWhere('schedule.groupId = :groupId', { groupId });
|
||||
} else {
|
||||
// 如果没有指定小组,只返回用户所在小组的排班
|
||||
const memberGroups = await this.groupMemberRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
select: ['groupId'],
|
||||
});
|
||||
const groupIds = memberGroups.map((m) => m.groupId);
|
||||
if (groupIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
queryBuilder.andWhere('schedule.groupId IN (:...groupIds)', { groupIds });
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
queryBuilder.andWhere('schedule.userId = :userId', { userId: targetUserId });
|
||||
}
|
||||
|
||||
if (startTime && endTime) {
|
||||
queryBuilder.andWhere('schedule.createdAt BETWEEN :startTime AND :endTime', {
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime),
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('schedule.createdAt', 'DESC')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
// 解析 availableSlots
|
||||
const formattedItems = items.map((item) => ({
|
||||
...item,
|
||||
availableSlots: this.normalizeAvailableSlots(item.availableSlots),
|
||||
}));
|
||||
|
||||
return {
|
||||
items: formattedItems,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: PaginationUtil.getTotalPages(total, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排班详情
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group', 'user'],
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
availableSlots: this.normalizeAvailableSlots(schedule.availableSlots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新排班
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateScheduleDto) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 只有创建者可以修改
|
||||
if (schedule.userId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateDto.availableSlots) {
|
||||
this.validateTimeSlots(updateDto.availableSlots);
|
||||
updateDto.availableSlots = updateDto.availableSlots as any;
|
||||
}
|
||||
|
||||
Object.assign(schedule, updateDto);
|
||||
await this.scheduleRepository.save(schedule);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除排班
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '排班不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 只有创建者可以删除
|
||||
if (schedule.userId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.scheduleRepository.remove(schedule);
|
||||
|
||||
return { message: '排班已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找共同空闲时间
|
||||
*/
|
||||
async findCommonSlots(userId: string, findDto: FindCommonSlotsDto) {
|
||||
const { groupId, startTime, endTime, minParticipants = 2 } = findDto;
|
||||
|
||||
// 验证用户权限
|
||||
await this.checkGroupMembership(userId, groupId);
|
||||
|
||||
// 获取时间范围内的所有排班
|
||||
const schedules = await this.scheduleRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return {
|
||||
commonSlots: [],
|
||||
message: '暂无排班数据',
|
||||
};
|
||||
}
|
||||
|
||||
// 解析所有时间段
|
||||
const userSlots: Map<string, TimeSlot[]> = new Map();
|
||||
schedules.forEach((schedule) => {
|
||||
const slots = this.normalizeAvailableSlots(schedule.availableSlots);
|
||||
const filteredSlots = slots.filter((slot) => {
|
||||
const slotStart = new Date(slot.startTime);
|
||||
const slotEnd = new Date(slot.endTime);
|
||||
const rangeStart = new Date(startTime);
|
||||
const rangeEnd = new Date(endTime);
|
||||
return slotStart >= rangeStart && slotEnd <= rangeEnd;
|
||||
});
|
||||
userSlots.set(schedule.userId, filteredSlots);
|
||||
});
|
||||
|
||||
// 计算时间交集
|
||||
const commonSlots = this.calculateCommonSlots(userSlots, minParticipants);
|
||||
|
||||
// 按参与人数排序
|
||||
commonSlots.sort((a, b) => b.participantCount - a.participantCount);
|
||||
|
||||
return {
|
||||
commonSlots,
|
||||
totalParticipants: schedules.length,
|
||||
minParticipants,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算共同空闲时间
|
||||
*/
|
||||
private calculateCommonSlots(
|
||||
userSlots: Map<string, TimeSlot[]>,
|
||||
minParticipants: number,
|
||||
): CommonSlot[] {
|
||||
const allSlots: Array<{ time: Date; userId: string; type: 'start' | 'end' }> = [];
|
||||
|
||||
// 收集所有时间点
|
||||
userSlots.forEach((slots, userId) => {
|
||||
slots.forEach((slot) => {
|
||||
allSlots.push({
|
||||
time: new Date(slot.startTime),
|
||||
userId,
|
||||
type: 'start',
|
||||
});
|
||||
allSlots.push({
|
||||
time: new Date(slot.endTime),
|
||||
userId,
|
||||
type: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
allSlots.sort((a, b) => a.time.getTime() - b.time.getTime());
|
||||
|
||||
// 扫描线算法计算重叠区间
|
||||
const commonSlots: CommonSlot[] = [];
|
||||
const activeUsers = new Set<string>();
|
||||
let lastTime: Date | null = null;
|
||||
|
||||
allSlots.forEach((event) => {
|
||||
if (lastTime && activeUsers.size >= minParticipants) {
|
||||
// 记录共同空闲时间段
|
||||
if (event.time.getTime() > lastTime.getTime()) {
|
||||
commonSlots.push({
|
||||
startTime: lastTime,
|
||||
endTime: event.time,
|
||||
participants: Array.from(activeUsers),
|
||||
participantCount: activeUsers.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'start') {
|
||||
activeUsers.add(event.userId);
|
||||
} else {
|
||||
activeUsers.delete(event.userId);
|
||||
}
|
||||
|
||||
lastTime = event.time;
|
||||
});
|
||||
|
||||
// 合并相邻的时间段
|
||||
return this.mergeAdjacentSlots(commonSlots);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并相邻的时间段
|
||||
*/
|
||||
private mergeAdjacentSlots(slots: CommonSlot[]): CommonSlot[] {
|
||||
if (slots.length === 0) return [];
|
||||
|
||||
const merged: CommonSlot[] = [];
|
||||
let current = slots[0];
|
||||
|
||||
for (let i = 1; i < slots.length; i++) {
|
||||
const next = slots[i];
|
||||
|
||||
// 如果参与者相同且时间连续,则合并
|
||||
if (
|
||||
current.endTime.getTime() === next.startTime.getTime() &&
|
||||
this.arraysEqual(current.participants, next.participants)
|
||||
) {
|
||||
current.endTime = next.endTime;
|
||||
} else {
|
||||
merged.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时间段
|
||||
*/
|
||||
private validateTimeSlots(slots: TimeSlot[]): void {
|
||||
if (slots.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: '至少需要一个时间段',
|
||||
});
|
||||
}
|
||||
|
||||
slots.forEach((slot, index) => {
|
||||
const start = new Date(slot.startTime);
|
||||
const end = new Date(slot.endTime);
|
||||
|
||||
if (start >= end) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: `时间段${index + 1}的结束时间必须大于开始时间`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化时间段数据
|
||||
*/
|
||||
private normalizeAvailableSlots(slots: any): TimeSlot[] {
|
||||
if (Array.isArray(slots)) {
|
||||
return slots;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个数组是否相同
|
||||
*/
|
||||
private async checkGroupMembership(userId: string, groupId: string) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个数组是否相同
|
||||
*/
|
||||
private arraysEqual(arr1: string[], arr2: string[]): boolean {
|
||||
if (arr1.length !== arr2.length) return false;
|
||||
const sorted1 = [...arr1].sort();
|
||||
const sorted2 = [...arr2].sort();
|
||||
return sorted1.every((val, index) => val === sorted2[index]);
|
||||
}
|
||||
}
|
||||
31
src/modules/users/dto/user.dto.ts
Normal file
31
src/modules/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiProperty({ description: '邮箱', required: false })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: '头像URL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: '旧密码' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
oldPassword: string;
|
||||
|
||||
@ApiProperty({ description: '新密码' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: '密码至少6个字符' })
|
||||
newPassword: string;
|
||||
}
|
||||
46
src/modules/users/users.controller.ts
Normal file
46
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@ApiTags('users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getProfile(@CurrentUser() user: User) {
|
||||
return this.usersService.findOne(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Put('me')
|
||||
@ApiOperation({ summary: '更新当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async update(@CurrentUser() user: User, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(user.id, updateUserDto);
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
@ApiOperation({ summary: '修改密码' })
|
||||
@ApiResponse({ status: 200, description: '修改成功' })
|
||||
async changePassword(
|
||||
@CurrentUser() user: User,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
) {
|
||||
return this.usersService.changePassword(user.id, changePasswordDto);
|
||||
}
|
||||
}
|
||||
13
src/modules/users/users.module.ts
Normal file
13
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
234
src/modules/users/users.service.spec.ts
Normal file
234
src/modules/users/users.service.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
jest.mock('../../common/utils/crypto.util');
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let mockUserRepository: any;
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
password: 'hashedPassword',
|
||||
avatar: null,
|
||||
role: 'user',
|
||||
isMember: false,
|
||||
memberExpireAt: null,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: 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: [
|
||||
UsersService,
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: mockCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('应该成功获取用户信息', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findOne('user-1');
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.username).toBe('testuser');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('user-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('应该成功更新用户信息', async () => {
|
||||
const updateDto = { email: 'newemail@example.com', avatar: 'newavatar.jpg' };
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(mockUser) // 第一次调用:获取原用户
|
||||
.mockResolvedValueOnce(null) // 第二次调用:检查邮箱是否存在
|
||||
.mockResolvedValueOnce({ // 第三次调用:返回更新后的用户
|
||||
...mockUser,
|
||||
...updateDto,
|
||||
});
|
||||
mockUserRepository.save.mockResolvedValue({
|
||||
...mockUser,
|
||||
...updateDto,
|
||||
});
|
||||
|
||||
const result = await service.update('user-1', updateDto);
|
||||
|
||||
expect(result.email).toBe('newemail@example.com');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', { email: 'newemail@example.com' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('应该在邮箱已被使用时抛出异常', async () => {
|
||||
const userWithDifferentEmail = { ...mockUser, email: 'original@example.com' };
|
||||
const anotherUser = { id: 'user-2', email: 'newemail@example.com' };
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(userWithDifferentEmail) // 获取原用户
|
||||
.mockResolvedValueOnce(anotherUser); // 邮箱已存在
|
||||
|
||||
await expect(
|
||||
service.update('user-1', { email: 'newemail@example.com' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在手机号已被使用时抛出异常', async () => {
|
||||
const userWithDifferentPhone = { ...mockUser, phone: '13800138000' };
|
||||
const anotherUser = { id: 'user-2', phone: '13900139000' };
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(userWithDifferentPhone) // 获取原用户
|
||||
.mockResolvedValueOnce(anotherUser); // 手机号已存在
|
||||
|
||||
await expect(
|
||||
service.update('user-1', { phone: '13900139000' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('应该成功修改密码', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(mockUser),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
(CryptoUtil.comparePassword as jest.Mock).mockResolvedValue(true);
|
||||
(CryptoUtil.hashPassword as jest.Mock).mockResolvedValue('newHashedPassword');
|
||||
mockUserRepository.save.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: 'newHashedPassword',
|
||||
});
|
||||
|
||||
const result = await service.changePassword('user-1', {
|
||||
oldPassword: 'oldPassword',
|
||||
newPassword: 'newPassword',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在旧密码错误时抛出异常', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(mockUser),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
(CryptoUtil.comparePassword as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.changePassword('user-1', {
|
||||
oldPassword: 'wrongPassword',
|
||||
newPassword: 'newPassword',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
await expect(
|
||||
service.changePassword('user-1', {
|
||||
oldPassword: 'oldPassword',
|
||||
newPassword: 'newPassword',
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCreatedGroupsCount', () => {
|
||||
it('应该成功获取用户创建的小组数量', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(3),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getCreatedGroupsCount('user-1');
|
||||
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJoinedGroupsCount', () => {
|
||||
it('应该成功获取用户加入的小组数量', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(5),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.getJoinedGroupsCount('user-1');
|
||||
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
174
src/modules/users/users.service.ts
Normal file
174
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
import { CacheService } from '../../common/services/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly CACHE_PREFIX = 'user';
|
||||
private readonly CACHE_TTL = 300; // 5分钟
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
// 先查缓存
|
||||
const cached = this.cacheService.get<any>(id, { prefix: this.CACHE_PREFIX });
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
isMember: user.isMember,
|
||||
memberExpireAt: user.memberExpireAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
|
||||
// 写入缓存
|
||||
this.cacheService.set(id, result, {
|
||||
prefix: this.CACHE_PREFIX,
|
||||
ttl: this.CACHE_TTL,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
async update(id: string, updateUserDto: UpdateUserDto) {
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查邮箱是否已被使用
|
||||
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { email: updateUserDto.email },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '邮箱已被使用',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查手机号是否已被使用
|
||||
if (updateUserDto.phone && updateUserDto.phone !== user.phone) {
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { phone: updateUserDto.phone },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '手机号已被使用',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(user, updateUserDto);
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 清除缓存
|
||||
this.cacheService.del(id, { prefix: this.CACHE_PREFIX });
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
async changePassword(id: string, changePasswordDto: ChangePasswordDto) {
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.id = :id', { id })
|
||||
.addSelect('user.password')
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isPasswordValid = await CryptoUtil.comparePassword(
|
||||
changePasswordDto.oldPassword,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PASSWORD_ERROR,
|
||||
message: '原密码错误',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
user.password = await CryptoUtil.hashPassword(changePasswordDto.newPassword);
|
||||
await this.userRepository.save(user);
|
||||
|
||||
return { message: '密码修改成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户创建的小组数量
|
||||
*/
|
||||
async getCreatedGroupsCount(userId: string): Promise<number> {
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.leftJoin('user.groupMembers', 'member')
|
||||
.leftJoin('member.group', 'group')
|
||||
.where('user.id = :userId', { userId })
|
||||
.andWhere('group.ownerId = :userId', { userId })
|
||||
.getCount();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户加入的小组数量
|
||||
*/
|
||||
async getJoinedGroupsCount(userId: string): Promise<number> {
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.leftJoin('user.groupMembers', 'member')
|
||||
.where('user.id = :userId', { userId })
|
||||
.getCount();
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user