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

- 基于 NestJS + TypeScript + MySQL + Redis 架构
- 完整的模块化设计(认证、用户、小组、游戏、预约等)
- JWT 认证和 RBAC 权限控制系统
- Docker 容器化部署支持
- 添加 CLAUDE.md 项目开发指南
- 配置 .gitignore 忽略文件

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
UGREEN USER
2026-01-28 10:42:06 +08:00
commit b25aa5b143
134 changed files with 30536 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { AppointmentsService } from './appointments.service';
import {
CreateAppointmentDto,
UpdateAppointmentDto,
QueryAppointmentsDto,
JoinAppointmentDto,
} from './dto/appointment.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('appointments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('appointments')
export class AppointmentsController {
constructor(private readonly appointmentsService: AppointmentsService) {}
@Post()
@ApiOperation({ summary: '创建预约' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(
@CurrentUser('id') userId: string,
@Body() createDto: CreateAppointmentDto,
) {
return this.appointmentsService.create(userId, createDto);
}
@Get()
@ApiOperation({ summary: '获取预约列表' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
@ApiQuery({ name: 'gameId', required: false, description: '游戏ID' })
@ApiQuery({ name: 'status', required: false, description: '状态' })
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
async findAll(
@CurrentUser('id') userId: string,
@Query() queryDto: QueryAppointmentsDto,
) {
return this.appointmentsService.findAll(userId, queryDto);
}
@Get('my')
@ApiOperation({ summary: '获取我参与的预约' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'status', required: false, description: '状态' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
async findMyAppointments(
@CurrentUser('id') userId: string,
@Query() queryDto: QueryAppointmentsDto,
) {
return this.appointmentsService.findMyAppointments(userId, queryDto);
}
@Get(':id')
@ApiOperation({ summary: '获取预约详情' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.findOne(id, userId);
}
@Post('join')
@ApiOperation({ summary: '加入预约' })
@ApiResponse({ status: 200, description: '加入成功' })
async join(
@CurrentUser('id') userId: string,
@Body() joinDto: JoinAppointmentDto,
) {
return this.appointmentsService.join(userId, joinDto.appointmentId);
}
@Delete(':id/leave')
@ApiOperation({ summary: '退出预约' })
@ApiResponse({ status: 200, description: '退出成功' })
async leave(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.leave(userId, id);
}
@Put(':id')
@ApiOperation({ summary: '更新预约' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser('id') userId: string,
@Param('id') id: string,
@Body() updateDto: UpdateAppointmentDto,
) {
return this.appointmentsService.update(userId, id, updateDto);
}
@Put(':id/confirm')
@ApiOperation({ summary: '确认预约' })
@ApiResponse({ status: 200, description: '确认成功' })
async confirm(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.confirm(userId, id);
}
@Put(':id/complete')
@ApiOperation({ summary: '完成预约' })
@ApiResponse({ status: 200, description: '完成成功' })
async complete(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.complete(userId, id);
}
@Delete(':id')
@ApiOperation({ summary: '取消预约' })
@ApiResponse({ status: 200, description: '取消成功' })
async cancel(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.cancel(userId, id);
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppointmentsService } from './appointments.service';
import { AppointmentsController } from './appointments.controller';
import { Appointment } from '../../entities/appointment.entity';
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { Game } from '../../entities/game.entity';
import { User } from '../../entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Appointment,
AppointmentParticipant,
Group,
GroupMember,
Game,
User,
]),
],
controllers: [AppointmentsController],
providers: [AppointmentsService],
exports: [AppointmentsService],
})
export class AppointmentsModule {}

View File

@@ -0,0 +1,396 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { AppointmentsService } from './appointments.service';
import { Appointment } from '../../entities/appointment.entity';
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { Game } from '../../entities/game.entity';
import { User } from '../../entities/user.entity';
import { CacheService } from '../../common/services/cache.service';
enum AppointmentStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
CANCELLED = 'cancelled',
COMPLETED = 'completed',
}
describe('AppointmentsService', () => {
let service: AppointmentsService;
let mockAppointmentRepository: any;
let mockParticipantRepository: any;
let mockGroupRepository: any;
let mockGroupMemberRepository: any;
let mockGameRepository: any;
let mockUserRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' };
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
const mockGame = { id: 'game-1', name: '测试游戏' };
const mockMembership = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: 'member',
isActive: true,
};
const mockAppointment = {
id: 'appointment-1',
groupId: 'group-1',
gameId: 'game-1',
creatorId: 'user-1',
title: '周末开黑',
description: '描述',
startTime: new Date('2024-01-20T19:00:00Z'),
endTime: new Date('2024-01-20T23:00:00Z'),
maxParticipants: 5,
status: AppointmentStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockParticipant = {
id: 'participant-1',
appointmentId: 'appointment-1',
userId: 'user-1',
status: 'accepted',
joinedAt: new Date(),
};
beforeEach(async () => {
mockAppointmentRepository = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
mockParticipantRepository = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
mockGroupRepository = {
findOne: jest.fn(),
};
mockGroupMemberRepository = {
findOne: jest.fn(),
find: jest.fn(),
};
mockGameRepository = {
findOne: jest.fn(),
};
mockUserRepository = {
findOne: jest.fn(),
};
const mockCacheService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
clear: jest.fn(),
clearByPrefix: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AppointmentsService,
{
provide: getRepositoryToken(Appointment),
useValue: mockAppointmentRepository,
},
{
provide: getRepositoryToken(AppointmentParticipant),
useValue: mockParticipantRepository,
},
{
provide: getRepositoryToken(Group),
useValue: mockGroupRepository,
},
{
provide: getRepositoryToken(GroupMember),
useValue: mockGroupMemberRepository,
},
{
provide: getRepositoryToken(Game),
useValue: mockGameRepository,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: CacheService,
useValue: mockCacheService,
},
],
}).compile();
service = module.get<AppointmentsService>(AppointmentsService);
});
describe('create', () => {
it('应该成功创建预约', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockGameRepository.findOne.mockResolvedValue(mockGame);
mockAppointmentRepository.create.mockReturnValue(mockAppointment);
mockAppointmentRepository.save.mockResolvedValue(mockAppointment);
mockParticipantRepository.create.mockReturnValue(mockParticipant);
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
mockAppointmentRepository.findOne.mockResolvedValue({
...mockAppointment,
group: mockGroup,
game: mockGame,
creator: mockUser,
participants: [mockParticipant],
});
const result = await service.create('user-1', {
groupId: 'group-1',
gameId: 'game-1',
title: '周末开黑',
startTime: new Date('2024-01-20T19:00:00Z'),
maxParticipants: 5,
});
expect(result).toHaveProperty('id');
expect(result.title).toBe('周末开黑');
expect(mockAppointmentRepository.save).toHaveBeenCalled();
expect(mockParticipantRepository.save).toHaveBeenCalled();
});
it('应该在小组不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
gameId: 'game-1',
title: '周末开黑',
startTime: new Date('2024-01-20T19:00:00Z'),
maxParticipants: 5,
}),
).rejects.toThrow(NotFoundException);
});
it('应该在用户不在小组中时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
gameId: 'game-1',
title: '周末开黑',
startTime: new Date('2024-01-20T19:00:00Z'),
maxParticipants: 5,
}),
).rejects.toThrow(ForbiddenException);
});
it('应该在游戏不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockGameRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
gameId: 'game-1',
title: '周末开黑',
startTime: new Date('2024-01-20T19:00:00Z'),
maxParticipants: 5,
}),
).rejects.toThrow(NotFoundException);
});
});
describe('findAll', () => {
it('应该成功获取预约列表', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockAppointment], 1]),
};
mockAppointmentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', {
groupId: 'group-1',
page: 1,
limit: 10,
});
expect(result).toHaveProperty('items');
expect(result).toHaveProperty('total');
expect(result.items).toHaveLength(1);
});
});
describe('findOne', () => {
it('应该成功获取预约详情', async () => {
mockAppointmentRepository.findOne.mockResolvedValue({
...mockAppointment,
group: mockGroup,
game: mockGame,
creator: mockUser,
});
const result = await service.findOne('appointment-1');
expect(result).toHaveProperty('id');
expect(result.id).toBe('appointment-1');
});
it('应该在预约不存在时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('appointment-1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
it('应该成功更新预约', async () => {
mockAppointmentRepository.findOne
.mockResolvedValueOnce(mockAppointment)
.mockResolvedValueOnce({
...mockAppointment,
title: '更新后的标题',
group: mockGroup,
game: mockGame,
creator: mockUser,
});
mockAppointmentRepository.save.mockResolvedValue({
...mockAppointment,
title: '更新后的标题',
});
const result = await service.update('user-1', 'appointment-1', {
title: '更新后的标题',
});
expect(result.title).toBe('更新后的标题');
});
it('应该在非创建者更新时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect(
service.update('user-2', 'appointment-1', { title: '新标题' }),
).rejects.toThrow(ForbiddenException);
});
});
describe('cancel', () => {
it('应该成功取消预约', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockAppointmentRepository.save.mockResolvedValue({
...mockAppointment,
status: AppointmentStatus.CANCELLED,
});
const result = await service.cancel('user-1', 'appointment-1');
expect(result).toHaveProperty('message');
});
it('应该在非创建者取消时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect(
service.cancel('user-2', 'appointment-1'),
).rejects.toThrow(ForbiddenException);
});
});
describe('join', () => {
it('应该成功加入预约', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(null);
mockParticipantRepository.count.mockResolvedValue(3);
mockParticipantRepository.create.mockReturnValue(mockParticipant);
mockParticipantRepository.save.mockResolvedValue(mockParticipant);
const result = await service.join('user-2', 'appointment-1');
expect(result).toHaveProperty('message');
expect(mockParticipantRepository.save).toHaveBeenCalled();
});
it('应该在预约已满时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(null);
mockParticipantRepository.count.mockResolvedValue(5);
await expect(
service.join('user-2', 'appointment-1'),
).rejects.toThrow(BadRequestException);
});
it('应该在已加入时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
await expect(
service.join('user-1', 'appointment-1'),
).rejects.toThrow(BadRequestException);
});
});
describe('leave', () => {
it('应该成功离开预约', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
mockParticipantRepository.remove.mockResolvedValue(mockParticipant);
const result = await service.leave('user-1', 'appointment-1');
expect(result).toHaveProperty('message');
expect(mockParticipantRepository.remove).toHaveBeenCalled();
});
it('应该在创建者尝试离开时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect(
service.leave('user-1', 'appointment-1'),
).rejects.toThrow(BadRequestException);
});
it('应该在未加入时抛出异常', async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockParticipantRepository.findOne.mockResolvedValue(null);
await expect(
service.leave('user-2', 'appointment-1'),
).rejects.toThrow(BadRequestException);
});
});
});

View File

