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, @InjectRepository(Group) private groupRepository: Repository, @InjectRepository(GroupMember) private groupMemberRepository: Repository, ) {} /** * 创建排班 */ 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 = 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, 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(); 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]); } }