@@ -0,0 +1,512 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThan, MoreThan } from 'typeorm';
import { Appointment } from '../../entities/appointment.entity';
import { AppointmentParticipant } from '../../entities/appointment-participant.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { Game } from '../../entities/game.entity';
import { User } from '../../entities/user.entity';
import {
CreateAppointmentDto,
UpdateAppointmentDto,
QueryAppointmentsDto,
} from './dto/appointment.dto';
import { AppointmentStatus, GroupMemberRole } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import { PaginationUtil } from '../../common/utils/pagination.util';
import { CacheService } from '../../common/services/cache.service';
@Injectable()
export class AppointmentsService {
private readonly CACHE_PREFIX = 'appointment';
private readonly CACHE_TTL = 300; // 5分钟
constructor(
@InjectRepository(Appointment)
private appointmentRepository: Repository<Appointment>,
@InjectRepository(AppointmentParticipant)
private participantRepository: Repository<AppointmentParticipant>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
@InjectRepository(Game)
private gameRepository: Repository<Game>,
@InjectRepository(User)
private userRepository: Repository<User>,
private readonly cacheService: CacheService,
) {}
/**
* 创建预约
*/
async create(userId: string, createDto: CreateAppointmentDto) {
const { groupId, gameId, ...rest } = createDto;
// 验证小组是否存在
const group = await this.groupRepository.findOne({
where: { id: groupId, isActive: true },
});
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证用户是否在小组中
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 验证游戏是否存在
const game = await this.gameRepository.findOne({
where: { id: gameId, isActive: true },
});
if (!game) {
throw new NotFoundException({
code: ErrorCode.GAME_NOT_FOUND,
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
});
}
// 创建预约
const appointment = this.appointmentRepository.create({
...rest,
groupId,
gameId,
initiatorId: userId,
status: AppointmentStatus.OPEN,
});
await this.appointmentRepository.save(appointment);
// 创建者自动加入预约
const participant = this.participantRepository.create({
appointmentId: appointment.id,
userId,
});
await this.participantRepository.save(participant);
return this.findOne(appointment.id, userId);
}
/**
* 获取预约列表
*/
async findAll(userId: string, queryDto: QueryAppointmentsDto) {
const {
groupId,
gameId,
status,
startTime,
endTime,
page = 1,
limit = 10,
} = queryDto;
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.appointmentRepository
.createQueryBuilder('appointment')
.leftJoinAndSelect('appointment.group', 'group')
.leftJoinAndSelect('appointment.game', 'game')
.leftJoinAndSelect('appointment.creator', 'creator')
.leftJoinAndSelect('appointment.participants', 'participants')
.leftJoinAndSelect('participants.user', 'participantUser');
// 筛选条件
if (groupId) {
queryBuilder.andWhere('appointment.groupId = :groupId', { groupId });
}
if (gameId) {
queryBuilder.andWhere('appointment.gameId = :gameId', { gameId });
}
if (status) {
queryBuilder.andWhere('appointment.status = :status', { status });
}
if (startTime && endTime) {
queryBuilder.andWhere('appointment.startTime BETWEEN :startTime AND :endTime', {
startTime,
endTime,
});
} else if (startTime) {
queryBuilder.andWhere('appointment.startTime >= :startTime', { startTime });
} else if (endTime) {
queryBuilder.andWhere('appointment.startTime <= :endTime', { endTime });
}
// 分页
const [items, total] = await queryBuilder
.orderBy('appointment.startTime', 'ASC')
.skip(offset)
.take(limit)
.getManyAndCount();
return {
items: items.map((item) => this.formatAppointment(item, userId)),
total,
page,
limit,
totalPages: PaginationUtil.getTotalPages(total, limit),
};
}
/**
* 获取我参与的预约
*/
async findMyAppointments(userId: string, queryDto: QueryAppointmentsDto) {
const { status, page = 1, limit = 10 } = queryDto;
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.appointmentRepository
.createQueryBuilder('appointment')
.innerJoin('appointment.participants', 'participant', 'participant.userId = :userId', {
userId,
})
.leftJoinAndSelect('appointment.group', 'group')
.leftJoinAndSelect('appointment.game', 'game')
.leftJoinAndSelect('appointment.creator', 'creator')
.leftJoinAndSelect('appointment.participants', 'participants')
.leftJoinAndSelect('participants.user', 'participantUser');
if (status) {
queryBuilder.andWhere('appointment.status = :status', { status });
}
const [items, total] = await queryBuilder
.orderBy('appointment.startTime', 'ASC')
.skip(offset)
.take(limit)
.getManyAndCount();
return {
items: items.map((item) => this.formatAppointment(item, userId)),
total,
page,
limit,
totalPages: PaginationUtil.getTotalPages(total, limit),
};
}
/**
* 获取预约详情
*/
async findOne(id: string, userId?: string) {
// 先查缓存
const cacheKey = userId ? `${id}_${userId}` : id;
const cached = this.cacheService.get<any>(cacheKey, { prefix: this.CACHE_PREFIX });
if (cached) {
return cached;
}
const appointment = await this.appointmentRepository.findOne({
where: { id },
relations: ['group', 'game', 'creator', 'participants', 'participants.user'],
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
const result = this.formatAppointment(appointment, userId);
// 写入缓存
this.cacheService.set(cacheKey, result, {
prefix: this.CACHE_PREFIX,
ttl: this.CACHE_TTL,
});
return result;
}
/**
* 加入预约(使用原子更新防止并发竞态条件)
*/
async join(userId: string, appointmentId: string) {
const appointment = await this.appointmentRepository.findOne({
where: { id: appointmentId },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 检查预约状态
if (appointment.status === AppointmentStatus.CANCELLED) {
throw new BadRequestException({
code: ErrorCode.APPOINTMENT_CLOSED,
message: '预约已取消',
});
}
if (appointment.status === AppointmentStatus.FINISHED) {
throw new BadRequestException({
code: ErrorCode.APPOINTMENT_CLOSED,
message: '预约已完成',
});
}
// 检查用户是否在小组中
const membership = await this.groupMemberRepository.findOne({
where: { groupId: appointment.groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 检查是否已经参与
const existingParticipant = await this.participantRepository.findOne({
where: { appointmentId, userId },
});
if (existingParticipant) {
throw new BadRequestException({
code: ErrorCode.ALREADY_JOINED,
message: ErrorMessage[ErrorCode.ALREADY_JOINED],
});
}
// 使用原子更新:只有当当前参与人数小于最大人数时才成功
const updateResult = await this.appointmentRepository
.createQueryBuilder()
.update(Appointment)
.set({
currentParticipants: () => 'currentParticipants + 1',
})
.where('id = :id', { id: appointmentId })
.andWhere('currentParticipants < maxParticipants')
.execute();
// 如果影响的行数为0说明预约已满
if (updateResult.affected === 0) {
throw new BadRequestException({
code: ErrorCode.APPOINTMENT_FULL,
message: ErrorMessage[ErrorCode.APPOINTMENT_FULL],
});
}
// 加入预约
const participant = this.participantRepository.create({
appointmentId,
userId,
});
await this.participantRepository.save(participant);
return this.findOne(appointmentId, userId);
}
/**
* 退出预约
*/
async leave(userId: string, appointmentId: string) {
const appointment = await this.appointmentRepository.findOne({
where: { id: appointmentId },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 创建者不能退出
if (appointment.initiatorId === userId) {
throw new BadRequestException({
code: ErrorCode.NO_PERMISSION,
message: '创建者不能退出预约',
});
}
const participant = await this.participantRepository.findOne({
where: { appointmentId, userId },
});
if (!participant) {
throw new BadRequestException({
code: ErrorCode.NOT_JOINED,
message: ErrorMessage[ErrorCode.NOT_JOINED],
});
}
await this.participantRepository.remove(participant);
return { message: '已退出预约' };
}
/**
* 更新预约
*/
async update(userId: string, id: string, updateDto: UpdateAppointmentDto) {
const appointment = await this.appointmentRepository.findOne({
where: { id },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
Object.assign(appointment, updateDto);
await this.appointmentRepository.save(appointment);
// 清除缓存包括有userId和无userId的两种情况
this.cacheService.clearByPrefix(`${this.CACHE_PREFIX}:${id}`);
return this.findOne(id, userId);
}
/**
* 取消预约
*/
async cancel(userId: string, id: string) {
const appointment = await this.appointmentRepository.findOne({
where: { id },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
appointment.status = AppointmentStatus.CANCELLED;
await this.appointmentRepository.save(appointment);
return { message: '预约已取消' };
}
/**
* 确认预约
*/
async confirm(userId: string, id: string) {
const appointment = await this.appointmentRepository.findOne({
where: { id },
relations: ['participants'],
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
// 检查是否已满员
if (appointment.participants.length >= appointment.maxParticipants) {
appointment.status = AppointmentStatus.FULL;
}
await this.appointmentRepository.save(appointment);
return this.findOne(id, userId);
}
/**
* 完成预约
*/
async complete(userId: string, id: string) {
const appointment = await this.appointmentRepository.findOne({
where: { id },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId);
appointment.status = AppointmentStatus.FINISHED;
await this.appointmentRepository.save(appointment);
return this.findOne(id, userId);
}
/**
* 检查用户权限
*/
private async checkPermission(
userId: string,
groupId: string,
initiatorId: string,
): Promise<void> {
// 如果是创建者,直接通过
if (userId === initiatorId) {
return;
}
// 检查是否是小组管理员或组长
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (
!membership ||
(membership.role !== GroupMemberRole.ADMIN &&
membership.role !== GroupMemberRole.OWNER)
) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
}
/**
* 格式化预约数据
*/
private formatAppointment(appointment: Appointment, userId?: string) {
const participantCount = appointment.participants?.length || 0;
const isParticipant = userId
? appointment.participants?.some((p) => p.userId === userId)
: false;
const isCreator = userId ? appointment.initiatorId === userId : false;
return {
...appointment,
participantCount,
isParticipant,
isCreator,
isFull: participantCount >= appointment.maxParticipants,
};
}
}

View File

@@ -0,0 +1,189 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
Min,
IsDateString,
IsEnum,
IsArray,
ValidateNested,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { AppointmentStatus } from '../../../common/enums';
export class CreateAppointmentDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '游戏ID' })
@IsString()
@IsNotEmpty({ message: '游戏ID不能为空' })
gameId: string;
@ApiProperty({ description: '预约标题' })
@IsString()
@IsNotEmpty({ message: '预约标题不能为空' })
title: string;
@ApiProperty({ description: '预约描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '预约开始时间' })
@IsDateString()
startTime: Date;
@ApiProperty({ description: '预约结束时间', required: false })
@IsDateString()
@IsOptional()
endTime?: Date;
@ApiProperty({ description: '最大参与人数', example: 5 })
@IsNumber()
@Min(1)
@Type(() => Number)
maxParticipants: number;
}
export class UpdateAppointmentDto {
@ApiProperty({ description: '预约标题', required: false })
@IsString()
@IsOptional()
title?: string;
@ApiProperty({ description: '预约描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '预约开始时间', required: false })
@IsDateString()
@IsOptional()
startTime?: Date;
@ApiProperty({ description: '预约结束时间', required: false })
@IsDateString()
@IsOptional()
endTime?: Date;
@ApiProperty({ description: '最大参与人数', required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
maxParticipants?: number;
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
@IsEnum(AppointmentStatus)
@IsOptional()
status?: AppointmentStatus;
}
export class JoinAppointmentDto {
@ApiProperty({ description: '预约ID' })
@IsString()
@IsNotEmpty({ message: '预约ID不能为空' })
appointmentId: string;
}
export class QueryAppointmentsDto {
@ApiProperty({ description: '小组ID', required: false })
@IsString()
@IsOptional()
groupId?: string;
@ApiProperty({ description: '游戏ID', required: false })
@IsString()
@IsOptional()
gameId?: string;
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false })
@IsEnum(AppointmentStatus)
@IsOptional()
status?: AppointmentStatus;
@ApiProperty({ description: '开始时间', required: false })
@IsDateString()
@IsOptional()
startTime?: Date;
@ApiProperty({ description: '结束时间', required: false })
@IsDateString()
@IsOptional()
endTime?: Date;
@ApiProperty({ description: '页码', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
limit?: number;
}
export class PollOptionDto {
@ApiProperty({ description: '选项时间' })
@IsDateString()
time: Date;
@ApiProperty({ description: '选项描述', required: false })
@IsString()
@IsOptional()
description?: string;
}
export class CreatePollDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '游戏ID' })
@IsString()
@IsNotEmpty({ message: '游戏ID不能为空' })
gameId: string;
@ApiProperty({ description: '投票标题' })
@IsString()
@IsNotEmpty({ message: '投票标题不能为空' })
title: string;
@ApiProperty({ description: '投票描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '投票选项', type: [PollOptionDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PollOptionDto)
options: PollOptionDto[];
@ApiProperty({ description: '投票截止时间' })
@IsDateString()
deadline: Date;
}
export class VoteDto {
@ApiProperty({ description: '投票ID' })
@IsString()
@IsNotEmpty({ message: '投票ID不能为空' })
pollId: string;
@ApiProperty({ description: '选项索引' })
@IsNumber()
@Min(0)
@Type(() => Number)
optionIndex: number;
}

View File

@@ -0,0 +1,84 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AssetsService } from './assets.service';
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto, ReturnAssetDto } from './dto/asset.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('assets')
@Controller('assets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Post()
@ApiOperation({ summary: '创建资产(管理员)' })
create(@CurrentUser() user, @Body() createDto: CreateAssetDto) {
return this.assetsService.create(user.id, createDto);
}
@Get('group/:groupId')
@ApiOperation({ summary: '查询小组资产列表' })
findAll(@Param('groupId') groupId: string) {
return this.assetsService.findAll(groupId);
}
@Get(':id')
@ApiOperation({ summary: '查询资产详情' })
findOne(@CurrentUser() user, @Param('id') id: string) {
return this.assetsService.findOne(id, user.id);
}
@Patch(':id')
@ApiOperation({ summary: '更新资产(管理员)' })
update(
@CurrentUser() user,
@Param('id') id: string,
@Body() updateDto: UpdateAssetDto,
) {
return this.assetsService.update(user.id, id, updateDto);
}
@Post(':id/borrow')
@ApiOperation({ summary: '借用资产' })
borrow(
@CurrentUser() user,
@Param('id') id: string,
@Body() borrowDto: BorrowAssetDto,
) {
return this.assetsService.borrow(user.id, id, borrowDto);
}
@Post(':id/return')
@ApiOperation({ summary: '归还资产' })
returnAsset(
@CurrentUser() user,
@Param('id') id: string,
@Body() returnDto: ReturnAssetDto,
) {
return this.assetsService.return(user.id, id, returnDto.note);
}
@Get(':id/logs')
@ApiOperation({ summary: '查询资产借还记录' })
getLogs(@Param('id') id: string) {
return this.assetsService.getLogs(id);
}
@Delete(':id')
@ApiOperation({ summary: '删除资产(管理员)' })
remove(@CurrentUser() user, @Param('id') id: string) {
return this.assetsService.remove(user.id, id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import { Asset } from '../../entities/asset.entity';
import { AssetLog } from '../../entities/asset-log.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])],
controllers: [AssetsController],
providers: [AssetsService],
exports: [AssetsService],
})
export class AssetsModule {}

View File

@@ -0,0 +1,242 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { AssetsService } from './assets.service';
import { Asset } from '../../entities/asset.entity';
import { AssetLog } from '../../entities/asset-log.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { AssetType, AssetStatus, GroupMemberRole, AssetLogAction } from '../../common/enums';
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
describe('AssetsService', () => {
let service: AssetsService;
let assetRepository: Repository<Asset>;
let assetLogRepository: Repository<AssetLog>;
let groupRepository: Repository<Group>;
let groupMemberRepository: Repository<GroupMember>;
const mockAsset = {
id: 'asset-1',
groupId: 'group-1',
type: AssetType.ACCOUNT,
name: '测试账号',
description: '测试描述',
accountCredentials: 'encrypted-data',
quantity: 1,
status: AssetStatus.AVAILABLE,
currentBorrowerId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockGroup = {
id: 'group-1',
name: '测试小组',
};
const mockGroupMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: GroupMemberRole.ADMIN,
};
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AssetsService,
{
provide: getRepositoryToken(Asset),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
},
},
{
provide: getRepositoryToken(AssetLog),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
},
},
{
provide: getRepositoryToken(Group),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(GroupMember),
useValue: {
findOne: jest.fn(),
},
},
{
provide: DataSource,
useValue: mockDataSource,
},
],
}).compile();
service = module.get<AssetsService>(AssetsService);
assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset));
assetLogRepository = module.get<Repository<AssetLog>>(getRepositoryToken(AssetLog));
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('应该成功创建资产', async () => {
const createDto = {
groupId: 'group-1',
type: AssetType.ACCOUNT,
name: '测试账号',
description: '测试描述',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(assetRepository, 'create').mockReturnValue(mockAsset as any);
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
const result = await service.create('user-1', createDto);
expect(result).toBeDefined();
expect(groupRepository.findOne).toHaveBeenCalledWith({ where: { id: 'group-1' } });
expect(groupMemberRepository.findOne).toHaveBeenCalled();
});
it('小组不存在时应该抛出异常', async () => {
const createDto = {
groupId: 'group-1',
type: AssetType.ACCOUNT,
name: '测试账号',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
});
it('无权限时应该抛出异常', async () => {
const createDto = {
groupId: 'group-1',
type: AssetType.ACCOUNT,
name: '测试账号',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
});
});
describe('findAll', () => {
it('应该返回资产列表', async () => {
jest.spyOn(assetRepository, 'find').mockResolvedValue([mockAsset] as any);
const result = await service.findAll('group-1');
expect(result).toHaveLength(1);
expect(result[0].accountCredentials).toBeUndefined();
});
});
describe('borrow', () => {
it('应该成功借用资产', async () => {
const borrowDto = { reason: '需要使用' };
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(assetRepository, 'save').mockResolvedValue({ ...mockAsset, status: AssetStatus.IN_USE } as any);
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
const result = await service.borrow('user-1', 'asset-1', borrowDto);
expect(result.message).toBe('借用成功');
expect(assetRepository.save).toHaveBeenCalled();
expect(assetLogRepository.save).toHaveBeenCalled();
});
it('资产不可用时应该抛出异常', async () => {
const borrowDto = { reason: '需要使用' };
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
...mockAsset,
status: AssetStatus.IN_USE,
} as any);
await expect(service.borrow('user-1', 'asset-1', borrowDto)).rejects.toThrow(BadRequestException);
});
});
describe('return', () => {
it('应该成功归还资产', async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
...mockAsset,
currentBorrowerId: 'user-1',
} as any);
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
const result = await service.return('user-1', 'asset-1', '已归还');
expect(result.message).toBe('归还成功');
expect(assetRepository.save).toHaveBeenCalled();
});
it('非借用人归还时应该抛出异常', async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
...mockAsset,
currentBorrowerId: 'user-2',
} as any);
await expect(service.return('user-1', 'asset-1')).rejects.toThrow(ForbiddenException);
});
});
describe('remove', () => {
it('应该成功删除资产', async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(assetRepository, 'remove').mockResolvedValue(mockAsset as any);
const result = await service.remove('user-1', 'asset-1');
expect(result.message).toBe('删除成功');
expect(assetRepository.remove).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,355 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Asset } from '../../entities/asset.entity';
import { AssetLog } from '../../entities/asset-log.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto } from './dto/asset.dto';
import { AssetStatus, AssetLogAction, GroupMemberRole } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import * as crypto from 'crypto';
@Injectable()
export class AssetsService {
private readonly ENCRYPTION_KEY = process.env.ASSET_ENCRYPTION_KEY || 'default-key-change-in-production';
constructor(
@InjectRepository(Asset)
private assetRepository: Repository<Asset>,
@InjectRepository(AssetLog)
private assetLogRepository: Repository<AssetLog>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
private dataSource: DataSource,
) {}
/**
* 加密账号凭据
*/
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* 解密账号凭据
*/
private decrypt(encrypted: string): string {
const parts = encrypted.split(':');
const ivStr = parts.shift();
if (!ivStr) throw new Error('Invalid encrypted data');
const iv = Buffer.from(ivStr, 'hex');
const encryptedText = parts.join(':');
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* 创建资产
*/
async create(userId: string, createDto: CreateAssetDto) {
const { groupId, accountCredentials, ...rest } = createDto;
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId },
});
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '需要管理员权限',
});
}
const asset = this.assetRepository.create({
...rest,
groupId,
accountCredentials: accountCredentials ? this.encrypt(accountCredentials) : undefined,
});
await this.assetRepository.save(asset);
return this.findOne(asset.id);
}
/**
* 查询资产列表
*/
async findAll(groupId: string) {
const assets = await this.assetRepository.find({
where: { groupId },
relations: ['group'],
order: { createdAt: 'DESC' },
});
return assets.map((asset) => ({
...asset,
accountCredentials: undefined, // 不返回加密凭据
}));
}
/**
* 查询单个资产详情(包含解密后的凭据,需管理员权限)
*/
async findOne(id: string, userId?: string) {
const asset = await this.assetRepository.findOne({
where: { id },
relations: ['group'],
});
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
// 如果提供了userId验证权限后返回解密凭据
if (userId) {
const membership = await this.groupMemberRepository.findOne({
where: { groupId: asset.groupId, userId },
});
if (membership && (membership.role === GroupMemberRole.ADMIN || membership.role === GroupMemberRole.OWNER)) {
return {
...asset,
accountCredentials: asset.accountCredentials ? this.decrypt(asset.accountCredentials) : null,
};
}
}
return {
...asset,
accountCredentials: undefined,
};
}
/**
* 更新资产
*/
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId: asset.groupId, userId },
});
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
if (updateDto.accountCredentials) {
updateDto.accountCredentials = this.encrypt(updateDto.accountCredentials);
}
Object.assign(asset, updateDto);
await this.assetRepository.save(asset);
return this.findOne(id, userId);
}
/**
* 借用资产(使用事务和悲观锁防止并发问题)
*/
async borrow(userId: string, id: string, borrowDto: BorrowAssetDto) {
// 使用事务确保数据一致性
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 使用悲观锁防止并发借用
const asset = await queryRunner.manager.findOne(Asset, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
if (asset.status !== AssetStatus.AVAILABLE) {
throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION,
message: '资产不可用',
});
}
// 验证用户在小组中
const membership = await queryRunner.manager.findOne(GroupMember, {
where: { groupId: asset.groupId, userId },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 更新资产状态
asset.status = AssetStatus.IN_USE;
asset.currentBorrowerId = userId;
await queryRunner.manager.save(Asset, asset);
// 记录日志
const log = queryRunner.manager.create(AssetLog, {
assetId: id,
userId,
action: AssetLogAction.BORROW,
note: borrowDto.reason,
});
await queryRunner.manager.save(AssetLog, log);
await queryRunner.commitTransaction();
return { message: '借用成功' };
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* 归还资产(使用事务确保数据一致性)
*/
async return(userId: string, id: string, note?: string) {
// 使用事务确保数据一致性
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 使用悲观锁防止并发问题
const asset = await queryRunner.manager.findOne(Asset, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
if (asset.currentBorrowerId !== userId) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '无权归还此资产',
});
}
// 更新资产状态
asset.status = AssetStatus.AVAILABLE;
asset.currentBorrowerId = null;
await queryRunner.manager.save(Asset, asset);
// 记录日志
const log = queryRunner.manager.create(AssetLog, {
assetId: id,
userId,
action: AssetLogAction.RETURN,
note,
});
await queryRunner.manager.save(AssetLog, log);
await queryRunner.commitTransaction();
return { message: '归还成功' };
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* 获取资产借还记录
*/
async getLogs(id: string) {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
const logs = await this.assetLogRepository.find({
where: { assetId: id },
relations: ['user'],
order: { createdAt: 'DESC' },
});
return logs;
}
/**
* 删除资产
*/
async remove(userId: string, id: string) {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset) {
throw new NotFoundException({
code: ErrorCode.ASSET_NOT_FOUND,
message: '资产不存在',
});
}
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId: asset.groupId, userId },
});
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
await this.assetRepository.remove(asset);
return { message: '删除成功' };
}
}

View File

@@ -0,0 +1,84 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsEnum,
Min,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { AssetType, AssetStatus } from '../../../common/enums';
export class CreateAssetDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '资产类型', enum: AssetType })
@IsEnum(AssetType)
type: AssetType;
@ApiProperty({ description: '资产名称', example: '公用游戏账号' })
@IsString()
@IsNotEmpty({ message: '名称不能为空' })
name: string;
@ApiProperty({ description: '描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '账号凭据(将加密存储)', required: false })
@IsString()
@IsOptional()
accountCredentials?: string;
@ApiProperty({ description: '数量', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
quantity?: number;
}
export class UpdateAssetDto {
@ApiProperty({ description: '资产名称', required: false })
@IsString()
@IsOptional()
name?: string;
@ApiProperty({ description: '描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '账号凭据', required: false })
@IsString()
@IsOptional()
accountCredentials?: string;
@ApiProperty({ description: '数量', required: false })
@IsNumber()
@Min(1)
@IsOptional()
quantity?: number;
@ApiProperty({ description: '状态', enum: AssetStatus, required: false })
@IsEnum(AssetStatus)
@IsOptional()
status?: AssetStatus;
}
export class BorrowAssetDto {
@ApiProperty({ description: '借用理由', required: false })
@IsString()
@IsOptional()
reason?: string;
}
export class ReturnAssetDto {
@ApiProperty({ description: '归还备注', required: false })
@IsString()
@IsOptional()
note?: string;
}

View File

@@ -0,0 +1,140 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
describe('AuthController (e2e)', () => {
let app: INestApplication;
let authService: AuthService;
const mockAuthService = {
register: jest.fn(),
login: jest.fn(),
refreshToken: jest.fn(),
};
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
authService = moduleFixture.get<AuthService>(AuthService);
});
afterAll(async () => {
await app.close();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('/api/auth/register (POST)', () => {
it('应该成功注册并返回用户信息和Token', () => {
const registerDto = {
username: 'testuser',
password: 'Password123!',
email: 'test@example.com',
};
const mockResponse = {
user: {
id: 'test-id',
username: 'testuser',
email: 'test@example.com',
},
accessToken: 'access-token',
refreshToken: 'refresh-token',
};
mockAuthService.register.mockResolvedValue(mockResponse);
return request(app.getHttpServer())
.post('/auth/register')
.send(registerDto)
.expect(201)
.expect((res) => {
expect(res.body.data).toHaveProperty('user');
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
});
});
it('应该在缺少必填字段时返回400', () => {
return request(app.getHttpServer())
.post('/auth/register')
.send({
username: 'testuser',
// 缺少密码
})
.expect(400);
});
});
describe('/api/auth/login (POST)', () => {
it('应该成功登录', () => {
const loginDto = {
username: 'testuser',
password: 'Password123!',
};
const mockResponse = {
user: {
id: 'test-id',
username: 'testuser',
},
accessToken: 'access-token',
refreshToken: 'refresh-token',
};
mockAuthService.login.mockResolvedValue(mockResponse);
return request(app.getHttpServer())
.post('/auth/login')
.send(loginDto)
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveProperty('accessToken');
});
});
});
describe('/api/auth/refresh (POST)', () => {
it('应该成功刷新Token', () => {
const refreshDto = {
refreshToken: 'valid-refresh-token',
};
const mockResponse = {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
};
mockAuthService.refreshToken.mockResolvedValue(mockResponse);
return request(app.getHttpServer())
.post('/auth/refresh')
.send(refreshDto)
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
});
});
});
});

View File

@@ -0,0 +1,37 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
import { Public } from '../../common/decorators/public.decorator';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('register')
@ApiOperation({ summary: '用户注册' })
@ApiResponse({ status: 201, description: '注册成功' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户登录' })
@ApiResponse({ status: 200, description: '登录成功' })
async login(@Body() loginDto: LoginDto, @Ip() ip: string) {
return this.authService.login(loginDto, ip);
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌' })
@ApiResponse({ status: 200, description: '刷新成功' })
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
}

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../../entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get('jwt.secret'),
signOptions: {
expiresIn: configService.get('jwt.expiresIn'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtStrategy],
})
export class AuthModule {}

View File

@@ -0,0 +1,312 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { User } from '../../entities/user.entity';
import { CryptoUtil } from '../../common/utils/crypto.util';
import { UserRole } from '../../common/enums';
describe('AuthService', () => {
let service: AuthService;
let userRepository: Repository<User>;
let jwtService: JwtService;
const mockUser = {
id: 'test-user-id',
username: 'testuser',
email: 'test@example.com',
phone: '13800138000',
password: 'hashedPassword',
role: UserRole.USER,
isMember: false,
memberExpiredAt: null,
lastLoginAt: null,
lastLoginIp: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUserRepository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockJwtService = {
sign: jest.fn(),
signAsync: jest.fn(),
verify: jest.fn(),
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string) => {
const config = {
'jwt.secret': 'test-secret',
'jwt.accessExpiresIn': '15m',
'jwt.refreshExpiresIn': '7d',
};
return config[key];
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: JwtService,
useValue: mockJwtService,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
jwtService = module.get<JwtService>(JwtService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
it('应该成功注册新用户', async () => {
const registerDto = {
username: 'newuser',
password: 'Password123!',
email: 'new@example.com',
phone: '13900139000',
};
mockUserRepository.findOne
.mockResolvedValueOnce(null) // 邮箱检查
.mockResolvedValueOnce(null); // 手机号检查
mockUserRepository.create.mockReturnValue({
...registerDto,
id: 'new-user-id',
password: 'hashedPassword',
});
mockUserRepository.save.mockResolvedValue({
...registerDto,
id: 'new-user-id',
});
mockJwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
const result = await service.register(registerDto);
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken', 'access-token');
expect(result).toHaveProperty('refreshToken', 'refresh-token');
expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2);
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('应该在邮箱已存在时抛出异常', async () => {
const registerDto = {
username: 'newuser',
password: 'Password123!',
email: 'existing@example.com',
};
mockUserRepository.findOne.mockResolvedValueOnce(mockUser);
await expect(service.register(registerDto)).rejects.toThrow(
BadRequestException,
);
});
it('应该在手机号已存在时抛出异常', async () => {
const registerDto = {
username: 'newuser',
password: 'Password123!',
phone: '13800138000',
};
mockUserRepository.findOne
.mockResolvedValueOnce(null) // 邮箱不存在
.mockResolvedValueOnce(mockUser); // 手机号已存在
await expect(service.register(registerDto)).rejects.toThrow(
BadRequestException,
);
});
it('应该在缺少邮箱和手机号时抛出异常', async () => {
const registerDto = {
username: 'newuser',
password: 'Password123!',
};
await expect(service.register(registerDto)).rejects.toThrow(
BadRequestException,
);
});
});
describe('login', () => {
it('应该使用用户名成功登录', async () => {
const loginDto = {
account: 'testuser',
password: 'Password123!',
};
mockUserRepository.findOne.mockResolvedValue({
...mockUser,
password: await CryptoUtil.hashPassword('Password123!'),
});
mockJwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
const result = await service.login(loginDto, '127.0.0.1');
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken', 'access-token');
expect(result).toHaveProperty('refreshToken', 'refresh-token');
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { username: loginDto.account },
});
});
it('应该使用邮箱成功登录', async () => {
const loginDto = {
account: 'test@example.com',
password: 'Password123!',
};
mockUserRepository.findOne.mockResolvedValue({
...mockUser,
password: await CryptoUtil.hashPassword('Password123!'),
});
mockJwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
const result = await service.login(loginDto, '127.0.0.1');
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken');
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { email: loginDto.account },
});
});
it('应该在用户不存在时抛出异常', async () => {
const loginDto = {
account: 'nonexistent',
password: 'Password123!',
};
mockUserRepository.findOne.mockResolvedValue(null);
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
UnauthorizedException,
);
});
it('应该在密码错误时抛出异常', async () => {
const loginDto = {
account: 'testuser',
password: 'WrongPassword',
};
mockUserRepository.findOne.mockResolvedValue({
...mockUser,
password: await CryptoUtil.hashPassword('CorrectPassword'),
});
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('refreshToken', () => {
it('应该成功刷新Token', async () => {
const refreshToken = 'valid-refresh-token';
mockJwtService.verify.mockReturnValue({
sub: 'test-user-id',
username: 'testuser',
});
mockUserRepository.findOne.mockResolvedValue(mockUser);
mockJwtService.signAsync
.mockResolvedValueOnce('new-access-token')
.mockResolvedValueOnce('new-refresh-token');
const result = await service.refreshToken(refreshToken);
expect(result).toHaveProperty('accessToken', 'new-access-token');
expect(result).toHaveProperty('refreshToken', 'new-refresh-token');
expect(mockJwtService.verify).toHaveBeenCalledWith('valid-refresh-token');
});
it('应该在Token无效时抛出异常', async () => {
const refreshToken = 'invalid-token';
mockJwtService.verify.mockImplementation(() => {
throw new Error('Invalid token');
});
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
UnauthorizedException,
);
});
it('应该在用户不存在时抛出异常', async () => {
const refreshToken = 'valid-refresh-token';
mockJwtService.verify.mockReturnValue({
sub: 'nonexistent-user-id',
username: 'nonexistent',
});
mockUserRepository.findOne.mockResolvedValue(null);
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('validateUser', () => {
it('应该返回用户信息(排除密码)', async () => {
mockUserRepository.findOne.mockResolvedValue(mockUser);
const result = await service.validateUser('test-user-id');
expect(result).toBeDefined();
expect(result.id).toBe('test-user-id');
expect(result).not.toHaveProperty('password');
});
it('应该在用户不存在时返回null', async () => {
mockUserRepository.findOne.mockResolvedValue(null);
const result = await service.validateUser('nonexistent-id');
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,233 @@
import { Injectable, UnauthorizedException, BadRequestException, HttpException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../entities/user.entity';
import { RegisterDto, LoginDto } from './dto/auth.dto';
import { CryptoUtil } from '../../common/utils/crypto.util';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
/**
* 用户注册
*/
async register(registerDto: RegisterDto) {
const { username, password, email, phone } = registerDto;
// 验证邮箱和手机号至少有一个
if (!email && !phone) {
throw new BadRequestException({
code: ErrorCode.PARAM_ERROR,
message: '邮箱和手机号至少填写一个',
});
}
// 检查用户名是否已存在
const existingUser = await this.userRepository.findOne({
where: [
{ username },
...(email ? [{ email }] : []),
...(phone ? [{ phone }] : []),
],
});
if (existingUser) {
if (existingUser.username === username) {
throw new HttpException(
{
code: ErrorCode.USER_EXISTS,
message: '用户名已存在',
},
400,
);
}
if (email && existingUser.email === email) {
throw new HttpException(
{
code: ErrorCode.USER_EXISTS,
message: '邮箱已被注册',
},
400,
);
}
if (phone && existingUser.phone === phone) {
throw new HttpException(
{
code: ErrorCode.USER_EXISTS,
message: '手机号已被注册',
},
400,
);
}
}
// 加密密码
const hashedPassword = await CryptoUtil.hashPassword(password);
// 创建用户
const user = this.userRepository.create({
username,
password: hashedPassword,
email,
phone,
});
await this.userRepository.save(user);
// 生成 token
const tokens = await this.generateTokens(user);
return {
user: {
id: user.id,
username: user.username,
email: user.email,
phone: user.phone,
avatar: user.avatar,
isMember: user.isMember,
},
...tokens,
};
}
/**
* 用户登录
*/
async login(loginDto: LoginDto, ip?: string) {
const { account, password } = loginDto;
// 查找用户(支持用户名、邮箱、手机号登录)
const user = await this.userRepository
.createQueryBuilder('user')
.where('user.username = :account', { account })
.orWhere('user.email = :account', { account })
.orWhere('user.phone = :account', { account })
.addSelect('user.password')
.getOne();
if (!user) {
throw new UnauthorizedException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
// 验证密码
const isPasswordValid = await CryptoUtil.comparePassword(
password,
user.password,
);
if (!isPasswordValid) {
throw new UnauthorizedException({
code: ErrorCode.PASSWORD_ERROR,
message: ErrorMessage[ErrorCode.PASSWORD_ERROR],
});
}
// 更新登录信息
user.lastLoginIp = ip || null;
user.lastLoginAt = new Date();
await this.userRepository.save(user);
// 生成 token
const tokens = await this.generateTokens(user);
return {
user: {
id: user.id,
username: user.username,
email: user.email,
phone: user.phone,
avatar: user.avatar,
role: user.role,
isMember: user.isMember,
memberExpireAt: user.memberExpireAt,
},
...tokens,
};
}
/**
* 刷新 token
*/
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'),
});
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user) {
throw new UnauthorizedException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
return this.generateTokens(user);
} catch (error) {
throw new UnauthorizedException({
code: ErrorCode.TOKEN_INVALID,
message: ErrorMessage[ErrorCode.TOKEN_INVALID],
});
}
}
/**
* 验证用户
*/
async validateUser(userId: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
return user;
}
/**
* 生成 access token 和 refresh token
*/
private async generateTokens(user: User) {
const payload = {
sub: user.id,
username: user.username,
role: user.role,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.secret'),
expiresIn: this.configService.get('jwt.expiresIn'),
}),
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'),
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
}),
]);
return {
accessToken,
refreshToken,
};
}
}

View File

@@ -0,0 +1,45 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ description: '用户名', example: 'john_doe' })
@IsString()
@IsNotEmpty({ message: '用户名不能为空' })
@MinLength(3, { message: '用户名至少3个字符' })
username: string;
@ApiProperty({ description: '密码', example: 'Password123!' })
@IsString()
@IsNotEmpty({ message: '密码不能为空' })
@MinLength(6, { message: '密码至少6个字符' })
password: string;
@ApiProperty({ description: '邮箱', example: 'john@example.com', required: false })
@IsEmail({}, { message: '邮箱格式不正确' })
@IsOptional()
email?: string;
@ApiProperty({ description: '手机号', example: '13800138000', required: false })
@IsString()
@IsOptional()
phone?: string;
}
export class LoginDto {
@ApiProperty({ description: '用户名/邮箱/手机号', example: 'john_doe' })
@IsString()
@IsNotEmpty({ message: '账号不能为空' })
account: string;
@ApiProperty({ description: '密码', example: 'Password123!' })
@IsString()
@IsNotEmpty({ message: '密码不能为空' })
password: string;
}
export class RefreshTokenDto {
@ApiProperty({ description: '刷新令牌' })
@IsString()
@IsNotEmpty({ message: '刷新令牌不能为空' })
refreshToken: string;
}

View File

@@ -0,0 +1,33 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret') || 'default-secret',
});
}
async validate(payload: any) {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException({
code: ErrorCode.UNAUTHORIZED,
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
});
}
return user;
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BetsService } from './bets.service';
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('bets')
@Controller('bets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class BetsController {
constructor(private readonly betsService: BetsService) {}
@Post()
@ApiOperation({ summary: '创建竞猜下注' })
create(@CurrentUser() user, @Body() createDto: CreateBetDto) {
return this.betsService.create(user.id, createDto);
}
@Get('appointment/:appointmentId')
@ApiOperation({ summary: '查询预约的所有竞猜' })
findAll(@Param('appointmentId') appointmentId: string) {
return this.betsService.findAll(appointmentId);
}
@Post('appointment/:appointmentId/settle')
@ApiOperation({ summary: '结算竞猜(管理员)' })
settle(
@CurrentUser() user,
@Param('appointmentId') appointmentId: string,
@Body() settleDto: SettleBetDto,
) {
return this.betsService.settle(user.id, appointmentId, settleDto);
}
@Post('appointment/:appointmentId/cancel')
@ApiOperation({ summary: '取消竞猜' })
cancel(@Param('appointmentId') appointmentId: string) {
return this.betsService.cancel(appointmentId);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BetsController } from './bets.controller';
import { BetsService } from './bets.service';
import { Bet } from '../../entities/bet.entity';
import { Appointment } from '../../entities/appointment.entity';
import { Point } from '../../entities/point.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Bet, Appointment, Point, GroupMember])],
controllers: [BetsController],
providers: [BetsService],
exports: [BetsService],
})
export class BetsModule {}

View File

@@ -0,0 +1,283 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { BetsService } from './bets.service';
import { Bet } from '../../entities/bet.entity';
import { Appointment } from '../../entities/appointment.entity';
import { Point } from '../../entities/point.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
describe('BetsService', () => {
let service: BetsService;
let betRepository: Repository<Bet>;
let appointmentRepository: Repository<Appointment>;
let pointRepository: Repository<Point>;
let groupMemberRepository: Repository<GroupMember>;
const mockAppointment = {
id: 'appointment-1',
groupId: 'group-1',
title: '测试预约',
status: AppointmentStatus.PENDING,
};
const mockBet = {
id: 'bet-1',
appointmentId: 'appointment-1',
userId: 'user-1',
betOption: '胜',
amount: 10,
status: BetStatus.PENDING,
winAmount: 0,
createdAt: new Date(),
};
const mockGroupMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: GroupMemberRole.ADMIN,
};
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn(),
};
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
},
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BetsService,
{
provide: getRepositoryToken(Bet),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(Appointment),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(Point),
useValue: {
create: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
},
},
{
provide: getRepositoryToken(GroupMember),
useValue: {
findOne: jest.fn(),
},
},
{
provide: DataSource,
useValue: mockDataSource,
},
],
}).compile();
service = module.get<BetsService>(BetsService);
betRepository = module.get<Repository<Bet>>(getRepositoryToken(Bet));
appointmentRepository = module.get<Repository<Appointment>>(getRepositoryToken(Appointment));
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('应该成功创建竞猜下注', async () => {
const createDto = {
appointmentId: 'appointment-1',
betOption: '胜',
amount: 10,
};
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
jest.spyOn(betRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(betRepository, 'create').mockReturnValue(mockBet as any);
jest.spyOn(betRepository, 'save').mockResolvedValue(mockBet as any);
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
const result = await service.create('user-1', createDto);
expect(result).toBeDefined();
expect(betRepository.save).toHaveBeenCalled();
expect(pointRepository.save).toHaveBeenCalled();
});
it('预约不存在时应该抛出异常', async () => {
const createDto = {
appointmentId: 'appointment-1',
betOption: '胜',
amount: 10,
};
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
});
it('预约已结束时应该抛出异常', async () => {
const createDto = {
appointmentId: 'appointment-1',
betOption: '胜',
amount: 10,
};
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue({
...mockAppointment,
status: AppointmentStatus.FINISHED,
} as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
});
it('积分不足时应该抛出异常', async () => {
const createDto = {
appointmentId: 'appointment-1',
betOption: '胜',
amount: 100,
};
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '50' });
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
});
it('重复下注时应该抛出异常', async () => {
const createDto = {
appointmentId: 'appointment-1',
betOption: '胜',
amount: 10,
};
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
jest.spyOn(betRepository, 'findOne').mockResolvedValue(mockBet as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException);
});
});
describe('findAll', () => {
it('应该返回竞猜列表及统计', async () => {
const bets = [
{ ...mockBet, betOption: '胜', amount: 10 },
{ ...mockBet, id: 'bet-2', betOption: '胜', amount: 20 },
{ ...mockBet, id: 'bet-3', betOption: '负', amount: 15 },
];
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
const result = await service.findAll('appointment-1');
expect(result.bets).toHaveLength(3);
expect(result.totalBets).toBe(3);
expect(result.totalAmount).toBe(45);
expect(result.stats['胜']).toBeDefined();
expect(result.stats['胜'].count).toBe(2);
expect(result.stats['胜'].totalAmount).toBe(30);
});
});
describe('settle', () => {
it('应该成功结算竞猜', async () => {
const settleDto = { winningOption: '胜' };
const bets = [
{ ...mockBet, betOption: '胜', amount: 30 },
{ ...mockBet, id: 'bet-2', betOption: '负', amount: 20 },
];
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
const result = await service.settle('user-1', 'appointment-1', settleDto);
expect(result.message).toBe('结算成功');
expect(result.winners).toBe(1);
});
it('无权限时应该抛出异常', async () => {
const settleDto = { winningOption: '胜' };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(ForbiddenException);
});
it('没有人下注该选项时应该抛出异常', async () => {
const settleDto = { winningOption: '平' };
const bets = [
{ ...mockBet, betOption: '胜', amount: 30 },
];
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(BadRequestException);
});
});
describe('cancel', () => {
it('应该成功取消竞猜并退还积分', async () => {
const bets = [
{ ...mockBet, status: BetStatus.PENDING, appointment: mockAppointment },
];
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any);
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any);
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any);
const result = await service.cancel('appointment-1');
expect(result.message).toBe('竞猜已取消,积分已退还');
expect(betRepository.save).toHaveBeenCalled();
expect(pointRepository.save).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,302 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Bet } from '../../entities/bet.entity';
import { Appointment } from '../../entities/appointment.entity';
import { Point } from '../../entities/point.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
@Injectable()
export class BetsService {
constructor(
@InjectRepository(Bet)
private betRepository: Repository<Bet>,
@InjectRepository(Appointment)
private appointmentRepository: Repository<Appointment>,
@InjectRepository(Point)
private pointRepository: Repository<Point>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
private dataSource: DataSource,
) {}
/**
* 创建竞猜下注
*/
async create(userId: string, createDto: CreateBetDto) {
const { appointmentId, amount, betOption } = createDto;
// 使用事务确保数据一致性
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 验证预约存在
const appointment = await queryRunner.manager.findOne(Appointment, {
where: { id: appointmentId },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 验证预约状态
if (appointment.status !== AppointmentStatus.PENDING) {
throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION,
message: '预约已结束,无法下注',
});
}
// 验证用户积分是否足够
const balance = await queryRunner.manager
.createQueryBuilder(Point, 'point')
.select('SUM(point.amount)', 'total')
.where('point.userId = :userId', { userId })
.andWhere('point.groupId = :groupId', { groupId: appointment.groupId })
.getRawOne();
const currentBalance = parseInt(balance.total || '0');
if (currentBalance < amount) {
throw new BadRequestException({
code: ErrorCode.INSUFFICIENT_POINTS,
message: '积分不足',
});
}
// 检查是否已下注
const existingBet = await queryRunner.manager.findOne(Bet, {
where: { appointmentId, userId },
});
if (existingBet) {
throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION,
message: '已下注,不能重复下注',
});
}
const bet = queryRunner.manager.create(Bet, {
appointmentId,
userId,
betOption,
amount,
});
const savedBet = await queryRunner.manager.save(Bet, bet);
// 扣除积分
const pointRecord = queryRunner.manager.create(Point, {
userId,
groupId: appointment.groupId,
amount: -amount,
reason: '竞猜下注',
description: `预约: ${appointment.title}`,
relatedId: savedBet.id,
});
await queryRunner.manager.save(Point, pointRecord);
await queryRunner.commitTransaction();
return savedBet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* 查询预约的所有竞猜
*/
async findAll(appointmentId: string) {
const bets = await this.betRepository.find({
where: { appointmentId },
relations: ['user'],
order: { createdAt: 'DESC' },
});
// 统计各选项的下注情况
const stats = bets.reduce((acc, bet) => {
if (!acc[bet.betOption]) {
acc[bet.betOption] = { count: 0, totalAmount: 0 };
}
acc[bet.betOption].count++;
acc[bet.betOption].totalAmount += bet.amount;
return acc;
}, {});
return {
bets,
stats,
totalBets: bets.length,
totalAmount: bets.reduce((sum, bet) => sum + bet.amount, 0),
};
}
/**
* 结算竞猜(管理员)
*/
async settle(userId: string, appointmentId: string, settleDto: SettleBetDto) {
const { winningOption } = settleDto;
// 验证预约存在
const appointment = await this.appointmentRepository.findOne({
where: { id: appointmentId },
});
if (!appointment) {
throw new NotFoundException({
code: ErrorCode.APPOINTMENT_NOT_FOUND,
message: ErrorMessage[ErrorCode.APPOINTMENT_NOT_FOUND],
});
}
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId: appointment.groupId, userId },
});
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '需要管理员权限',
});
}
// 使用事务确保数据一致性
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 获取所有下注
const bets = await queryRunner.manager.find(Bet, {
where: { appointmentId },
});
// 计算总奖池和赢家总下注
const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0);
const winningBets = bets.filter((bet) => bet.betOption === winningOption);
const winningTotal = winningBets.reduce((sum, bet) => sum + bet.amount, 0);
if (winningTotal === 0) {
throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION,
message: '没有人下注该选项',
});
}
// 按比例分配奖池,修复精度损失问题
let distributedAmount = 0;
for (let i = 0; i < winningBets.length; i++) {
const bet = winningBets[i];
let winAmount: number;
if (i === winningBets.length - 1) {
// 最后一个赢家获得剩余所有积分,避免精度损失
winAmount = totalPool - distributedAmount;
} else {
winAmount = Math.floor((bet.amount / winningTotal) * totalPool);
distributedAmount += winAmount;
}
bet.winAmount = winAmount;
bet.status = BetStatus.WON;
// 返还积分
const pointRecord = queryRunner.manager.create(Point, {
userId: bet.userId,
groupId: appointment.groupId,
amount: winAmount,
reason: '竞猜获胜',
description: `预约: ${appointment.title}`,
relatedId: bet.id,
});
await queryRunner.manager.save(Point, pointRecord);
await queryRunner.manager.save(Bet, bet);
}
// 更新输家状态
for (const bet of bets) {
if (bet.betOption !== winningOption) {
bet.status = BetStatus.LOST;
await queryRunner.manager.save(Bet, bet);
}
}
await queryRunner.commitTransaction();
return {
message: '结算成功',
winningOption,
totalPool,
winners: winningBets.length,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* 取消竞猜(预约取消时)
*/
async cancel(appointmentId: string) {
// 使用事务确保数据一致性
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const bets = await queryRunner.manager.find(Bet, {
where: { appointmentId },
relations: ['appointment'],
});
for (const bet of bets) {
if (bet.status === BetStatus.PENDING) {
bet.status = BetStatus.CANCELLED;
await queryRunner.manager.save(Bet, bet);
// 退还积分
const pointRecord = queryRunner.manager.create(Point, {
userId: bet.userId,
groupId: bet.appointment.groupId,
amount: bet.amount,
reason: '竞猜取消退款',
description: `预约: ${bet.appointment.title}`,
relatedId: bet.id,
});
await queryRunner.manager.save(Point, pointRecord);
}
}
await queryRunner.commitTransaction();
return { message: '竞猜已取消,积分已退还' };
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}

View File

@@ -0,0 +1,31 @@
import {
IsString,
IsNotEmpty,
IsNumber,
Min,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateBetDto {
@ApiProperty({ description: '预约ID' })
@IsString()
@IsNotEmpty({ message: '预约ID不能为空' })
appointmentId: string;
@ApiProperty({ description: '下注选项', example: '胜' })
@IsString()
@IsNotEmpty({ message: '下注选项不能为空' })
betOption: string;
@ApiProperty({ description: '下注积分', example: 10 })
@IsNumber()
@Min(1)
amount: number;
}
export class SettleBetDto {
@ApiProperty({ description: '胜利选项', example: '胜' })
@IsString()
@IsNotEmpty({ message: '胜利选项不能为空' })
winningOption: string;
}

View File

@@ -0,0 +1,68 @@
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BlacklistService } from './blacklist.service';
import {
CreateBlacklistDto,
ReviewBlacklistDto,
QueryBlacklistDto,
} from './dto/blacklist.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('blacklist')
@Controller('blacklist')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class BlacklistController {
constructor(private readonly blacklistService: BlacklistService) {}
@Post()
@ApiOperation({ summary: '提交黑名单举报' })
create(@CurrentUser() user, @Body() createDto: CreateBlacklistDto) {
return this.blacklistService.create(user.id, createDto);
}
@Get()
@ApiOperation({ summary: '查询黑名单列表' })
findAll(@Query() query: QueryBlacklistDto) {
return this.blacklistService.findAll(query);
}
@Get('check/:targetGameId')
@ApiOperation({ summary: '检查游戏ID是否在黑名单中' })
checkBlacklist(@Param('targetGameId') targetGameId: string) {
return this.blacklistService.checkBlacklist(targetGameId);
}
@Get(':id')
@ApiOperation({ summary: '查询单个黑名单记录' })
findOne(@Param('id') id: string) {
return this.blacklistService.findOne(id);
}
@Patch(':id/review')
@ApiOperation({ summary: '审核黑名单(管理员)' })
review(
@CurrentUser() user,
@Param('id') id: string,
@Body() reviewDto: ReviewBlacklistDto,
) {
return this.blacklistService.review(user.id, id, reviewDto);
}
@Delete(':id')
@ApiOperation({ summary: '删除黑名单记录' })
remove(@CurrentUser() user, @Param('id') id: string) {
return this.blacklistService.remove(user.id, id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlacklistController } from './blacklist.controller';
import { BlacklistService } from './blacklist.service';
import { Blacklist } from '../../entities/blacklist.entity';
import { User } from '../../entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([Blacklist, User])],
controllers: [BlacklistController],
providers: [BlacklistService],
exports: [BlacklistService],
})
export class BlacklistModule {}

View File

@@ -0,0 +1,272 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlacklistService } from './blacklist.service';
import { Blacklist } from '../../entities/blacklist.entity';
import { User } from '../../entities/user.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { BlacklistStatus } from '../../common/enums';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('BlacklistService', () => {
let service: BlacklistService;
let blacklistRepository: Repository<Blacklist>;
let userRepository: Repository<User>;
let groupMemberRepository: Repository<GroupMember>;
const mockBlacklist = {
id: 'blacklist-1',
reporterId: 'user-1',
targetGameId: 'game-123',
targetNickname: '违规玩家',
reason: '恶意行为',
proofImages: ['image1.jpg'],
status: BlacklistStatus.PENDING,
createdAt: new Date(),
};
const mockUser = {
id: 'user-1',
username: '举报人',
isMember: true,
};
const mockGroupMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
};
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BlacklistService,
{
provide: getRepositoryToken(Blacklist),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
count: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
},
},
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(GroupMember),
useValue: {
find: jest.fn(),
},
},
],
}).compile();
service = module.get<BlacklistService>(BlacklistService);
blacklistRepository = module.get<Repository<Blacklist>>(getRepositoryToken(Blacklist));
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('应该成功创建黑名单举报', async () => {
const createDto = {
targetGameId: 'game-123',
targetNickname: '违规玩家',
reason: '恶意行为',
proofImages: ['image1.jpg'],
};
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'create').mockReturnValue(mockBlacklist as any);
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(mockBlacklist as any);
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
const result = await service.create('user-1', createDto);
expect(result).toBeDefined();
expect(blacklistRepository.create).toHaveBeenCalledWith({
...createDto,
reporterId: 'user-1',
status: BlacklistStatus.PENDING,
});
expect(blacklistRepository.save).toHaveBeenCalled();
});
});
describe('findAll', () => {
it('应该返回黑名单列表', async () => {
const query = { status: BlacklistStatus.APPROVED };
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
const result = await service.findAll(query);
expect(result).toHaveLength(1);
expect(blacklistRepository.createQueryBuilder).toHaveBeenCalled();
});
it('应该支持按状态筛选', async () => {
const query = { status: BlacklistStatus.PENDING };
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
await service.findAll(query);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'blacklist.status = :status',
{ status: BlacklistStatus.PENDING }
);
});
});
describe('findOne', () => {
it('应该返回单个黑名单记录', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
const result = await service.findOne('blacklist-1');
expect(result).toBeDefined();
expect(result.id).toBe('blacklist-1');
});
it('记录不存在时应该抛出异常', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
});
});
describe('review', () => {
it('应该成功审核黑名单(会员权限)', async () => {
const reviewDto = {
status: BlacklistStatus.APPROVED,
reviewNote: '确认违规',
};
const updatedBlacklist = {
...mockBlacklist,
...reviewDto,
reviewerId: 'user-1',
};
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'findOne')
.mockResolvedValueOnce(mockBlacklist as any) // First call in review method
.mockResolvedValueOnce(updatedBlacklist as any); // Second call in findOne at the end
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(updatedBlacklist as any);
const result = await service.review('user-1', 'blacklist-1', reviewDto);
expect(result.status).toBe(BlacklistStatus.APPROVED);
expect(blacklistRepository.save).toHaveBeenCalled();
});
it('非会员审核时应该抛出异常', async () => {
const reviewDto = {
status: BlacklistStatus.APPROVED,
};
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
...mockUser,
isMember: false,
} as any);
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
});
it('用户不存在时应该抛出异常', async () => {
const reviewDto = {
status: BlacklistStatus.APPROVED,
};
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException);
});
});
describe('checkBlacklist', () => {
it('应该正确检查玩家是否在黑名单', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
...mockBlacklist,
status: BlacklistStatus.APPROVED,
} as any);
const result = await service.checkBlacklist('game-123');
expect(result.isBlacklisted).toBe(true);
expect(result.blacklist).toBeDefined();
expect(blacklistRepository.findOne).toHaveBeenCalledWith({
where: {
targetGameId: 'game-123',
status: BlacklistStatus.APPROVED,
},
});
});
it('玩家不在黑名单时应该返回false', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null);
const result = await service.checkBlacklist('game-123');
expect(result.isBlacklisted).toBe(false);
expect(result.blacklist).toBeNull();
});
});
describe('remove', () => {
it('举报人应该可以删除自己的举报', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
const result = await service.remove('user-1', 'blacklist-1');
expect(result.message).toBe('删除成功');
expect(blacklistRepository.remove).toHaveBeenCalled();
});
it('会员应该可以删除任何举报', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
...mockBlacklist,
reporterId: 'other-user',
} as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any);
const result = await service.remove('user-1', 'blacklist-1');
expect(result.message).toBe('删除成功');
});
it('非举报人且非会员删除时应该抛出异常', async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({
...mockBlacklist,
reporterId: 'other-user',
} as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
...mockUser,
isMember: false,
} as any);
await expect(service.remove('user-1', 'blacklist-1')).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -0,0 +1,175 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Blacklist } from '../../entities/blacklist.entity';
import { User } from '../../entities/user.entity';
import {
CreateBlacklistDto,
ReviewBlacklistDto,
QueryBlacklistDto,
} from './dto/blacklist.dto';
import { BlacklistStatus } from '../../common/enums';
import {
ErrorCode,
ErrorMessage,
} from '../../common/interfaces/response.interface';
@Injectable()
export class BlacklistService {
constructor(
@InjectRepository(Blacklist)
private blacklistRepository: Repository<Blacklist>,
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
/**
* 提交黑名单举报
*/
async create(userId: string, createDto: CreateBlacklistDto) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
const blacklist = this.blacklistRepository.create({
...createDto,
reporterId: userId,
status: BlacklistStatus.PENDING,
});
await this.blacklistRepository.save(blacklist);
return this.findOne(blacklist.id);
}
/**
* 查询黑名单列表
*/
async findAll(query: QueryBlacklistDto) {
const qb = this.blacklistRepository
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.reporter', 'reporter')
.leftJoinAndSelect('blacklist.reviewer', 'reviewer');
if (query.targetGameId) {
qb.andWhere('blacklist.targetGameId LIKE :targetGameId', {
targetGameId: `%${query.targetGameId}%`,
});
}
if (query.status) {
qb.andWhere('blacklist.status = :status', { status: query.status });
}
qb.orderBy('blacklist.createdAt', 'DESC');
const blacklists = await qb.getMany();
return blacklists;
}
/**
* 查询单个黑名单记录
*/
async findOne(id: string) {
const blacklist = await this.blacklistRepository.findOne({
where: { id },
relations: ['reporter', 'reviewer'],
});
if (!blacklist) {
throw new NotFoundException({
code: ErrorCode.BLACKLIST_NOT_FOUND,
message: '黑名单记录不存在',
});
}
return blacklist;
}
/**
* 审核黑名单(管理员权限)
*/
async review(userId: string, id: string, reviewDto: ReviewBlacklistDto) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user || !user.isMember) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '需要会员权限',
});
}
const blacklist = await this.findOne(id);
if (blacklist.status !== BlacklistStatus.PENDING) {
throw new ForbiddenException({
code: ErrorCode.INVALID_OPERATION,
message: '该记录已审核',
});
}
blacklist.status = reviewDto.status;
if (reviewDto.reviewNote) {
blacklist.reviewNote = reviewDto.reviewNote;
}
blacklist.reviewerId = userId;
await this.blacklistRepository.save(blacklist);
return this.findOne(id);
}
/**
* 检查游戏ID是否在黑名单中
*/
async checkBlacklist(targetGameId: string) {
const blacklist = await this.blacklistRepository.findOne({
where: {
targetGameId,
status: BlacklistStatus.APPROVED,
},
});
return {
isBlacklisted: !!blacklist,
blacklist: blacklist || null,
};
}
/**
* 删除黑名单记录(仅举报人或管理员)
*/
async remove(userId: string, id: string) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
const blacklist = await this.findOne(id);
if (blacklist.reporterId !== userId && !user.isMember) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
await this.blacklistRepository.remove(blacklist);
return { message: '删除成功' };
}
}

View File

@@ -0,0 +1,59 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsEnum,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { BlacklistStatus } from '../../../common/enums';
export class CreateBlacklistDto {
@ApiProperty({ description: '目标游戏ID或用户名', example: 'PlayerXXX#1234' })
@IsString()
@IsNotEmpty({ message: '目标游戏ID不能为空' })
@MaxLength(100)
targetGameId: string;
@ApiProperty({ description: '举报原因' })
@IsString()
@IsNotEmpty({ message: '举报原因不能为空' })
reason: string;
@ApiProperty({ description: '证据图片URL列表', required: false })
@IsArray()
@IsOptional()
proofImages?: string[];
}
export class ReviewBlacklistDto {
@ApiProperty({
description: '审核状态',
enum: BlacklistStatus,
example: BlacklistStatus.APPROVED,
})
@IsEnum(BlacklistStatus)
status: BlacklistStatus;
@ApiProperty({ description: '审核意见', required: false })
@IsString()
@IsOptional()
reviewNote?: string;
}
export class QueryBlacklistDto {
@ApiProperty({ description: '目标游戏ID', required: false })
@IsString()
@IsOptional()
targetGameId?: string;
@ApiProperty({
description: '状态',
enum: BlacklistStatus,
required: false,
})
@IsEnum(BlacklistStatus)
@IsOptional()
status?: BlacklistStatus;
}

View File

@@ -0,0 +1,117 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateGameDto {
@ApiProperty({ description: '游戏名称', example: '王者荣耀' })
@IsString()
@IsNotEmpty({ message: '游戏名称不能为空' })
name: string;
@ApiProperty({ description: '游戏封面URL', required: false })
@IsString()
@IsOptional()
coverUrl?: string;
@ApiProperty({ description: '游戏描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '最大玩家数', example: 5 })
@IsNumber()
@Min(1)
@Type(() => Number)
maxPlayers: number;
@ApiProperty({ description: '最小玩家数', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
minPlayers?: number;
@ApiProperty({ description: '游戏平台', example: 'PC/iOS/Android', required: false })
@IsString()
@IsOptional()
platform?: string;
@ApiProperty({ description: '游戏标签', example: ['MOBA', '5v5'], required: false, type: [String] })
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
export class UpdateGameDto {
@ApiProperty({ description: '游戏名称', required: false })
@IsString()
@IsOptional()
name?: string;
@ApiProperty({ description: '游戏封面URL', required: false })
@IsString()
@IsOptional()
coverUrl?: string;
@ApiProperty({ description: '游戏描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '最大玩家数', required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
maxPlayers?: number;
@ApiProperty({ description: '最小玩家数', required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
minPlayers?: number;
@ApiProperty({ description: '游戏平台', required: false })
@IsString()
@IsOptional()
platform?: string;
@ApiProperty({ description: '游戏标签', required: false, type: [String] })
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
export class SearchGameDto {
@ApiProperty({ description: '搜索关键词', required: false })
@IsString()
@IsOptional()
keyword?: string;
@ApiProperty({ description: '游戏平台', required: false })
@IsString()
@IsOptional()
platform?: string;
@ApiProperty({ description: '游戏标签', required: false })
@IsString()
@IsOptional()
tag?: string;
@ApiProperty({ description: '页码', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
limit?: number;
}

View File

@@ -0,0 +1,95 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { GamesService } from './games.service';
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
@ApiTags('games')
@Controller('games')
export class GamesController {
constructor(private readonly gamesService: GamesService) {}
@Public()
@Get()
@ApiOperation({ summary: '获取游戏列表' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'keyword', required: false, description: '搜索关键词' })
@ApiQuery({ name: 'platform', required: false, description: '游戏平台' })
@ApiQuery({ name: 'tag', required: false, description: '游戏标签' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
async findAll(@Query() searchDto: SearchGameDto) {
return this.gamesService.findAll(searchDto);
}
@Public()
@Get('popular')
@ApiOperation({ summary: '获取热门游戏' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'limit', required: false, description: '数量限制' })
async findPopular(@Query('limit') limit?: number) {
return this.gamesService.findPopular(limit);
}
@Public()
@Get('tags')
@ApiOperation({ summary: '获取所有游戏标签' })
@ApiResponse({ status: 200, description: '获取成功' })
async getTags() {
return this.gamesService.getTags();
}
@Public()
@Get('platforms')
@ApiOperation({ summary: '获取所有游戏平台' })
@ApiResponse({ status: 200, description: '获取成功' })
async getPlatforms() {
return this.gamesService.getPlatforms();
}
@Public()
@Get(':id')
@ApiOperation({ summary: '获取游戏详情' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(@Param('id') id: string) {
return this.gamesService.findOne(id);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({ summary: '创建游戏' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(@Body() createGameDto: CreateGameDto) {
return this.gamesService.create(createGameDto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Put(':id')
@ApiOperation({ summary: '更新游戏信息' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(@Param('id') id: string, @Body() updateGameDto: UpdateGameDto) {
return this.gamesService.update(id, updateGameDto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete(':id')
@ApiOperation({ summary: '删除游戏' })
@ApiResponse({ status: 200, description: '删除成功' })
async remove(@Param('id') id: string) {
return this.gamesService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GamesService } from './games.service';
import { GamesController } from './games.controller';
import { Game } from '../../entities/game.entity';
@Module({
imports: [TypeOrmModule.forFeature([Game])],
controllers: [GamesController],
providers: [GamesService],
exports: [GamesService],
})
export class GamesModule {}

View File

@@ -0,0 +1,301 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { GamesService } from './games.service';
import { Game } from '../../entities/game.entity';
describe('GamesService', () => {
let service: GamesService;
let repository: Repository<Game>;
const mockGame = {
id: 'game-id-1',
name: '王者荣耀',
coverUrl: 'https://example.com/cover.jpg',
description: '5v5竞技游戏',
maxPlayers: 10,
minPlayers: 1,
platform: 'iOS/Android',
tags: ['MOBA', '5v5'],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockRepository = {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GamesService,
{
provide: getRepositoryToken(Game),
useValue: mockRepository,
},
],
}).compile();
service = module.get<GamesService>(GamesService);
repository = module.get<Repository<Game>>(getRepositoryToken(Game));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('应该成功创建游戏', async () => {
const createDto = {
name: '原神',
coverUrl: 'https://example.com/genshin.jpg',
description: '开放世界冒险游戏',
maxPlayers: 4,
minPlayers: 1,
platform: 'PC/iOS/Android',
tags: ['RPG', '开放世界'],
};
mockRepository.findOne.mockResolvedValue(null); // 游戏名称不存在
mockRepository.create.mockReturnValue({ ...createDto, id: 'new-game-id' });
mockRepository.save.mockResolvedValue({ ...createDto, id: 'new-game-id' });
const result = await service.create(createDto);
expect(result).toHaveProperty('id', 'new-game-id');
expect(result.name).toBe(createDto.name);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { name: createDto.name },
});
});
it('应该在游戏名称已存在时抛出异常', async () => {
const createDto = {
name: '王者荣耀',
maxPlayers: 10,
};
mockRepository.findOne.mockResolvedValue(mockGame);
await expect(service.create(createDto as any)).rejects.toThrow(
BadRequestException,
);
});
});
describe('findAll', () => {
it('应该返回游戏列表', async () => {
const searchDto = {
page: 1,
limit: 10,
};
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
};
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.findAll(searchDto);
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
});
it('应该支持关键词搜索', async () => {
const searchDto = {
keyword: '王者',
page: 1,
limit: 10,
};
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
};
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.findAll(searchDto);
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
expect(result.items).toHaveLength(1);
});
it('应该支持平台筛选', async () => {
const searchDto = {
platform: 'iOS',
page: 1,
limit: 10,
};
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockGame], 1]),
};
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
await service.findAll(searchDto);
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('应该返回游戏详情', async () => {
mockRepository.findOne.mockResolvedValue(mockGame);
const result = await service.findOne('game-id-1');
expect(result).toEqual(mockGame);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'game-id-1', isActive: true },
});
});
it('应该在游戏不存在时抛出异常', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('nonexistent-id')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
it('应该成功更新游戏', async () => {
const updateDto = {
description: '更新后的描述',
maxPlayers: 12,
};
mockRepository.findOne
.mockResolvedValueOnce(mockGame) // findOne调用
.mockResolvedValueOnce(null); // 名称检查
mockRepository.save.mockResolvedValue({
...mockGame,
...updateDto,
});
const result = await service.update('game-id-1', updateDto);
expect(result.description).toBe(updateDto.description);
expect(result.maxPlayers).toBe(updateDto.maxPlayers);
});
it('应该在更新名称时检查重名', async () => {
const updateDto = {
name: '已存在的游戏名',
};
const anotherGame = {
...mockGame,
id: 'another-game-id',
name: '已存在的游戏名',
};
mockRepository.findOne
.mockResolvedValueOnce(mockGame) // 第一次:获取要更新的游戏
.mockResolvedValueOnce(anotherGame); // 第二次:检查名称是否存在
await expect(
service.update('game-id-1', updateDto),
).rejects.toThrow(BadRequestException);
});
});
describe('remove', () => {
it('应该软删除游戏', async () => {
mockRepository.findOne.mockResolvedValue(mockGame);
mockRepository.save.mockResolvedValue({
...mockGame,
isActive: false,
});
const result = await service.remove('game-id-1');
expect(result).toHaveProperty('message', '游戏已删除');
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ isActive: false }),
);
});
});
describe('findPopular', () => {
it('应该返回热门游戏列表', async () => {
mockRepository.find.mockResolvedValue([mockGame]);
const result = await service.findPopular(5);
expect(result).toHaveLength(1);
expect(mockRepository.find).toHaveBeenCalledWith({
where: { isActive: true },
order: { createdAt: 'DESC' },
take: 5,
});
});
});
describe('getTags', () => {
it('应该返回所有游戏标签', async () => {
const games = [
{ ...mockGame, tags: ['MOBA', '5v5'] },
{ ...mockGame, tags: ['FPS', 'RPG'] },
];
mockRepository.find.mockResolvedValue(games);
const result = await service.getTags();
expect(result).toContain('MOBA');
expect(result).toContain('FPS');
expect(result.length).toBeGreaterThan(0);
});
});
describe('getPlatforms', () => {
it('应该返回所有游戏平台', async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawMany: jest
.fn()
.mockResolvedValue([
{ platform: 'iOS/Android' },
{ platform: 'PC' },
]),
};
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.getPlatforms();
expect(result).toContain('iOS/Android');
expect(result).toContain('PC');
});
});
});

View File

@@ -0,0 +1,190 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Game } from '../../entities/game.entity';
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import { PaginationUtil } from '../../common/utils/pagination.util';
@Injectable()
export class GamesService {
constructor(
@InjectRepository(Game)
private gameRepository: Repository<Game>,
) {}
/**
* 创建游戏
*/
async create(createGameDto: CreateGameDto) {
// 检查游戏名称是否已存在
const existingGame = await this.gameRepository.findOne({
where: { name: createGameDto.name },
});
if (existingGame) {
throw new BadRequestException({
code: ErrorCode.GAME_EXISTS,
message: ErrorMessage[ErrorCode.GAME_EXISTS],
});
}
const game = this.gameRepository.create({
...createGameDto,
minPlayers: createGameDto.minPlayers || 1,
});
await this.gameRepository.save(game);
return game;
}
/**
* 获取游戏列表
*/
async findAll(searchDto: SearchGameDto) {
const { keyword, platform, tag, page = 1, limit = 10 } = searchDto;
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.gameRepository
.createQueryBuilder('game')
.where('game.isActive = :isActive', { isActive: true });
// 关键词搜索(游戏名称和描述)
if (keyword) {
queryBuilder.andWhere(
'(game.name LIKE :keyword OR game.description LIKE :keyword)',
{ keyword: `%${keyword}%` },
);
}
// 平台筛选
if (platform) {
queryBuilder.andWhere('game.platform LIKE :platform', {
platform: `%${platform}%`,
});
}
// 标签筛选
if (tag) {
queryBuilder.andWhere('game.tags LIKE :tag', { tag: `%${tag}%` });
}
// 分页
const [items, total] = await queryBuilder
.orderBy('game.createdAt', 'DESC')
.skip(offset)
.take(limit)
.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: PaginationUtil.getTotalPages(total, limit),
};
}
/**
* 获取游戏详情
*/
async findOne(id: string) {
const game = await this.gameRepository.findOne({
where: { id, isActive: true },
});
if (!game) {
throw new NotFoundException({
code: ErrorCode.GAME_NOT_FOUND,
message: ErrorMessage[ErrorCode.GAME_NOT_FOUND],
});
}
return game;
}
/**
* 更新游戏信息
*/
async update(id: string, updateGameDto: UpdateGameDto) {
const game = await this.findOne(id);
// 如果要修改游戏名称,检查是否与其他游戏重名
if (updateGameDto.name && updateGameDto.name !== game.name) {
const existingGame = await this.gameRepository.findOne({
where: { name: updateGameDto.name },
});
if (existingGame) {
throw new BadRequestException({
code: ErrorCode.GAME_EXISTS,
message: '游戏名称已存在',
});
}
}
Object.assign(game, updateGameDto);
await this.gameRepository.save(game);
return game;
}
/**
* 删除游戏(软删除)
*/
async remove(id: string) {
const game = await this.findOne(id);
game.isActive = false;
await this.gameRepository.save(game);
return { message: '游戏已删除' };
}
/**
* 获取热门游戏(可根据实际需求调整排序逻辑)
*/
async findPopular(limit: number = 10) {
const games = await this.gameRepository.find({
where: { isActive: true },
order: { createdAt: 'DESC' },
take: limit,
});
return games;
}
/**
* 获取所有游戏标签
*/
async getTags() {
const games = await this.gameRepository.find({
where: { isActive: true },
select: ['tags'],
});
const tagsSet = new Set<string>();
games.forEach((game) => {
if (game.tags && game.tags.length > 0) {
game.tags.forEach((tag) => tagsSet.add(tag));
}
});
return Array.from(tagsSet);
}
/**
* 获取所有游戏平台
*/
async getPlatforms() {
const games = await this.gameRepository
.createQueryBuilder('game')
.select('DISTINCT game.platform', 'platform')
.where('game.isActive = :isActive', { isActive: true })
.andWhere('game.platform IS NOT NULL')
.getRawMany();
return games.map((item) => item.platform);
}
}

View File

@@ -0,0 +1,99 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateGroupDto {
@ApiProperty({ description: '小组名称', example: '王者荣耀固定队' })
@IsString()
@IsNotEmpty({ message: '小组名称不能为空' })
name: string;
@ApiProperty({ description: '小组描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '小组头像', required: false })
@IsString()
@IsOptional()
avatar?: string;
@ApiProperty({ description: '小组类型', example: 'normal', required: false })
@IsString()
@IsOptional()
type?: string;
@ApiProperty({ description: '父组ID创建子组时使用', required: false })
@IsString()
@IsOptional()
parentId?: string;
@ApiProperty({ description: '最大成员数', example: 50, required: false })
@IsNumber()
@Min(2)
@Max(500)
@IsOptional()
@Type(() => Number)
maxMembers?: number;
}
export class UpdateGroupDto {
@ApiProperty({ description: '小组名称', required: false })
@IsString()
@IsOptional()
name?: string;
@ApiProperty({ description: '小组描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '小组头像', required: false })
@IsString()
@IsOptional()
avatar?: string;
@ApiProperty({ description: '公示信息', required: false })
@IsString()
@IsOptional()
announcement?: string;
@ApiProperty({ description: '最大成员数', required: false })
@IsNumber()
@Min(2)
@Max(500)
@IsOptional()
@Type(() => Number)
maxMembers?: number;
}
export class JoinGroupDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '组内昵称', required: false })
@IsString()
@IsOptional()
nickname?: string;
}
export class UpdateMemberRoleDto {
@ApiProperty({ description: '成员ID' })
@IsString()
@IsNotEmpty({ message: '成员ID不能为空' })
userId: string;
@ApiProperty({ description: '角色', example: 'admin', enum: ['owner', 'admin', 'member'] })
@IsString()
@IsNotEmpty({ message: '角色不能为空' })
role: string;
}
export class KickMemberDto {
@ApiProperty({ description: '成员ID' })
@IsString()
@IsNotEmpty({ message: '成员ID不能为空' })
userId: string;
}

View File

@@ -0,0 +1,110 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { GroupsService } from './groups.service';
import {
CreateGroupDto,
UpdateGroupDto,
JoinGroupDto,
UpdateMemberRoleDto,
KickMemberDto,
} from './dto/group.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../../entities/user.entity';
@ApiTags('groups')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('groups')
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
@Post()
@ApiOperation({ summary: '创建小组' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(@CurrentUser() user: User, @Body() createGroupDto: CreateGroupDto) {
return this.groupsService.create(user.id, createGroupDto);
}
@Post('join')
@ApiOperation({ summary: '加入小组' })
@ApiResponse({ status: 200, description: '加入成功' })
async join(@CurrentUser() user: User, @Body() joinGroupDto: JoinGroupDto) {
return this.groupsService.join(user.id, joinGroupDto);
}
@Delete(':id/leave')
@ApiOperation({ summary: '退出小组' })
@ApiResponse({ status: 200, description: '退出成功' })
async leave(@CurrentUser() user: User, @Param('id') id: string) {
return this.groupsService.leave(user.id, id);
}
@Get('my')
@ApiOperation({ summary: '获取我的小组列表' })
@ApiResponse({ status: 200, description: '获取成功' })
async findMy(@CurrentUser() user: User) {
return this.groupsService.findUserGroups(user.id);
}
@Get(':id')
@ApiOperation({ summary: '获取小组详情' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(@Param('id') id: string) {
return this.groupsService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: '更新小组信息' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() updateGroupDto: UpdateGroupDto,
) {
return this.groupsService.update(user.id, id, updateGroupDto);
}
@Put(':id/members/role')
@ApiOperation({ summary: '设置成员角色' })
@ApiResponse({ status: 200, description: '设置成功' })
async updateMemberRole(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
) {
return this.groupsService.updateMemberRole(
user.id,
id,
updateMemberRoleDto.userId,
updateMemberRoleDto.role as any,
);
}
@Delete(':id/members')
@ApiOperation({ summary: '踢出成员' })
@ApiResponse({ status: 200, description: '移除成功' })
async kickMember(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() kickMemberDto: KickMemberDto,
) {
return this.groupsService.kickMember(user.id, id, kickMemberDto.userId);
}
@Delete(':id')
@ApiOperation({ summary: '解散小组' })
@ApiResponse({ status: 200, description: '解散成功' })
async disband(@CurrentUser() user: User, @Param('id') id: string) {
return this.groupsService.disband(user.id, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GroupsService } from './groups.service';
import { GroupsController } from './groups.controller';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { User } from '../../entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([Group, GroupMember, User])],
controllers: [GroupsController],
providers: [GroupsService],
exports: [GroupsService],
})
export class GroupsModule {}

View File

@@ -0,0 +1,290 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { GroupsService } from './groups.service';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { User } from '../../entities/user.entity';
import { CacheService } from '../../common/services/cache.service';
describe('GroupsService', () => {
let service: GroupsService;
let mockGroupRepository: any;
let mockGroupMemberRepository: any;
let mockUserRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' };
const mockGroup = {
id: 'group-1',
name: '测试小组',
description: '描述',
ownerId: 'user-1',
maxMembers: 10,
isPublic: true,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: 'owner',
isActive: true,
joinedAt: new Date(),
};
beforeEach(async () => {
mockGroupRepository = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
count: jest.fn(),
};
mockGroupMemberRepository = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
mockUserRepository = {
findOne: jest.fn(),
};
const mockCacheService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
clear: jest.fn(),
clearByPrefix: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
GroupsService,
{
provide: getRepositoryToken(Group),
useValue: mockGroupRepository,
},
{
provide: getRepositoryToken(GroupMember),
useValue: mockGroupMemberRepository,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: CacheService,
useValue: mockCacheService,
},
],
}).compile();
service = module.get<GroupsService>(GroupsService);
mockUserRepository.findOne.mockResolvedValue(mockUser);
});
describe('create', () => {
it('应该成功创建小组', async () => {
mockGroupRepository.count.mockResolvedValue(2);
mockGroupRepository.create.mockReturnValue(mockGroup);
mockGroupRepository.save.mockResolvedValue(mockGroup);
mockGroupMemberRepository.create.mockReturnValue(mockMember);
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
mockGroupRepository.findOne.mockResolvedValue({
...mockGroup,
owner: mockUser,
});
const result = await service.create('user-1', {
name: '测试小组',
description: '描述',
maxMembers: 10,
});
expect(result).toHaveProperty('id');
expect(result.name).toBe('测试小组');
expect(mockGroupRepository.save).toHaveBeenCalled();
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
});
it('应该mock在创建小组数量超限时抛出异常', async () => {
mockGroupRepository.count.mockResolvedValue(5);
mockUserRepository.findOne.mockResolvedValue(mockUser);
await expect(
service.create('user-1', {
name: '测试小组',
maxMembers: 10,
}),
).rejects.toThrow(BadRequestException);
});
});
describe('findOne', () => {
it('应该成功获取小组详情', async () => {
mockGroupRepository.findOne.mockResolvedValue({
...mockGroup,
owner: mockUser,
});
const result = await service.findOne('group-1');
expect(result).toHaveProperty('id');
expect(result.id).toBe('group-1');
});
it('应该在小组不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('group-1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
it('应该成功更新小组', async () => {
mockGroupRepository.findOne
.mockResolvedValueOnce(mockGroup)
.mockResolvedValueOnce({
...mockGroup,
name: '更新后的名称',
owner: mockUser,
});
mockGroupRepository.save.mockResolvedValue({
...mockGroup,
name: '更新后的名称',
});
const result = await service.update('user-1', 'group-1', {
name: '更新后的名称',
});
expect(result.name).toBe('更新后的名称');
});
it('应该在非所有者更新时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
await expect(
service.update('user-2', 'group-1', { name: '新名称' }),
).rejects.toThrow(ForbiddenException);
});
});
describe('join', () => {
it('应该成功加入小组', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
mockGroupMemberRepository.count
.mockResolvedValueOnce(3) // 用户已加入的小组数
.mockResolvedValueOnce(5); // 小组当前成员数
mockGroupMemberRepository.create.mockReturnValue(mockMember);
mockGroupMemberRepository.save.mockResolvedValue(mockMember);
const result = await service.join('user-2', { groupId: 'group-1' });
expect(result).toHaveProperty('message');
expect(mockGroupMemberRepository.save).toHaveBeenCalled();
});
it('应该在小组不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(null);
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
NotFoundException,
);
});
it('应该在已加入时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
await expect(service.join('user-1', { groupId: 'group-1' })).rejects.toThrow(
BadRequestException,
);
});
it('应该在小组已满时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
mockGroupMemberRepository.count
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(10);
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow(
BadRequestException,
);
});
});
describe('leave', () => {
it('应该成功离开小组', async () => {
const memberNotOwner = { ...mockMember, role: 'member' };
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(memberNotOwner);
mockGroupMemberRepository.save.mockResolvedValue({
...memberNotOwner,
isActive: false,
});
const result = await service.leave('user-2', 'group-1');
expect(result).toHaveProperty('message');
});
it('应该在小组所有者尝试离开时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
await expect(service.leave('user-1', 'group-1')).rejects.toThrow(
BadRequestException,
);
});
});
describe('updateMemberRole', () => {
it('应该成功更新成员角色', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMember,
role: 'member',
});
mockGroupMemberRepository.save.mockResolvedValue({
...mockMember,
role: 'admin',
});
const result = await service.updateMemberRole(
'user-1',
'group-1',
'user-2',
'admin' as any,
);
expect(result).toHaveProperty('message');
});
it('应该在非所有者更新角色时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
await expect(
service.updateMemberRole('user-2', 'group-1', 'user-3', 'admin' as any),
).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -0,0 +1,441 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { User } from '../../entities/user.entity';
import { CreateGroupDto, UpdateGroupDto, JoinGroupDto } from './dto/group.dto';
import { GroupMemberRole } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import { CacheService } from '../../common/services/cache.service';
@Injectable()
export class GroupsService {
private readonly CACHE_PREFIX = 'group';
private readonly CACHE_TTL = 300; // 5 minutes
constructor(
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
@InjectRepository(User)
private userRepository: Repository<User>,
private cacheService: CacheService,
) {}
/**
* 创建小组
*/
async create(userId: string, createGroupDto: CreateGroupDto) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
// 检查用户创建的小组数量
const ownedGroupsCount = await this.groupRepository.count({
where: { ownerId: userId },
});
if (!user.isMember && ownedGroupsCount >= 1) {
throw new BadRequestException({
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
message: '非会员最多只能创建1个小组',
});
}
if (user.isMember && ownedGroupsCount >= 10) {
throw new BadRequestException({
code: ErrorCode.GROUP_LIMIT_EXCEEDED,
message: '会员最多只能创建10个小组',
});
}
// 如果是创建子组,检查父组是否存在且用户是否为会员
if (createGroupDto.parentId) {
if (!user.isMember) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '非会员不能创建子组',
});
}
const parentGroup = await this.groupRepository.findOne({
where: { id: createGroupDto.parentId },
});
if (!parentGroup) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: '父组不存在',
});
}
}
// 创建小组
const group = this.groupRepository.create({
...createGroupDto,
ownerId: userId,
maxMembers: createGroupDto.maxMembers || 50,
});
await this.groupRepository.save(group);
// 将创建者添加为小组成员(角色为 owner
const member = this.groupMemberRepository.create({
groupId: group.id,
userId: userId,
role: GroupMemberRole.OWNER,
});
await this.groupMemberRepository.save(member);
return this.findOne(group.id);
}
/**
* 加入小组(使用原子更新防止并发竞态条件)
*/
async join(userId: string, joinGroupDto: JoinGroupDto) {
const { groupId, nickname } = joinGroupDto;
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 检查是否已经是成员
const existingMember = await this.groupMemberRepository.findOne({
where: { groupId, userId },
});
if (existingMember) {
throw new BadRequestException({
code: ErrorCode.ALREADY_IN_GROUP,
message: ErrorMessage[ErrorCode.ALREADY_IN_GROUP],
});
}
// 检查用户加入的小组数量
const joinedGroupsCount = await this.groupMemberRepository.count({
where: { userId },
});
if (!user.isMember && joinedGroupsCount >= 3) {
throw new BadRequestException({
code: ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED,
message: ErrorMessage[ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED],
});
}
// 使用原子更新:只有当当前成员数小于最大成员数时才成功
const updateResult = await this.groupRepository
.createQueryBuilder()
.update(Group)
.set({
currentMembers: () => 'currentMembers + 1',
})
.where('id = :id', { id: groupId })
.andWhere('currentMembers < maxMembers')
.execute();
// 如果影响的行数为0说明小组已满
if (updateResult.affected === 0) {
throw new BadRequestException({
code: ErrorCode.GROUP_FULL,
message: ErrorMessage[ErrorCode.GROUP_FULL],
});
}
// 添加成员记录
const member = this.groupMemberRepository.create({
groupId,
userId,
nickname,
role: GroupMemberRole.MEMBER,
});
await this.groupMemberRepository.save(member);
return this.findOne(groupId);
}
/**
* 退出小组
*/
async leave(userId: string, groupId: string) {
const member = await this.groupMemberRepository.findOne({
where: { groupId, userId },
});
if (!member) {
throw new NotFoundException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 组长不能直接退出
if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({
code: ErrorCode.NO_PERMISSION,
message: '组长不能退出小组,请先转让组长或解散小组',
});
}
await this.groupMemberRepository.remove(member);
// 更新小组成员数
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (group) {
group.currentMembers = Math.max(0, group.currentMembers - 1);
await this.groupRepository.save(group);
}
return { message: '退出成功' };
}
/**
* 获取小组详情
*/
async findOne(id: string) {
// 尝试从缓存获取
const cached = this.cacheService.get<any>(id, {
prefix: this.CACHE_PREFIX,
});
if (cached) {
return cached;
}
const group = await this.groupRepository.findOne({
where: { id },
relations: ['owner', 'members', 'members.user'],
});
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
const result = {
...group,
members: group.members.map((member) => ({
id: member.id,
userId: member.userId,
username: member.user.username,
avatar: member.user.avatar,
nickname: member.nickname,
role: member.role,
joinedAt: member.joinedAt,
})),
};
// 缓存结果
this.cacheService.set(id, result, {
prefix: this.CACHE_PREFIX,
ttl: this.CACHE_TTL,
});
return result;
}
/**
* 获取用户的小组列表
*/
async findUserGroups(userId: string) {
const members = await this.groupMemberRepository.find({
where: { userId },
relations: ['group', 'group.owner'],
});
return members.map((member) => ({
...member.group,
myRole: member.role,
myNickname: member.nickname,
}));
}
/**
* 更新小组信息
*/
async update(userId: string, groupId: string, updateGroupDto: UpdateGroupDto) {
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 检查权限(只有组长和管理员可以修改)
await this.checkPermission(userId, groupId, [
GroupMemberRole.OWNER,
GroupMemberRole.ADMIN,
]);
Object.assign(group, updateGroupDto);
await this.groupRepository.save(group);
// 清除缓存
this.cacheService.del(groupId, { prefix: this.CACHE_PREFIX });
return this.findOne(groupId);
}
/**
* 设置成员角色
*/
async updateMemberRole(
userId: string,
groupId: string,
targetUserId: string,
role: GroupMemberRole,
) {
// 只有组长可以设置管理员
await this.checkPermission(userId, groupId, [GroupMemberRole.OWNER]);
const member = await this.groupMemberRepository.findOne({
where: { groupId, userId: targetUserId },
});
if (!member) {
throw new NotFoundException({
code: ErrorCode.NOT_IN_GROUP,
message: '该用户不在小组中',
});
}
// 不能修改组长角色
if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({
code: ErrorCode.NO_PERMISSION,
message: '不能修改组长角色',
});
}
member.role = role;
await this.groupMemberRepository.save(member);
return { message: '角色设置成功' };
}
/**
* 踢出成员
*/
async kickMember(userId: string, groupId: string, targetUserId: string) {
// 组长和管理员可以踢人
await this.checkPermission(userId, groupId, [
GroupMemberRole.OWNER,
GroupMemberRole.ADMIN,
]);
const member = await this.groupMemberRepository.findOne({
where: { groupId, userId: targetUserId },
});
if (!member) {
throw new NotFoundException({
code: ErrorCode.NOT_IN_GROUP,
message: '该用户不在小组中',
});
}
// 不能踢出组长
if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({
code: ErrorCode.NO_PERMISSION,
message: '不能踢出组长',
});
}
await this.groupMemberRepository.remove(member);
// 更新小组成员数
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (group) {
group.currentMembers = Math.max(0, group.currentMembers - 1);
await this.groupRepository.save(group);
}
return { message: '成员已移除' };
}
/**
* 解散小组
*/
async disband(userId: string, groupId: string) {
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 只有组长可以解散
if (group.ownerId !== userId) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '只有组长可以解散小组',
});
}
group.isActive = false;
await this.groupRepository.save(group);
return { message: '小组已解散' };
}
/**
* 检查权限
*/
private async checkPermission(
userId: string,
groupId: string,
allowedRoles: GroupMemberRole[],
) {
const member = await this.groupMemberRepository.findOne({
where: { groupId, userId },
});
if (!member) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
if (!allowedRoles.includes(member.role)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
}
}

View File

@@ -0,0 +1,71 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsDateString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateHonorDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '荣誉标题', example: '首次五连胜' })
@IsString()
@IsNotEmpty({ message: '标题不能为空' })
@MaxLength(100)
title: string;
@ApiProperty({ description: '荣誉描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '媒体文件URL列表图片/视频)', required: false })
@IsArray()
@IsOptional()
mediaUrls?: string[];
@ApiProperty({ description: '荣誉获得日期', required: false })
@IsDateString()
@IsOptional()
achievedDate?: Date;
}
export class UpdateHonorDto {
@ApiProperty({ description: '荣誉标题', required: false })
@IsString()
@IsOptional()
@MaxLength(100)
title?: string;
@ApiProperty({ description: '荣誉描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '媒体文件URL列表', required: false })
@IsArray()
@IsOptional()
mediaUrls?: string[];
@ApiProperty({ description: '事件日期', required: false })
@IsDateString()
@IsOptional()
eventDate?: Date;
}
export class QueryHonorsDto {
@ApiProperty({ description: '小组ID', required: false })
@IsString()
@IsOptional()
groupId?: string;
@ApiProperty({ description: '年份筛选', required: false, example: 2024 })
@IsOptional()
year?: number;
}

View File

@@ -0,0 +1,64 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { HonorsService } from './honors.service';
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('honors')
@Controller('honors')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class HonorsController {
constructor(private readonly honorsService: HonorsService) {}
@Post()
@ApiOperation({ summary: '创建荣誉记录' })
create(@CurrentUser() user, @Body() createDto: CreateHonorDto) {
return this.honorsService.create(user.id, createDto);
}
@Get()
@ApiOperation({ summary: '查询荣誉列表' })
findAll(@Query() query: QueryHonorsDto) {
return this.honorsService.findAll(query);
}
@Get('timeline/:groupId')
@ApiOperation({ summary: '获取小组荣誉时间轴' })
getTimeline(@Param('groupId') groupId: string) {
return this.honorsService.getTimeline(groupId);
}
@Get(':id')
@ApiOperation({ summary: '查询单个荣誉记录' })
findOne(@Param('id') id: string) {
return this.honorsService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: '更新荣誉记录' })
update(
@CurrentUser() user,
@Param('id') id: string,
@Body() updateDto: UpdateHonorDto,
) {
return this.honorsService.update(user.id, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: '删除荣誉记录' })
remove(@CurrentUser() user, @Param('id') id: string) {
return this.honorsService.remove(user.id, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HonorsController } from './honors.controller';
import { HonorsService } from './honors.service';
import { Honor } from '../../entities/honor.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Honor, Group, GroupMember])],
controllers: [HonorsController],
providers: [HonorsService],
exports: [HonorsService],
})
export class HonorsModule {}

View File

@@ -0,0 +1,313 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HonorsService } from './honors.service';
import { Honor } from '../../entities/honor.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { GroupMemberRole } from '../../common/enums';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('HonorsService', () => {
let service: HonorsService;
let honorRepository: Repository<Honor>;
let groupRepository: Repository<Group>;
let groupMemberRepository: Repository<GroupMember>;
const mockHonor = {
id: 'honor-1',
groupId: 'group-1',
title: '冠军荣誉',
description: '获得比赛冠军',
eventDate: new Date('2025-01-01'),
media: ['image1.jpg'],
createdBy: 'user-1',
createdAt: new Date(),
};
const mockGroup = {
id: 'group-1',
name: '测试小组',
ownerId: 'user-1',
};
const mockGroupMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: GroupMemberRole.ADMIN,
};
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
HonorsService,
{
provide: getRepositoryToken(Honor),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
},
},
{
provide: getRepositoryToken(Group),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(GroupMember),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get<HonorsService>(HonorsService);
honorRepository = module.get<Repository<Honor>>(getRepositoryToken(Honor));
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('应该成功创建荣誉记录(管理员)', async () => {
const createDto = {
groupId: 'group-1',
title: '冠军荣誉',
description: '获得比赛冠军',
eventDate: new Date('2025-01-01'),
media: ['image1.jpg'],
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
const result = await service.create('user-1', createDto);
expect(result).toBeDefined();
expect(honorRepository.save).toHaveBeenCalled();
});
it('小组不存在时应该抛出异常', async () => {
const createDto = {
groupId: 'group-1',
title: '冠军荣誉',
eventDate: new Date('2025-01-01'),
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
});
it('非管理员创建时应该抛出异常', async () => {
const createDto = {
groupId: 'group-1',
title: '冠军荣誉',
eventDate: new Date('2025-01-01'),
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
});
it('组长应该可以创建荣誉记录', async () => {
const createDto = {
groupId: 'group-1',
title: '冠军荣誉',
eventDate: new Date('2025-01-01'),
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.OWNER,
} as any);
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any);
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
const result = await service.create('user-1', createDto);
expect(result).toBeDefined();
});
});
describe('findAll', () => {
it('应该返回荣誉列表', async () => {
mockQueryBuilder.getMany.mockResolvedValue([mockHonor]);
const result = await service.findAll({ groupId: 'group-1' });
expect(result).toHaveLength(1);
expect(honorRepository.createQueryBuilder).toHaveBeenCalled();
});
});
describe('getTimeline', () => {
it('应该返回按年份分组的时间轴', async () => {
const mockHonors = [
{ ...mockHonor, eventDate: new Date('2025-01-01') },
{ ...mockHonor, id: 'honor-2', eventDate: new Date('2024-06-01') },
];
jest.spyOn(honorRepository, 'find').mockResolvedValue(mockHonors as any);
const result = await service.getTimeline('group-1');
expect(result).toBeDefined();
expect(result[2025]).toHaveLength(1);
expect(result[2024]).toHaveLength(1);
});
it('空荣誉列表应该返回空对象', async () => {
jest.spyOn(honorRepository, 'find').mockResolvedValue([]);
const result = await service.getTimeline('group-1');
expect(result).toEqual({});
});
});
describe('findOne', () => {
it('应该返回单个荣誉记录', async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
const result = await service.findOne('honor-1');
expect(result).toBeDefined();
expect(result.id).toBe('honor-1');
});
it('记录不存在时应该抛出异常', async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException);
});
});
describe('update', () => {
it('创建者应该可以更新荣誉记录', async () => {
const updateDto = {
title: '更新后的标题',
};
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue({
...mockHonor,
...updateDto,
} as any);
const result = await service.update('user-1', 'honor-1', updateDto);
expect(result.title).toBe('更新后的标题');
});
it('管理员应该可以更新任何荣誉记录', async () => {
const updateDto = {
title: '更新后的标题',
};
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
...mockHonor,
createdBy: 'other-user',
} as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue({
...mockHonor,
...updateDto,
} as any);
const result = await service.update('user-1', 'honor-1', updateDto);
expect(result).toBeDefined();
});
it('无权限时应该抛出异常', async () => {
const updateDto = {
title: '更新后的标题',
};
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
...mockHonor,
createdBy: 'other-user',
} as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.update('user-1', 'honor-1', updateDto)).rejects.toThrow(ForbiddenException);
});
});
describe('remove', () => {
it('创建者应该可以删除自己的荣誉记录', async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
const result = await service.remove('user-1', 'honor-1');
expect(result.message).toBe('删除成功');
expect(honorRepository.remove).toHaveBeenCalled();
});
it('管理员应该可以删除任何荣誉记录', async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
...mockHonor,
createdBy: 'other-user',
} as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any);
const result = await service.remove('user-1', 'honor-1');
expect(result.message).toBe('删除成功');
});
it('无权限时应该抛出异常', async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({
...mockHonor,
createdBy: 'other-user',
} as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.remove('user-1', 'honor-1')).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -0,0 +1,198 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Honor } from '../../entities/honor.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto';
import { GroupMemberRole } from '../../common/enums';
import {
ErrorCode,
ErrorMessage,
} from '../../common/interfaces/response.interface';
@Injectable()
export class HonorsService {
constructor(
@InjectRepository(Honor)
private honorRepository: Repository<Honor>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
) {}
/**
* 创建荣誉记录
*/
async create(userId: string, createDto: CreateHonorDto) {
const { groupId, ...rest } = createDto;
// 验证小组存在
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证用户权限(需要是小组管理员或组长)
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId },
});
if (
!membership ||
(membership.role !== GroupMemberRole.ADMIN &&
membership.role !== GroupMemberRole.OWNER)
) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '需要管理员权限',
});
}
const honor = this.honorRepository.create({
...rest,
groupId,
creatorId: userId,
});
await this.honorRepository.save(honor);
return this.findOne(honor.id);
}
/**
* 查询荣誉列表
*/
async findAll(query: QueryHonorsDto) {
const qb = this.honorRepository
.createQueryBuilder('honor')
.leftJoinAndSelect('honor.group', 'group')
.leftJoinAndSelect('honor.creator', 'creator');
if (query.groupId) {
qb.andWhere('honor.groupId = :groupId', { groupId: query.groupId });
}
if (query.year) {
const startDate = new Date(`${query.year}-01-01`);
const endDate = new Date(`${query.year}-12-31`);
qb.andWhere('honor.eventDate BETWEEN :startDate AND :endDate', {
startDate,
endDate,
});
}
qb.orderBy('honor.eventDate', 'DESC');
const honors = await qb.getMany();
return honors;
}
/**
* 获取时间轴数据(按年份分组)
*/
async getTimeline(groupId: string) {
const honors = await this.honorRepository.find({
where: { groupId },
relations: ['creator'],
order: { eventDate: 'DESC' },
});
// 按年份分组
const timeline = honors.reduce((acc, honor) => {
const year = new Date(honor.eventDate).getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(honor);
return acc;
}, {});
return timeline;
}
/**
* 查询单个荣誉记录
*/
async findOne(id: string) {
const honor = await this.honorRepository.findOne({
where: { id },
relations: ['group', 'creator'],
});
if (!honor) {
throw new NotFoundException({
code: ErrorCode.HONOR_NOT_FOUND,
message: '荣誉记录不存在',
});
}
return honor;
}
/**
* 更新荣誉记录
*/
async update(userId: string, id: string, updateDto: UpdateHonorDto) {
const honor = await this.findOne(id);
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId: honor.groupId, userId },
});
if (
honor.creatorId !== userId &&
(!membership ||
(membership.role !== GroupMemberRole.ADMIN &&
membership.role !== GroupMemberRole.OWNER))
) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
Object.assign(honor, updateDto);
await this.honorRepository.save(honor);
return this.findOne(id);
}
/**
* 删除荣誉记录
*/
async remove(userId: string, id: string) {
const honor = await this.findOne(id);
// 验证权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId: honor.groupId, userId },
});
if (
honor.creatorId !== userId &&
(!membership ||
(membership.role !== GroupMemberRole.ADMIN &&
membership.role !== GroupMemberRole.OWNER))
) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
await this.honorRepository.remove(honor);
return { message: '删除成功' };
}
}

View File

@@ -0,0 +1,143 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
Min,
IsDateString,
IsEnum,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { LedgerType } from '../../../common/enums';
export class CreateLedgerDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '账目类型', enum: LedgerType })
@IsEnum(LedgerType)
type: LedgerType;
@ApiProperty({ description: '金额', example: 100.5 })
@IsNumber()
@Min(0)
@Type(() => Number)
amount: number;
@ApiProperty({ description: '账目描述' })
@IsString()
@IsNotEmpty({ message: '账目描述不能为空' })
description: string;
@ApiProperty({ description: '分类', required: false })
@IsString()
@IsOptional()
category?: string;
@ApiProperty({ description: '账目日期', required: false })
@IsDateString()
@IsOptional()
date?: Date;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
notes?: string;
}
export class UpdateLedgerDto {
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
@IsEnum(LedgerType)
@IsOptional()
type?: LedgerType;
@ApiProperty({ description: '金额', required: false })
@IsNumber()
@Min(0)
@IsOptional()
@Type(() => Number)
amount?: number;
@ApiProperty({ description: '账目描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '分类', required: false })
@IsString()
@IsOptional()
category?: string;
@ApiProperty({ description: '账目日期', required: false })
@IsDateString()
@IsOptional()
date?: Date;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
notes?: string;
}
export class QueryLedgersDto {
@ApiProperty({ description: '小组ID', required: false })
@IsString()
@IsOptional()
groupId?: string;
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false })
@IsEnum(LedgerType)
@IsOptional()
type?: LedgerType;
@ApiProperty({ description: '分类', required: false })
@IsString()
@IsOptional()
category?: string;
@ApiProperty({ description: '开始日期', required: false })
@IsDateString()
@IsOptional()
startDate?: Date;
@ApiProperty({ description: '结束日期', required: false })
@IsDateString()
@IsOptional()
endDate?: Date;
@ApiProperty({ description: '页码', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
limit?: number;
}
export class MonthlyStatisticsDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '年份', example: 2024 })
@IsNumber()
@Min(2000)
@Type(() => Number)
year: number;
@ApiProperty({ description: '月份', example: 1 })
@IsNumber()
@Min(1)
@Type(() => Number)
month: number;
}

View File

@@ -0,0 +1,110 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { LedgersService } from './ledgers.service';
import {
CreateLedgerDto,
UpdateLedgerDto,
QueryLedgersDto,
MonthlyStatisticsDto,
} from './dto/ledger.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('ledgers')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('ledgers')
export class LedgersController {
constructor(private readonly ledgersService: LedgersService) {}
@Post()
@ApiOperation({ summary: '创建账目' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(
@CurrentUser('id') userId: string,
@Body() createDto: CreateLedgerDto,
) {
return this.ledgersService.create(userId, createDto);
}
@Get()
@ApiOperation({ summary: '获取账目列表' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
@ApiQuery({ name: 'type', required: false, description: '账目类型' })
@ApiQuery({ name: 'category', required: false, description: '分类' })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
async findAll(
@CurrentUser('id') userId: string,
@Query() queryDto: QueryLedgersDto,
) {
return this.ledgersService.findAll(userId, queryDto);
}
@Get('statistics/monthly')
@ApiOperation({ summary: '月度统计' })
@ApiResponse({ status: 200, description: '获取成功' })
async getMonthlyStatistics(
@CurrentUser('id') userId: string,
@Query() statsDto: MonthlyStatisticsDto,
) {
return this.ledgersService.getMonthlyStatistics(userId, statsDto);
}
@Get('statistics/hierarchical/:groupId')
@ApiOperation({ summary: '层级汇总' })
@ApiResponse({ status: 200, description: '获取成功' })
async getHierarchicalSummary(
@CurrentUser('id') userId: string,
@Param('groupId') groupId: string,
) {
return this.ledgersService.getHierarchicalSummary(userId, groupId);
}
@Get(':id')
@ApiOperation({ summary: '获取账目详情' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(@Param('id') id: string) {
return this.ledgersService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: '更新账目' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser('id') userId: string,
@Param('id') id: string,
@Body() updateDto: UpdateLedgerDto,
) {
return this.ledgersService.update(userId, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: '删除账目' })
@ApiResponse({ status: 200, description: '删除成功' })
async remove(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.ledgersService.remove(userId, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LedgersService } from './ledgers.service';
import { LedgersController } from './ledgers.controller';
import { Ledger } from '../../entities/ledger.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Ledger, Group, GroupMember])],
controllers: [LedgersController],
providers: [LedgersService],
exports: [LedgersService],
})
export class LedgersModule {}

View File

@@ -0,0 +1,369 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { LedgersService } from './ledgers.service';
import { Ledger } from '../../entities/ledger.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
enum LedgerType {
INCOME = 'income',
EXPENSE = 'expense',
}
describe('LedgersService', () => {
let service: LedgersService;
let mockLedgerRepository: any;
let mockGroupRepository: any;
let mockGroupMemberRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' };
const mockGroup = {
id: 'group-1',
name: '测试小组',
isActive: true,
parentId: null,
};
const mockMembership = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: 'member',
isActive: true,
};
const mockLedger = {
id: 'ledger-1',
groupId: 'group-1',
creatorId: 'user-1',
type: LedgerType.INCOME,
amount: 100,
category: '聚餐费用',
description: '周末聚餐',
createdAt: new Date('2024-01-20T10:00:00Z'),
updatedAt: new Date(),
};
beforeEach(async () => {
mockLedgerRepository = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
mockGroupRepository = {
findOne: jest.fn(),
find: jest.fn(),
};
mockGroupMemberRepository = {
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
LedgersService,
{
provide: getRepositoryToken(Ledger),
useValue: mockLedgerRepository,
},
{
provide: getRepositoryToken(Group),
useValue: mockGroupRepository,
},
{
provide: getRepositoryToken(GroupMember),
useValue: mockGroupMemberRepository,
},
],
}).compile();
service = module.get<LedgersService>(LedgersService);
});
describe('create', () => {
it('应该成功创建账目', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockLedgerRepository.create.mockReturnValue(mockLedger);
mockLedgerRepository.save.mockResolvedValue(mockLedger);
mockLedgerRepository.findOne.mockResolvedValue({
...mockLedger,
group: mockGroup,
creator: mockUser,
});
const result = await service.create('user-1', {
groupId: 'group-1',
type: LedgerType.INCOME,
amount: 100,
category: '聚餐费用',
description: '周末聚餐',
});
expect(result).toHaveProperty('id');
expect(result.amount).toBe(100);
expect(mockLedgerRepository.save).toHaveBeenCalled();
});
it('应该在小组不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
type: LedgerType.INCOME,
amount: 100,
category: '聚餐费用',
description: '测试',
}),
).rejects.toThrow(NotFoundException);
});
it('应该在用户不在小组中时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
type: LedgerType.INCOME,
amount: 100,
category: '聚餐费用',
description: '测试',
}),
).rejects.toThrow(ForbiddenException);
});
it('应该在金额无效时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect(
service.create('user-1', {
groupId: 'group-1',
type: LedgerType.INCOME,
amount: -100,
category: '聚餐费用',
description: '测试',
}),
).rejects.toThrow(BadRequestException);
});
});
describe('findAll', () => {
it('应该成功获取账目列表', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
};
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', {
groupId: 'group-1',
page: 1,
limit: 10,
});
expect(result).toHaveProperty('items');
expect(result).toHaveProperty('total');
expect(result.items).toHaveLength(1);
});
it('应该支持按类型筛选', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockLedger], 1]),
};
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', {
groupId: 'group-1',
type: LedgerType.INCOME,
page: 1,
limit: 10,
});
expect(result.items).toHaveLength(1);
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('应该成功获取账目详情', async () => {
mockLedgerRepository.findOne.mockResolvedValue({
...mockLedger,
group: mockGroup,
creator: mockUser,
});
const result = await service.findOne('ledger-1');
expect(result).toHaveProperty('id');
expect(result.id).toBe('ledger-1');
});
it('应该在账目不存在时抛出异常', async () => {
mockLedgerRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('ledger-1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
it('应该成功更新账目', async () => {
mockLedgerRepository.findOne
.mockResolvedValueOnce(mockLedger)
.mockResolvedValueOnce({
...mockLedger,
amount: 200,
group: mockGroup,
creator: mockUser,
});
mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership,
role: 'admin',
});
mockLedgerRepository.save.mockResolvedValue({
...mockLedger,
amount: 200,
});
const result = await service.update('user-1', 'ledger-1', {
amount: 200,
});
expect(result.amount).toBe(200);
});
it('应该在账目不存在时抛出异常', async () => {
mockLedgerRepository.findOne.mockResolvedValue(null);
await expect(
service.update('user-1', 'ledger-1', { amount: 200 }),
).rejects.toThrow(NotFoundException);
});
it('应该在无权限时抛出异常', async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership,
role: 'member',
});
await expect(
service.update('user-2', 'ledger-1', { amount: 200 }),
).rejects.toThrow(ForbiddenException);
});
});
describe('remove', () => {
it('应该成功删除账目', async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership,
role: 'admin',
});
mockLedgerRepository.remove.mockResolvedValue(mockLedger);
const result = await service.remove('user-1', 'ledger-1');
expect(result).toHaveProperty('message');
});
it('应该在无权限时抛出异常', async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership,
role: 'member',
});
await expect(
service.remove('user-2', 'ledger-1'),
).rejects.toThrow(ForbiddenException);
});
});
describe('getMonthlyStatistics', () => {
it('应该成功获取月度统计', async () => {
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([
{ ...mockLedger, type: LedgerType.INCOME, amount: 100 },
{ ...mockLedger, type: LedgerType.EXPENSE, amount: 50 },
]),
};
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.getMonthlyStatistics('user-1', {
groupId: 'group-1',
year: 2024,
month: 1,
});
expect(result).toHaveProperty('income');
expect(result).toHaveProperty('expense');
expect(result).toHaveProperty('balance');
expect(result).toHaveProperty('categories');
});
it('应该在用户不在小组时抛出异常', async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.getMonthlyStatistics('user-1', {
groupId: 'group-1',
year: 2024,
month: 1,
}),
).rejects.toThrow(ForbiddenException);
});
});
describe('getHierarchicalSummary', () => {
it('应该成功获取层级汇总', async () => {
const childGroup = { id: 'group-2', name: '子小组', parentId: 'group-1' };
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([mockLedger]),
};
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockGroupRepository.find.mockResolvedValue([childGroup]);
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.getHierarchicalSummary('user-1', 'group-1');
expect(result).toHaveProperty('groupId');
expect(result).toHaveProperty('income');
expect(result).toHaveProperty('expense');
expect(result).toHaveProperty('balance');
});
});
});

View File

@@ -0,0 +1,419 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Ledger } from '../../entities/ledger.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import {
CreateLedgerDto,
UpdateLedgerDto,
QueryLedgersDto,
MonthlyStatisticsDto,
} from './dto/ledger.dto';
import { LedgerType, GroupMemberRole } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import { PaginationUtil } from '../../common/utils/pagination.util';
@Injectable()
export class LedgersService {
constructor(
@InjectRepository(Ledger)
private ledgerRepository: Repository<Ledger>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
) {}
/**
* 创建账目
*/
async create(userId: string, createDto: CreateLedgerDto) {
const { groupId, date, ...rest } = createDto;
// 验证小组是否存在
const group = await this.groupRepository.findOne({
where: { id: groupId, isActive: true },
});
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证用户权限(必须是小组成员)
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 创建账目
const ledger = this.ledgerRepository.create({
...rest,
groupId,
creatorId: userId,
});
await this.ledgerRepository.save(ledger);
return this.findOne(ledger.id);
}
/**
* 获取账目列表
*/
async findAll(userId: string, queryDto: QueryLedgersDto) {
const {
groupId,
type,
category,
startDate,
endDate,
page = 1,
limit = 10,
} = queryDto;
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.ledgerRepository
.createQueryBuilder('ledger')
.leftJoinAndSelect('ledger.group', 'group')
.leftJoinAndSelect('ledger.user', 'user');
// 筛选条件
if (groupId) {
// 验证用户是否在小组中
await this.checkGroupMembership(userId, groupId);
queryBuilder.andWhere('ledger.groupId = :groupId', { groupId });
} else {
// 如果没有指定小组,只返回用户所在小组的账目
const memberGroups = await this.groupMemberRepository.find({
where: { userId, isActive: true },
select: ['groupId'],
});
const groupIds = memberGroups.map((m) => m.groupId);
if (groupIds.length === 0) {
return {
items: [],
total: 0,
page,
limit,
totalPages: 0,
};
}
queryBuilder.andWhere('ledger.groupId IN (:...groupIds)', { groupIds });
}
if (type) {
queryBuilder.andWhere('ledger.type = :type', { type });
}
if (category) {
queryBuilder.andWhere('ledger.category = :category', { category });
}
if (startDate && endDate) {
queryBuilder.andWhere('ledger.createdAt BETWEEN :startDate AND :endDate', {
startDate: new Date(startDate),
endDate: new Date(endDate),
});
} else if (startDate) {
queryBuilder.andWhere('ledger.createdAt >= :startDate', {
startDate: new Date(startDate),
});
} else if (endDate) {
queryBuilder.andWhere('ledger.createdAt <= :endDate', {
endDate: new Date(endDate),
});
}
// 分页
const [items, total] = await queryBuilder
.orderBy('ledger.createdAt', 'DESC')
.skip(offset)
.take(limit)
.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: PaginationUtil.getTotalPages(total, limit),
};
}
/**
* 获取账目详情
*/
async findOne(id: string) {
const ledger = await this.ledgerRepository.findOne({
where: { id },
relations: ['group', 'user'],
});
if (!ledger) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '账目不存在',
});
}
return ledger;
}
/**
* 更新账目
*/
async update(userId: string, id: string, updateDto: UpdateLedgerDto) {
const ledger = await this.ledgerRepository.findOne({
where: { id },
});
if (!ledger) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '账目不存在',
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
Object.assign(ledger, updateDto);
await this.ledgerRepository.save(ledger);
return this.findOne(id);
}
/**
* 删除账目
*/
async remove(userId: string, id: string) {
const ledger = await this.ledgerRepository.findOne({
where: { id },
});
if (!ledger) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '账目不存在',
});
}
// 检查权限:创建者或小组管理员
await this.checkPermission(userId, ledger.groupId, ledger.creatorId);
await this.ledgerRepository.remove(ledger);
return { message: '账目已删除' };
}
/**
* 月度统计
*/
async getMonthlyStatistics(userId: string, statsDto: MonthlyStatisticsDto) {
const { groupId, year, month } = statsDto;
// 验证用户权限
await this.checkGroupMembership(userId, groupId);
// 计算月份起止时间
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59);
// 查询该月所有账目
const ledgers = await this.ledgerRepository.find({
where: {
groupId,
createdAt: Between(startDate, endDate),
},
});
// 统计收入和支出
let totalIncome = 0;
let totalExpense = 0;
const categoryStats: Record<
string,
{ income: number; expense: number; count: number }
> = {};
ledgers.forEach((ledger) => {
const amount = Number(ledger.amount);
if (ledger.type === LedgerType.INCOME) {
totalIncome += amount;
} else {
totalExpense += amount;
}
// 分类统计
const category = ledger.category || '未分类';
if (!categoryStats[category]) {
categoryStats[category] = { income: 0, expense: 0, count: 0 };
}
if (ledger.type === LedgerType.INCOME) {
categoryStats[category].income += amount;
} else {
categoryStats[category].expense += amount;
}
categoryStats[category].count++;
});
return {
groupId,
year,
month,
totalIncome,
totalExpense,
balance: totalIncome - totalExpense,
categoryStats,
recordCount: ledgers.length,
};
}
/**
* 层级汇总(大组->子组)
*/
async getHierarchicalSummary(userId: string, groupId: string) {
// 验证用户权限
await this.checkGroupMembership(userId, groupId);
// 获取大组信息
const parentGroup = await this.groupRepository.findOne({
where: { id: groupId, isActive: true },
});
if (!parentGroup) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 获取所有子组
const childGroups = await this.groupRepository.find({
where: { parentId: groupId, isActive: true },
});
// 统计大组账目
const parentLedgers = await this.ledgerRepository.find({
where: { groupId },
});
const parentStats = this.calculateStats(parentLedgers);
// 统计各子组账目
const childStats = await Promise.all(
childGroups.map(async (child) => {
const ledgers = await this.ledgerRepository.find({
where: { groupId: child.id },
});
return {
groupId: child.id,
groupName: child.name,
...this.calculateStats(ledgers),
};
}),
);
return {
parent: {
groupId: parentGroup.id,
groupName: parentGroup.name,
...parentStats,
},
children: childStats,
total: {
income:
parentStats.totalIncome +
childStats.reduce((sum, c) => sum + c.totalIncome, 0),
expense:
parentStats.totalExpense +
childStats.reduce((sum, c) => sum + c.totalExpense, 0),
},
};
}
/**
* 检查小组成员身份
*/
private async checkGroupMembership(userId: string, groupId: string) {
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
return membership;
}
/**
* 检查用户权限
*/
private async checkPermission(
userId: string,
groupId: string,
creatorId: string,
): Promise<void> {
// 如果是创建者,直接通过
if (userId === creatorId) {
return;
}
// 检查是否是小组管理员或组长
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (
!membership ||
(membership.role !== GroupMemberRole.ADMIN &&
membership.role !== GroupMemberRole.OWNER)
) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
}
/**
* 计算统计数据
*/
private calculateStats(ledgers: Ledger[]) {
let totalIncome = 0;
let totalExpense = 0;
ledgers.forEach((ledger) => {
const amount = Number(ledger.amount);
if (ledger.type === LedgerType.INCOME) {
totalIncome += amount;
} else {
totalExpense += amount;
}
});
return {
totalIncome,
totalExpense,
balance: totalIncome - totalExpense,
recordCount: ledgers.length,
};
}
}

View File

@@ -0,0 +1,52 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddPointDto {
@ApiProperty({ description: '用户ID' })
@IsString()
@IsNotEmpty({ message: '用户ID不能为空' })
userId: string;
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '积分数量', example: 10 })
@IsNumber()
amount: number;
@ApiProperty({ description: '原因', example: '参与预约' })
@IsString()
@IsNotEmpty({ message: '原因不能为空' })
@MaxLength(100)
reason: string;
@ApiProperty({ description: '详细说明', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '关联ID', required: false })
@IsString()
@IsOptional()
relatedId?: string;
}
export class QueryPointsDto {
@ApiProperty({ description: '用户ID', required: false })
@IsString()
@IsOptional()
userId?: string;
@ApiProperty({ description: '小组ID', required: false })
@IsString()
@IsOptional()
groupId?: string;
}

View File

@@ -0,0 +1,52 @@
import {
Controller,
Get,
Post,
Body,
Query,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PointsService } from './points.service';
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('points')
@Controller('points')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class PointsController {
constructor(private readonly pointsService: PointsService) {}
@Post()
@ApiOperation({ summary: '添加积分记录(管理员)' })
addPoint(@CurrentUser() user, @Body() addDto: AddPointDto) {
return this.pointsService.addPoint(user.id, addDto);
}
@Get()
@ApiOperation({ summary: '查询积分流水' })
findAll(@Query() query: QueryPointsDto) {
return this.pointsService.findAll(query);
}
@Get('balance/:userId/:groupId')
@ApiOperation({ summary: '查询用户在小组的积分余额' })
getUserBalance(
@Param('userId') userId: string,
@Param('groupId') groupId: string,
) {
return this.pointsService.getUserBalance(userId, groupId);
}
@Get('ranking/:groupId')
@ApiOperation({ summary: '获取小组积分排行榜' })
getGroupRanking(
@Param('groupId') groupId: string,
@Query('limit') limit?: number,
) {
return this.pointsService.getGroupRanking(groupId, limit);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PointsController } from './points.controller';
import { PointsService } from './points.service';
import { Point } from '../../entities/point.entity';
import { User } from '../../entities/user.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Point, User, Group, GroupMember])],
controllers: [PointsController],
providers: [PointsService],
exports: [PointsService],
})
export class PointsModule {}

View File

@@ -0,0 +1,229 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { PointsService } from './points.service';
import { Point } from '../../entities/point.entity';
import { User } from '../../entities/user.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { GroupMemberRole } from '../../common/enums';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('PointsService', () => {
let service: PointsService;
let pointRepository: Repository<Point>;
let userRepository: Repository<User>;
let groupRepository: Repository<Group>;
let groupMemberRepository: Repository<GroupMember>;
const mockPoint = {
id: 'point-1',
userId: 'user-1',
groupId: 'group-1',
amount: 10,
reason: '参与预约',
description: '测试说明',
createdAt: new Date(),
};
const mockUser = {
id: 'user-1',
username: '测试用户',
};
const mockGroup = {
id: 'group-1',
name: '测试小组',
};
const mockGroupMember = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: GroupMemberRole.ADMIN,
};
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getRawOne: jest.fn(),
getRawMany: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PointsService,
{
provide: getRepositoryToken(Point),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
},
},
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(Group),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(GroupMember),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get<PointsService>(PointsService);
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('addPoint', () => {
it('应该成功添加积分记录', async () => {
const addDto = {
userId: 'user-1',
groupId: 'group-1',
amount: 10,
reason: '参与预约',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
jest.spyOn(pointRepository, 'create').mockReturnValue(mockPoint as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue(mockPoint as any);
const result = await service.addPoint('user-1', addDto);
expect(result).toBeDefined();
expect(pointRepository.save).toHaveBeenCalled();
});
it('小组不存在时应该抛出异常', async () => {
const addDto = {
userId: 'user-1',
groupId: 'group-1',
amount: 10,
reason: '参与预约',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
});
it('用户不存在时应该抛出异常', async () => {
const addDto = {
userId: 'user-1',
groupId: 'group-1',
amount: 10,
reason: '参与预约',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException);
});
it('无权限时应该抛出异常', async () => {
const addDto = {
userId: 'user-1',
groupId: 'group-1',
amount: 10,
reason: '参与预约',
};
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any);
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
...mockGroupMember,
role: GroupMemberRole.MEMBER,
} as any);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(ForbiddenException);
});
});
describe('findAll', () => {
it('应该返回积分流水列表', async () => {
mockQueryBuilder.getMany.mockResolvedValue([mockPoint]);
const result = await service.findAll({ groupId: 'group-1' });
expect(result).toHaveLength(1);
expect(pointRepository.createQueryBuilder).toHaveBeenCalled();
});
});
describe('getUserBalance', () => {
it('应该返回用户积分余额', async () => {
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' });
const result = await service.getUserBalance('user-1', 'group-1');
expect(result.balance).toBe(100);
expect(result.userId).toBe('user-1');
expect(result.groupId).toBe('group-1');
});
it('没有积分记录时应该返回0', async () => {
mockQueryBuilder.getRawOne.mockResolvedValue({ total: null });
const result = await service.getUserBalance('user-1', 'group-1');
expect(result.balance).toBe(0);
});
});
describe('getGroupRanking', () => {
it('应该返回小组积分排行榜', async () => {
const mockRanking = [
{ userId: 'user-1', username: '用户1', totalPoints: '100' },
{ userId: 'user-2', username: '用户2', totalPoints: '80' },
];
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
mockQueryBuilder.getRawMany.mockResolvedValue(mockRanking);
const result = await service.getGroupRanking('group-1', 10);
expect(result).toHaveLength(2);
expect(result[0].rank).toBe(1);
expect(result[0].totalPoints).toBe(100);
expect(result[1].rank).toBe(2);
});
it('小组不存在时应该抛出异常', async () => {
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
await expect(service.getGroupRanking('group-1')).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,150 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Point } from '../../entities/point.entity';
import { User } from '../../entities/user.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { AddPointDto, QueryPointsDto } from './dto/point.dto';
import { GroupMemberRole } from '../../common/enums';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
@Injectable()
export class PointsService {
constructor(
@InjectRepository(Point)
private pointRepository: Repository<Point>,
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
) {}
/**
* 添加积分记录
*/
async addPoint(operatorId: string, addDto: AddPointDto) {
const { userId, groupId, ...rest } = addDto;
// 验证小组存在
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证用户存在
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
// 验证操作者权限(需要管理员)
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId: operatorId },
});
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: '需要管理员权限',
});
}
const point = this.pointRepository.create({
...rest,
userId,
groupId,
});
await this.pointRepository.save(point);
return point;
}
/**
* 查询积分流水
*/
async findAll(query: QueryPointsDto) {
const qb = this.pointRepository
.createQueryBuilder('point')
.leftJoinAndSelect('point.user', 'user')
.leftJoinAndSelect('point.group', 'group');
if (query.userId) {
qb.andWhere('point.userId = :userId', { userId: query.userId });
}
if (query.groupId) {
qb.andWhere('point.groupId = :groupId', { groupId: query.groupId });
}
qb.orderBy('point.createdAt', 'DESC');
const points = await qb.getMany();
return points;
}
/**
* 获取用户在小组的积分总和
*/
async getUserBalance(userId: string, groupId: string) {
const result = await this.pointRepository
.createQueryBuilder('point')
.select('SUM(point.amount)', 'total')
.where('point.userId = :userId', { userId })
.andWhere('point.groupId = :groupId', { groupId })
.getRawOne();
return {
userId,
groupId,
balance: parseInt(result.total || '0'),
};
}
/**
* 获取小组积分排行榜
*/
async getGroupRanking(groupId: string, limit: number = 10) {
const group = await this.groupRepository.findOne({ where: { id: groupId } });
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
const ranking = await this.pointRepository
.createQueryBuilder('point')
.select('point.userId', 'userId')
.addSelect('SUM(point.amount)', 'totalPoints')
.leftJoin('point.user', 'user')
.addSelect('user.username', 'username')
.where('point.groupId = :groupId', { groupId })
.groupBy('point.userId')
.orderBy('totalPoints', 'DESC')
.limit(limit)
.getRawMany();
return ranking.map((item, index) => ({
rank: index + 1,
userId: item.userId,
username: item.username,
totalPoints: parseInt(item.totalPoints),
}));
}
}

View File

@@ -0,0 +1,127 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
Min,
IsArray,
ValidateNested,
IsDateString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class TimeSlotDto {
@ApiProperty({ description: '开始时间', example: '2024-01-20T19:00:00Z' })
@IsDateString()
startTime: Date;
@ApiProperty({ description: '结束时间', example: '2024-01-20T23:00:00Z' })
@IsDateString()
endTime: Date;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
note?: string;
}
export class CreateScheduleDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '标题', example: '本周空闲时间' })
@IsString()
@IsNotEmpty({ message: '标题不能为空' })
title: string;
@ApiProperty({ description: '描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => TimeSlotDto)
availableSlots: TimeSlotDto[];
}
export class UpdateScheduleDto {
@ApiProperty({ description: '标题', required: false })
@IsString()
@IsOptional()
title?: string;
@ApiProperty({ description: '描述', required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto], required: false })
@IsArray()
@ValidateNested({ each: true })
@Type(() => TimeSlotDto)
@IsOptional()
availableSlots?: TimeSlotDto[];
}
export class QuerySchedulesDto {
@ApiProperty({ description: '小组ID', required: false })
@IsString()
@IsOptional()
groupId?: string;
@ApiProperty({ description: '用户ID', required: false })
@IsString()
@IsOptional()
userId?: string;
@ApiProperty({ description: '开始时间', required: false })
@IsDateString()
@IsOptional()
startTime?: Date;
@ApiProperty({ description: '结束时间', required: false })
@IsDateString()
@IsOptional()
endTime?: Date;
@ApiProperty({ description: '页码', example: 1, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
limit?: number;
}
export class FindCommonSlotsDto {
@ApiProperty({ description: '小组ID' })
@IsString()
@IsNotEmpty({ message: '小组ID不能为空' })
groupId: string;
@ApiProperty({ description: '开始时间' })
@IsDateString()
startTime: Date;
@ApiProperty({ description: '结束时间' })
@IsDateString()
endTime: Date;
@ApiProperty({ description: '最少参与人数', example: 3, required: false })
@IsNumber()
@Min(1)
@IsOptional()
@Type(() => Number)
minParticipants?: number;
}

View File

@@ -0,0 +1,99 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import {
CreateScheduleDto,
UpdateScheduleDto,
QuerySchedulesDto,
FindCommonSlotsDto,
} from './dto/schedule.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('schedules')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('schedules')
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Post()
@ApiOperation({ summary: '创建排班' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(
@CurrentUser('id') userId: string,
@Body() createDto: CreateScheduleDto,
) {
return this.schedulesService.create(userId, createDto);
}
@Get()
@ApiOperation({ summary: '获取排班列表' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' })
@ApiQuery({ name: 'userId', required: false, description: '用户ID' })
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' })
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
async findAll(
@CurrentUser('id') userId: string,
@Query() queryDto: QuerySchedulesDto,
) {
return this.schedulesService.findAll(userId, queryDto);
}
@Post('common-slots')
@ApiOperation({ summary: '查找共同空闲时间' })
@ApiResponse({ status: 200, description: '查询成功' })
async findCommonSlots(
@CurrentUser('id') userId: string,
@Body() findDto: FindCommonSlotsDto,
) {
return this.schedulesService.findCommonSlots(userId, findDto);
}
@Get(':id')
@ApiOperation({ summary: '获取排班详情' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(@Param('id') id: string) {
return this.schedulesService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: '更新排班' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser('id') userId: string,
@Param('id') id: string,
@Body() updateDto: UpdateScheduleDto,
) {
return this.schedulesService.update(userId, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: '删除排班' })
@ApiResponse({ status: 200, description: '删除成功' })
async remove(
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.schedulesService.remove(userId, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller';
import { Schedule } from '../../entities/schedule.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
@Module({
imports: [TypeOrmModule.forFeature([Schedule, Group, GroupMember])],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],
})
export class SchedulesModule {}

View File

@@ -0,0 +1,394 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { SchedulesService } from './schedules.service';
import { Schedule } from '../../entities/schedule.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import { TimeSlotDto } from './dto/schedule.dto';
describe('SchedulesService', () => {
let service: SchedulesService;
let mockScheduleRepository: any;
let mockGroupRepository: any;
let mockGroupMemberRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' };
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true };
const mockMembership = {
id: 'member-1',
userId: 'user-1',
groupId: 'group-1',
role: 'member',
isActive: true,
};
const mockTimeSlots: TimeSlotDto[] = [
{
startTime: new Date('2024-01-20T19:00:00Z'),
endTime: new Date('2024-01-20T21:00:00Z'),
note: '晚上空闲',
},
{
startTime: new Date('2024-01-21T14:00:00Z'),
endTime: new Date('2024-01-21T17:00:00Z'),
note: '下午空闲',
},
];
const mockSchedule = {
id: 'schedule-1',
userId: 'user-1',
groupId: 'group-1',
availableSlots: mockTimeSlots,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
mockScheduleRepository = {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
mockGroupRepository = {
findOne: jest.fn(),
};
mockGroupMemberRepository = {
find: jest.fn(),
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
{
provide: getRepositoryToken(Schedule),
useValue: mockScheduleRepository,
},
{
provide: getRepositoryToken(Group),
useValue: mockGroupRepository,
},
{
provide: getRepositoryToken(GroupMember),
useValue: mockGroupMemberRepository,
},
],
}).compile();
service = module.get<SchedulesService>(SchedulesService);
});
describe('create', () => {
it('应该成功创建排班', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.create.mockReturnValue(mockSchedule);
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
mockScheduleRepository.findOne.mockResolvedValue({
...mockSchedule,
user: mockUser,
group: mockGroup,
});
const result = await service.create('user-1', {
groupId: 'group-1',
title: '测试排班',
availableSlots: mockTimeSlots,
});
expect(result).toHaveProperty('id');
expect(mockScheduleRepository.save).toHaveBeenCalled();
});
it('应该在小组不存在时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
title: '测试排班',
availableSlots: mockTimeSlots,
}),
).rejects.toThrow(NotFoundException);
});
it('应该在用户不在小组中时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.create('user-1', {
groupId: 'group-1',
title: '测试排班',
availableSlots: mockTimeSlots,
}),
).rejects.toThrow(ForbiddenException);
});
it('应该在时间段为空时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect(
service.create('user-1', {
groupId: 'group-1',
title: '测试排班',
availableSlots: [],
}),
).rejects.toThrow(BadRequestException);
});
it('应该在时间段无效时抛出异常', async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect(
service.create('user-1', {
groupId: 'group-1',
title: '测试排班',
availableSlots: [
{
startTime: new Date('2024-01-20T21:00:00Z'),
endTime: new Date('2024-01-20T19:00:00Z'), // 结束时间早于开始时间
},
],
}),
).rejects.toThrow(BadRequestException);
});
});
describe('findAll', () => {
it('应该成功获取排班列表', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest
.fn()
.mockResolvedValue([[mockSchedule], 1]),
};
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', {
groupId: 'group-1',
page: 1,
limit: 10,
});
expect(result).toHaveProperty('items');
expect(result).toHaveProperty('total');
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
});
it('应该在指定小组且用户不在小组时抛出异常', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest
.fn()
.mockResolvedValue([[mockSchedule], 1]),
};
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.findAll('user-1', {
groupId: 'group-1',
}),
).rejects.toThrow(ForbiddenException);
});
it('应该在无小组ID时返回用户所在所有小组的排班', async () => {
const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest
.fn()
.mockResolvedValue([[mockSchedule], 1]),
};
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.find.mockResolvedValue([
{ groupId: 'group-1' },
{ groupId: 'group-2' },
]);
const result = await service.findAll('user-1', {});
expect(result.items).toHaveLength(1);
expect(mockGroupMemberRepository.find).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('应该成功获取排班详情', async () => {
mockScheduleRepository.findOne.mockResolvedValue({
...mockSchedule,
user: mockUser,
group: mockGroup,
});
const result = await service.findOne('schedule-1');
expect(result).toHaveProperty('id');
expect(result.id).toBe('schedule-1');
});
it('应该在排班不存在时抛出异常', async () => {
mockScheduleRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('schedule-1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
it('应该成功更新排班', async () => {
mockScheduleRepository.findOne
.mockResolvedValueOnce(mockSchedule)
.mockResolvedValueOnce({
...mockSchedule,
user: mockUser,
group: mockGroup,
});
mockScheduleRepository.save.mockResolvedValue(mockSchedule);
const result = await service.update('user-1', 'schedule-1', {
availableSlots: mockTimeSlots,
});
expect(result).toHaveProperty('id');
expect(mockScheduleRepository.save).toHaveBeenCalled();
});
it('应该在排班不存在时抛出异常', async () => {
mockScheduleRepository.findOne.mockResolvedValue(null);
await expect(
service.update('user-1', 'schedule-1', { availableSlots: mockTimeSlots }),
).rejects.toThrow(NotFoundException);
});
it('应该在非创建者更新时抛出异常', async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
await expect(
service.update('user-2', 'schedule-1', { availableSlots: mockTimeSlots }),
).rejects.toThrow(ForbiddenException);
});
});
describe('remove', () => {
it('应该成功删除排班', async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
mockScheduleRepository.remove.mockResolvedValue(mockSchedule);
const result = await service.remove('user-1', 'schedule-1');
expect(result).toHaveProperty('message');
expect(mockScheduleRepository.remove).toHaveBeenCalled();
});
it('应该在排班不存在时抛出异常', async () => {
mockScheduleRepository.findOne.mockResolvedValue(null);
await expect(service.remove('user-1', 'schedule-1')).rejects.toThrow(
NotFoundException,
);
});
it('应该在非创建者删除时抛出异常', async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
await expect(service.remove('user-2', 'schedule-1')).rejects.toThrow(
ForbiddenException,
);
});
});
describe('findCommonSlots', () => {
it('应该成功查找共同空闲时间', async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.find.mockResolvedValue([
{
...mockSchedule,
userId: 'user-1',
user: { id: 'user-1' },
},
{
...mockSchedule,
id: 'schedule-2',
userId: 'user-2',
user: { id: 'user-2' },
availableSlots: [
{
startTime: new Date('2024-01-20T19:30:00Z'),
endTime: new Date('2024-01-20T22:00:00Z'),
},
],
},
]);
const result = await service.findCommonSlots('user-1', {
groupId: 'group-1',
startTime: new Date('2024-01-20T00:00:00Z'),
endTime: new Date('2024-01-22T00:00:00Z'),
minParticipants: 2,
});
expect(result).toHaveProperty('commonSlots');
expect(result).toHaveProperty('totalParticipants');
expect(result.totalParticipants).toBe(2);
});
it('应该在用户不在小组时抛出异常', async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect(
service.findCommonSlots('user-1', {
groupId: 'group-1',
startTime: new Date('2024-01-20T00:00:00Z'),
endTime: new Date('2024-01-22T00:00:00Z'),
}),
).rejects.toThrow(ForbiddenException);
});
it('应该在没有排班数据时返回空结果', async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.find.mockResolvedValue([]);
const result = await service.findCommonSlots('user-1', {
groupId: 'group-1',
startTime: new Date('2024-01-20T00:00:00Z'),
endTime: new Date('2024-01-22T00:00:00Z'),
});
expect(result.commonSlots).toEqual([]);
expect(result.message).toBe('暂无排班数据');
});
});
});

View File

@@ -0,0 +1,446 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Schedule } from '../../entities/schedule.entity';
import { Group } from '../../entities/group.entity';
import { GroupMember } from '../../entities/group-member.entity';
import {
CreateScheduleDto,
UpdateScheduleDto,
QuerySchedulesDto,
FindCommonSlotsDto,
} from './dto/schedule.dto';
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
import { PaginationUtil } from '../../common/utils/pagination.util';
export interface TimeSlot {
startTime: Date;
endTime: Date;
note?: string;
}
export interface CommonSlot {
startTime: Date;
endTime: Date;
participants: string[];
participantCount: number;
}
@Injectable()
export class SchedulesService {
constructor(
@InjectRepository(Schedule)
private scheduleRepository: Repository<Schedule>,
@InjectRepository(Group)
private groupRepository: Repository<Group>,
@InjectRepository(GroupMember)
private groupMemberRepository: Repository<GroupMember>,
) {}
/**
* 创建排班
*/
async create(userId: string, createDto: CreateScheduleDto) {
const { groupId, availableSlots, ...rest } = createDto;
// 验证小组是否存在
const group = await this.groupRepository.findOne({
where: { id: groupId, isActive: true },
});
if (!group) {
throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND,
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
});
}
// 验证用户权限
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
// 验证时间段
this.validateTimeSlots(availableSlots);
// 创建排班
const schedule = this.scheduleRepository.create({
...rest,
groupId,
userId,
availableSlots: availableSlots as any,
});
await this.scheduleRepository.save(schedule);
return this.findOne(schedule.id);
}
/**
* 获取排班列表
*/
async findAll(userId: string, queryDto: QuerySchedulesDto) {
const {
groupId,
userId: targetUserId,
startTime,
endTime,
page = 1,
limit = 10,
} = queryDto;
const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.scheduleRepository
.createQueryBuilder('schedule')
.leftJoinAndSelect('schedule.group', 'group')
.leftJoinAndSelect('schedule.user', 'user');
// 筛选条件
if (groupId) {
// 验证用户是否在小组中
await this.checkGroupMembership(userId, groupId);
queryBuilder.andWhere('schedule.groupId = :groupId', { groupId });
} else {
// 如果没有指定小组,只返回用户所在小组的排班
const memberGroups = await this.groupMemberRepository.find({
where: { userId, isActive: true },
select: ['groupId'],
});
const groupIds = memberGroups.map((m) => m.groupId);
if (groupIds.length === 0) {
return {
items: [],
total: 0,
page,
limit,
totalPages: 0,
};
}
queryBuilder.andWhere('schedule.groupId IN (:...groupIds)', { groupIds });
}
if (targetUserId) {
queryBuilder.andWhere('schedule.userId = :userId', { userId: targetUserId });
}
if (startTime && endTime) {
queryBuilder.andWhere('schedule.createdAt BETWEEN :startTime AND :endTime', {
startTime: new Date(startTime),
endTime: new Date(endTime),
});
}
// 分页
const [items, total] = await queryBuilder
.orderBy('schedule.createdAt', 'DESC')
.skip(offset)
.take(limit)
.getManyAndCount();
// 解析 availableSlots
const formattedItems = items.map((item) => ({
...item,
availableSlots: this.normalizeAvailableSlots(item.availableSlots),
}));
return {
items: formattedItems,
total,
page,
limit,
totalPages: PaginationUtil.getTotalPages(total, limit),
};
}
/**
* 获取排班详情
*/
async findOne(id: string) {
const schedule = await this.scheduleRepository.findOne({
where: { id },
relations: ['group', 'user'],
});
if (!schedule) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '排班不存在',
});
}
return {
...schedule,
availableSlots: this.normalizeAvailableSlots(schedule.availableSlots),
};
}
/**
* 更新排班
*/
async update(userId: string, id: string, updateDto: UpdateScheduleDto) {
const schedule = await this.scheduleRepository.findOne({
where: { id },
});
if (!schedule) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '排班不存在',
});
}
// 只有创建者可以修改
if (schedule.userId !== userId) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
if (updateDto.availableSlots) {
this.validateTimeSlots(updateDto.availableSlots);
updateDto.availableSlots = updateDto.availableSlots as any;
}
Object.assign(schedule, updateDto);
await this.scheduleRepository.save(schedule);
return this.findOne(id);
}
/**
* 删除排班
*/
async remove(userId: string, id: string) {
const schedule = await this.scheduleRepository.findOne({
where: { id },
});
if (!schedule) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: '排班不存在',
});
}
// 只有创建者可以删除
if (schedule.userId !== userId) {
throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION,
message: ErrorMessage[ErrorCode.NO_PERMISSION],
});
}
await this.scheduleRepository.remove(schedule);
return { message: '排班已删除' };
}
/**
* 查找共同空闲时间
*/
async findCommonSlots(userId: string, findDto: FindCommonSlotsDto) {
const { groupId, startTime, endTime, minParticipants = 2 } = findDto;
// 验证用户权限
await this.checkGroupMembership(userId, groupId);
// 获取时间范围内的所有排班
const schedules = await this.scheduleRepository.find({
where: { groupId },
relations: ['user'],
});
if (schedules.length === 0) {
return {
commonSlots: [],
message: '暂无排班数据',
};
}
// 解析所有时间段
const userSlots: Map<string, TimeSlot[]> = new Map();
schedules.forEach((schedule) => {
const slots = this.normalizeAvailableSlots(schedule.availableSlots);
const filteredSlots = slots.filter((slot) => {
const slotStart = new Date(slot.startTime);
const slotEnd = new Date(slot.endTime);
const rangeStart = new Date(startTime);
const rangeEnd = new Date(endTime);
return slotStart >= rangeStart && slotEnd <= rangeEnd;
});
userSlots.set(schedule.userId, filteredSlots);
});
// 计算时间交集
const commonSlots = this.calculateCommonSlots(userSlots, minParticipants);
// 按参与人数排序
commonSlots.sort((a, b) => b.participantCount - a.participantCount);
return {
commonSlots,
totalParticipants: schedules.length,
minParticipants,
};
}
/**
* 计算共同空闲时间
*/
private calculateCommonSlots(
userSlots: Map<string, TimeSlot[]>,
minParticipants: number,
): CommonSlot[] {
const allSlots: Array<{ time: Date; userId: string; type: 'start' | 'end' }> = [];
// 收集所有时间点
userSlots.forEach((slots, userId) => {
slots.forEach((slot) => {
allSlots.push({
time: new Date(slot.startTime),
userId,
type: 'start',
});
allSlots.push({
time: new Date(slot.endTime),
userId,
type: 'end',
});
});
});
// 按时间排序
allSlots.sort((a, b) => a.time.getTime() - b.time.getTime());
// 扫描线算法计算重叠区间
const commonSlots: CommonSlot[] = [];
const activeUsers = new Set<string>();
let lastTime: Date | null = null;
allSlots.forEach((event) => {
if (lastTime && activeUsers.size >= minParticipants) {
// 记录共同空闲时间段
if (event.time.getTime() > lastTime.getTime()) {
commonSlots.push({
startTime: lastTime,
endTime: event.time,
participants: Array.from(activeUsers),
participantCount: activeUsers.size,
});
}
}
if (event.type === 'start') {
activeUsers.add(event.userId);
} else {
activeUsers.delete(event.userId);
}
lastTime = event.time;
});
// 合并相邻的时间段
return this.mergeAdjacentSlots(commonSlots);
}
/**
* 合并相邻的时间段
*/
private mergeAdjacentSlots(slots: CommonSlot[]): CommonSlot[] {
if (slots.length === 0) return [];
const merged: CommonSlot[] = [];
let current = slots[0];
for (let i = 1; i < slots.length; i++) {
const next = slots[i];
// 如果参与者相同且时间连续,则合并
if (
current.endTime.getTime() === next.startTime.getTime() &&
this.arraysEqual(current.participants, next.participants)
) {
current.endTime = next.endTime;
} else {
merged.push(current);
current = next;
}
}
merged.push(current);
return merged;
}
/**
* 验证时间段
*/
private validateTimeSlots(slots: TimeSlot[]): void {
if (slots.length === 0) {
throw new BadRequestException({
code: ErrorCode.PARAM_ERROR,
message: '至少需要一个时间段',
});
}
slots.forEach((slot, index) => {
const start = new Date(slot.startTime);
const end = new Date(slot.endTime);
if (start >= end) {
throw new BadRequestException({
code: ErrorCode.PARAM_ERROR,
message: `时间段${index + 1}的结束时间必须大于开始时间`,
});
}
});
}
/**
* 标准化时间段数据
*/
private normalizeAvailableSlots(slots: any): TimeSlot[] {
if (Array.isArray(slots)) {
return slots;
}
return [];
}
/**
* 比较两个数组是否相同
*/
private async checkGroupMembership(userId: string, groupId: string) {
const membership = await this.groupMemberRepository.findOne({
where: { groupId, userId, isActive: true },
});
if (!membership) {
throw new ForbiddenException({
code: ErrorCode.NOT_IN_GROUP,
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
});
}
return membership;
}
/**
* 比较两个数组是否相同
*/
private arraysEqual(arr1: string[], arr2: string[]): boolean {
if (arr1.length !== arr2.length) return false;
const sorted1 = [...arr1].sort();
const sorted2 = [...arr2].sort();
return sorted1.every((val, index) => val === sorted2[index]);
}
}

View File

@@ -0,0 +1,31 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiProperty({ description: '邮箱', required: false })
@IsEmail({}, { message: '邮箱格式不正确' })
@IsOptional()
email?: string;
@ApiProperty({ description: '手机号', required: false })
@IsString()
@IsOptional()
phone?: string;
@ApiProperty({ description: '头像URL', required: false })
@IsString()
@IsOptional()
avatar?: string;
}
export class ChangePasswordDto {
@ApiProperty({ description: '旧密码' })
@IsString()
@IsOptional()
oldPassword: string;
@ApiProperty({ description: '新密码' })
@IsString()
@MinLength(6, { message: '密码至少6个字符' })
newPassword: string;
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../../entities/user.entity';
@ApiTags('users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: '获取当前用户信息' })
@ApiResponse({ status: 200, description: '获取成功' })
async getProfile(@CurrentUser() user: User) {
return this.usersService.findOne(user.id);
}
@Get(':id')
@ApiOperation({ summary: '获取用户信息' })
@ApiResponse({ status: 200, description: '获取成功' })
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Put('me')
@ApiOperation({ summary: '更新当前用户信息' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(@CurrentUser() user: User, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(user.id, updateUserDto);
}
@Put('me/password')
@ApiOperation({ summary: '修改密码' })
@ApiResponse({ status: 200, description: '修改成功' })
async changePassword(
@CurrentUser() user: User,
@Body() changePasswordDto: ChangePasswordDto,
) {
return this.usersService.changePassword(user.id, changePasswordDto);
}
}

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

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

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