refactor: Phase 3 - Extract VenueAllocationService from God Class

- Create VenueAllocationService in schedule/allocation/
- Modify MartialScheduleArrangeServiceImpl to use VenueAllocationService
- Remove validateCapacity, assignVenueAndTimeSlot methods and SlotInfo class
- Original class reduced from 692 to ~500 lines
- All 438 tests passing

Related to Issue #11
This commit is contained in:
2026-01-17 00:15:31 +08:00
parent 017264fd0f
commit c0f3e4d971
10 changed files with 274 additions and 205 deletions
Binary file not shown.
@@ -27,6 +27,7 @@ import org.springblade.modules.martial.config.ScheduleConfig;
import org.springblade.modules.martial.service.IMartialScheduleArrangeService; import org.springblade.modules.martial.service.IMartialScheduleArrangeService;
import org.springblade.modules.martial.service.schedule.generator.TimeSlotGenerator; import org.springblade.modules.martial.service.schedule.generator.TimeSlotGenerator;
import org.springblade.modules.martial.service.schedule.grouping.ParticipantGroupingService; import org.springblade.modules.martial.service.schedule.grouping.ParticipantGroupingService;
import org.springblade.modules.martial.service.schedule.allocation.VenueAllocationService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData; import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData;
import org.springblade.modules.martial.service.schedule.model.TimeSlot; import org.springblade.modules.martial.service.schedule.model.TimeSlot;
@@ -62,6 +63,8 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
private final TimeSlotGenerator timeSlotGenerator; private final TimeSlotGenerator timeSlotGenerator;
private final ParticipantGroupingService participantGroupingService; private final ParticipantGroupingService participantGroupingService;
private final VenueAllocationService venueAllocationService;
@Override @Override
public List<Long> getUnlockedCompetitions() { public List<Long> getUnlockedCompetitions() {
// 查询所有未锁定的赛事(schedule_status != 2) // 查询所有未锁定的赛事(schedule_status != 2)
@@ -390,221 +393,28 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
/** /**
* 验证时间窗口容量是否足够容纳所有分组 * 验证时间窗口容量是否足够容纳所有分组
*/ */
/**
* Validate capacity
* @deprecated Use VenueAllocationService.validateCapacity() directly
*/
@Deprecated
private void validateCapacity(List<ScheduleGroupData> groups, private void validateCapacity(List<ScheduleGroupData> groups,
List<MartialVenue> venues, List<MartialVenue> venues,
List<TimeSlot> timeSlots) { List<TimeSlot> timeSlots) {
// 计算总需求时长 venueAllocationService.validateCapacity(groups, venues, timeSlots);
int totalDuration = groups.stream()
.mapToInt(ScheduleGroupData::getEstimatedDuration)
.sum();
// 计算总可用容量
int totalCapacity = venues.size() * timeSlots.size() * (timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity());
log.info("=== 容量验证 ===");
log.info("分组总需求时长: {} 分钟", totalDuration);
log.info("总可用容量: {} 分钟 ({}个场地 × {}个时段 × {}分钟/时段)",
totalCapacity, venues.size(), timeSlots.size(),
timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity());
if (totalDuration > totalCapacity) {
String errorMsg = String.format(
"时间窗口容量不足! 需要 %d 分钟, 但只有 %d 分钟可用 (缺口: %d 分钟)",
totalDuration, totalCapacity, totalDuration - totalCapacity
);
log.error(errorMsg);
throw new RuntimeException(errorMsg);
}
double utilizationRate = totalCapacity > 0 ? (totalDuration * 100.0 / totalCapacity) : 0;
log.info("预计容量利用率: {}%", (int)utilizationRate);
if (utilizationRate > scheduleConfig.getCapacityWarningThreshold()) {
log.warn("⚠️ 容量利用率超过90%,可能导致分配困难,建议增加场地或延长比赛时间");
}
} }
/**
* Assign venue and time slot
* @deprecated Use VenueAllocationService.allocate() directly
*/
@Deprecated
private void assignVenueAndTimeSlot(List<ScheduleGroupData> groups, private void assignVenueAndTimeSlot(List<ScheduleGroupData> groups,
List<MartialVenue> venues, List<MartialVenue> venues,
List<TimeSlot> timeSlots) { List<TimeSlot> timeSlots) {
log.info("=== 开始分配场地和时间段 ==="); venueAllocationService.allocate(groups, venues, timeSlots);
log.info("场地数量: {}, 时间段数量: {}, 分组数量: {}", venues.size(), timeSlots.size(), groups.size());
// 打印场地容量信息
for (MartialVenue venue : venues) {
log.info("场地: {}, 容纳人数: {}", venue.getVenueName(), venue.getCapacity());
} }
// 每个场地的已用时间(跨所有时段累计)
Map<Long, Integer> venueTotalUsed = new LinkedHashMap<>();
for (MartialVenue venue : venues) {
venueTotalUsed.put(venue.getId(), 0);
}
// 每个时段每个场地的已用时间
Map<String, Map<Long, Integer>> slotVenueUsed = new LinkedHashMap<>();
Map<String, TimeSlot> slotMap = new LinkedHashMap<>();
for (TimeSlot ts : timeSlots) {
String key = ts.getDate() + "_" + ts.getPeriod();
if (!slotVenueUsed.containsKey(key)) {
slotVenueUsed.put(key, new LinkedHashMap<>());
slotMap.put(key, ts);
for (MartialVenue venue : venues) {
slotVenueUsed.get(key).put(venue.getId(), 0);
}
}
}
// 按项目ID分组并计算总时长
Map<Long, List<ScheduleGroupData>> groupsByProject = groups.stream()
.collect(Collectors.groupingBy(ScheduleGroupData::getProjectId));
Map<Long, Integer> projectTotalDuration = new HashMap<>();
groupsByProject.forEach((projectId, projectGroups) -> {
int total = projectGroups.stream().mapToInt(g -> g.getEstimatedDuration() != null ? g.getEstimatedDuration() : 0).sum();
projectTotalDuration.put(projectId, total);
});
// 排序: 集体项目优先,然后按时长降序
List<Long> sortedProjectIds = new ArrayList<>(groupsByProject.keySet());
sortedProjectIds.sort((a, b) -> {
List<ScheduleGroupData> groupsA = groupsByProject.get(a);
List<ScheduleGroupData> groupsB = groupsByProject.get(b);
int typeA = groupsA.get(0).getProjectType() != null ? groupsA.get(0).getProjectType() : 1;
int typeB = groupsB.get(0).getProjectType() != null ? groupsB.get(0).getProjectType() : 1;
if (typeA != typeB) {
return typeB - typeA;
}
return projectTotalDuration.get(b) - projectTotalDuration.get(a);
});
log.info("项目排序: {}", sortedProjectIds.stream()
.map(id -> groupsByProject.get(id).get(0).getGroupName().split(" ")[0] + "(" + projectTotalDuration.get(id) + "分钟)")
.collect(Collectors.joining(", ")));
int assignedCount = 0;
for (Long projectId : sortedProjectIds) {
List<ScheduleGroupData> projectGroups = groupsByProject.get(projectId);
int projectDuration = projectTotalDuration.get(projectId);
String projectName = projectGroups.get(0).getGroupName().split(" ")[0];
// 获取项目的单位容纳人数(每组最大人数)
int projectMaxParticipants = projectGroups.get(0).getMaxParticipants() != null
? projectGroups.get(0).getMaxParticipants() : 1;
log.info("开始分配项目 '{}': {}个分组, 总时长{}分钟, 单位容纳人数={}",
projectName, projectGroups.size(), projectDuration, projectMaxParticipants);
// 第一步:筛选出容量足够的场地
List<MartialVenue> eligibleVenues = venues.stream()
.filter(v -> {
Integer capacity = v.getCapacity();
// 如果场地没有设置容量,默认允许
if (capacity == null || capacity <= 0) return true;
// 场地容量必须 >= 项目单位容纳人数
return capacity >= projectMaxParticipants;
})
.collect(Collectors.toList());
if (eligibleVenues.isEmpty()) {
log.warn("项目 '{}' 需要容纳{}人的场地,但没有符合条件的场地!使用第一个场地",
projectName, projectMaxParticipants);
eligibleVenues = venues;
} else {
log.info("项目 '{}' 可用场地: {}", projectName,
eligibleVenues.stream().map(MartialVenue::getVenueName).collect(Collectors.joining(", ")));
}
// 第二步:在符合条件的场地中,选择总负载最小的
Long bestVenueId = null;
int minTotalUsed = Integer.MAX_VALUE;
for (MartialVenue venue : eligibleVenues) {
int used = venueTotalUsed.get(venue.getId());
if (used < minTotalUsed) {
minTotalUsed = used;
bestVenueId = venue.getId();
}
}
// 第三步:在选定的场地中,找第一个能容纳该项目的时段
String bestSlotKey = null;
for (String slotKey : slotVenueUsed.keySet()) {
TimeSlot ts = slotMap.get(slotKey);
int used = slotVenueUsed.get(slotKey).get(bestVenueId);
int remain = ts.getCapacity() - used;
if (remain >= projectDuration) {
bestSlotKey = slotKey;
break;
}
}
// 如果没有单个时段能容纳,选择剩余容量最大的时段
if (bestSlotKey == null) {
int maxRemain = -1;
for (String slotKey : slotVenueUsed.keySet()) {
TimeSlot ts = slotMap.get(slotKey);
int used = slotVenueUsed.get(slotKey).get(bestVenueId);
int remain = ts.getCapacity() - used;
if (remain > maxRemain) {
maxRemain = remain;
bestSlotKey = slotKey;
}
}
log.warn("项目 '{}' 总时长{}分钟,在场地上没有单个时段能完全容纳", projectName, projectDuration);
}
TimeSlot targetSlot = slotMap.get(bestSlotKey);
int slotIndex = timeSlots.indexOf(targetSlot);
final Long venueId = bestVenueId;
String venueName = venues.stream().filter(v -> v.getId().equals(venueId)).findFirst().map(MartialVenue::getVenueName).orElse("未知场地");
log.info("项目 '{}' 分配到: 场地={}, 日期={}, 时段={}",
projectName, venueName, targetSlot.getDate(), targetSlot.getStartTime());
// 分配该项目的所有分组到同一场地
for (ScheduleGroupData group : projectGroups) {
group.setAssignedVenueId(venueId);
group.setAssignedVenueName(venueName);
group.setAssignedDate(targetSlot.getDate());
group.setAssignedTimeSlot(targetSlot.getStartTime());
group.setAssignedTimeSlotIndex(slotIndex);
group.setAssignedTimePeriod(targetSlot.getPeriod());
assignedCount++;
}
// 更新已用时间
slotVenueUsed.get(bestSlotKey).put(venueId, slotVenueUsed.get(bestSlotKey).get(venueId) + projectDuration);
venueTotalUsed.put(venueId, venueTotalUsed.get(venueId) + projectDuration);
log.info(" 场地 {} 总负载更新为 {} 分钟", venueName, venueTotalUsed.get(venueId));
}
log.info("=== 分配完成: {}/{} 个分组成功分配 ===", assignedCount, groups.size());
// 输出使用统计
log.info("=== 各场地总负载 ===");
for (Map.Entry<Long, Integer> entry : venueTotalUsed.entrySet()) {
final Long vid = entry.getKey();
String vname = venues.stream().filter(v -> v.getId().equals(vid)).findFirst().map(MartialVenue::getVenueName).orElse("未知");
log.info("场地={}, 总负载={}分钟", vname, entry.getValue());
}
}
// 槽位信息内部类
private static class SlotInfo {
int timeSlotIndex; // 时间段索引 (0=第1天上午, 1=第1天下午, 2=第2天上午, ...)
Long venueId;
String venueName;
LocalDate date;
String timeSlot;
String period;
int capacity;
int currentLoad;
}
private void clearOldScheduleData(Long competitionId) { private void clearOldScheduleData(Long competitionId) {
// 删除旧的编排数据 // 删除旧的编排数据
@@ -0,0 +1,259 @@
package org.springblade.modules.martial.service.schedule.allocation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.modules.martial.config.ScheduleConfig;
import org.springblade.modules.martial.pojo.entity.MartialVenue;
import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData;
import org.springblade.modules.martial.service.schedule.model.TimeSlot;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* Venue allocation service
* Allocates groups to venues and time slots
*
* @author Refactored from MartialScheduleArrangeServiceImpl
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VenueAllocationService {
private final ScheduleConfig scheduleConfig;
/**
* Validate if capacity is sufficient for all groups
*/
public void validateCapacity(List<ScheduleGroupData> groups,
List<MartialVenue> venues,
List<TimeSlot> timeSlots) {
int totalDuration = groups.stream()
.mapToInt(ScheduleGroupData::getEstimatedDuration)
.sum();
int totalCapacity = venues.size() * timeSlots.size() *
(timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity());
log.info("=== Capacity Validation ===");
log.info("Total required duration: {} minutes", totalDuration);
log.info("Total available capacity: {} minutes ({} venues x {} slots x {} min/slot)",
totalCapacity, venues.size(), timeSlots.size(),
timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity());
if (totalDuration > totalCapacity) {
String errorMsg = String.format(
"Insufficient capacity! Need %d minutes, but only %d available (gap: %d minutes)",
totalDuration, totalCapacity, totalDuration - totalCapacity
);
log.error(errorMsg);
throw new RuntimeException(errorMsg);
}
double utilizationRate = totalCapacity > 0 ? (totalDuration * 100.0 / totalCapacity) : 0;
log.info("Expected utilization rate: {}%", (int)utilizationRate);
if (utilizationRate > scheduleConfig.getCapacityWarningThreshold()) {
log.warn("Warning: Utilization rate exceeds 90%, allocation may be difficult");
}
}
/**
* Allocate groups to venues and time slots
*/
public void allocate(List<ScheduleGroupData> groups,
List<MartialVenue> venues,
List<TimeSlot> timeSlots) {
log.info("=== Starting venue and time slot allocation ===");
log.info("Venues: {}, Time slots: {}, Groups: {}", venues.size(), timeSlots.size(), groups.size());
Map<Long, Integer> venueTotalUsed = initVenueTotalUsed(venues);
Map<String, Map<Long, Integer>> slotVenueUsed = new LinkedHashMap<>();
Map<String, TimeSlot> slotMap = new LinkedHashMap<>();
initSlotMaps(timeSlots, venues, slotVenueUsed, slotMap);
Map<Long, List<ScheduleGroupData>> groupsByProject = groups.stream()
.collect(Collectors.groupingBy(ScheduleGroupData::getProjectId));
Map<Long, Integer> projectTotalDuration = calculateProjectDurations(groupsByProject);
List<Long> sortedProjectIds = sortProjectsByPriority(groupsByProject, projectTotalDuration);
int assignedCount = 0;
for (Long projectId : sortedProjectIds) {
assignedCount += allocateProject(projectId, groupsByProject.get(projectId),
projectTotalDuration.get(projectId), venues, timeSlots,
venueTotalUsed, slotVenueUsed, slotMap);
}
log.info("=== Allocation complete: {}/{} groups assigned ===", assignedCount, groups.size());
logVenueUsage(venues, venueTotalUsed);
}
private Map<Long, Integer> initVenueTotalUsed(List<MartialVenue> venues) {
Map<Long, Integer> venueTotalUsed = new LinkedHashMap<>();
for (MartialVenue venue : venues) {
venueTotalUsed.put(venue.getId(), 0);
}
return venueTotalUsed;
}
private void initSlotMaps(List<TimeSlot> timeSlots, List<MartialVenue> venues,
Map<String, Map<Long, Integer>> slotVenueUsed,
Map<String, TimeSlot> slotMap) {
for (TimeSlot ts : timeSlots) {
String key = ts.getDate() + "_" + ts.getPeriod();
if (!slotVenueUsed.containsKey(key)) {
slotVenueUsed.put(key, new LinkedHashMap<>());
slotMap.put(key, ts);
for (MartialVenue venue : venues) {
slotVenueUsed.get(key).put(venue.getId(), 0);
}
}
}
}
private Map<Long, Integer> calculateProjectDurations(Map<Long, List<ScheduleGroupData>> groupsByProject) {
Map<Long, Integer> projectTotalDuration = new HashMap<>();
groupsByProject.forEach((projectId, projectGroups) -> {
int total = projectGroups.stream()
.mapToInt(g -> g.getEstimatedDuration() != null ? g.getEstimatedDuration() : 0)
.sum();
projectTotalDuration.put(projectId, total);
});
return projectTotalDuration;
}
private List<Long> sortProjectsByPriority(Map<Long, List<ScheduleGroupData>> groupsByProject,
Map<Long, Integer> projectTotalDuration) {
List<Long> sortedProjectIds = new ArrayList<>(groupsByProject.keySet());
sortedProjectIds.sort((a, b) -> {
List<ScheduleGroupData> groupsA = groupsByProject.get(a);
List<ScheduleGroupData> groupsB = groupsByProject.get(b);
int typeA = groupsA.get(0).getProjectType() != null ? groupsA.get(0).getProjectType() : 1;
int typeB = groupsB.get(0).getProjectType() != null ? groupsB.get(0).getProjectType() : 1;
if (typeA != typeB) {
return typeB - typeA;
}
return projectTotalDuration.get(b) - projectTotalDuration.get(a);
});
return sortedProjectIds;
}
private int allocateProject(Long projectId, List<ScheduleGroupData> projectGroups,
int projectDuration, List<MartialVenue> venues,
List<TimeSlot> timeSlots, Map<Long, Integer> venueTotalUsed,
Map<String, Map<Long, Integer>> slotVenueUsed,
Map<String, TimeSlot> slotMap) {
String projectName = projectGroups.get(0).getGroupName().split(" ")[0];
int projectMaxParticipants = projectGroups.get(0).getMaxParticipants() != null
? projectGroups.get(0).getMaxParticipants() : 1;
List<MartialVenue> eligibleVenues = filterEligibleVenues(venues, projectMaxParticipants, projectName);
Long bestVenueId = findBestVenue(eligibleVenues, venueTotalUsed);
String bestSlotKey = findBestSlot(bestVenueId, projectDuration, slotVenueUsed, slotMap, projectName);
TimeSlot targetSlot = slotMap.get(bestSlotKey);
int slotIndex = timeSlots.indexOf(targetSlot);
final Long venueId = bestVenueId;
String venueName = venues.stream()
.filter(v -> v.getId().equals(venueId))
.findFirst()
.map(MartialVenue::getVenueName)
.orElse("Unknown");
log.info("Project '{}' assigned to: venue={}, date={}, slot={}",
projectName, venueName, targetSlot.getDate(), targetSlot.getStartTime());
for (ScheduleGroupData group : projectGroups) {
group.setAssignedVenueId(venueId);
group.setAssignedVenueName(venueName);
group.setAssignedDate(targetSlot.getDate());
group.setAssignedTimeSlot(targetSlot.getStartTime());
group.setAssignedTimeSlotIndex(slotIndex);
group.setAssignedTimePeriod(targetSlot.getPeriod());
}
slotVenueUsed.get(bestSlotKey).put(venueId,
slotVenueUsed.get(bestSlotKey).get(venueId) + projectDuration);
venueTotalUsed.put(venueId, venueTotalUsed.get(venueId) + projectDuration);
return projectGroups.size();
}
private List<MartialVenue> filterEligibleVenues(List<MartialVenue> venues,
int projectMaxParticipants,
String projectName) {
List<MartialVenue> eligibleVenues = venues.stream()
.filter(v -> {
Integer capacity = v.getCapacity();
if (capacity == null || capacity <= 0) return true;
return capacity >= projectMaxParticipants;
})
.collect(Collectors.toList());
if (eligibleVenues.isEmpty()) {
log.warn("Project '{}' needs venue for {} people, but no eligible venue found! Using first venue",
projectName, projectMaxParticipants);
return venues;
}
return eligibleVenues;
}
private Long findBestVenue(List<MartialVenue> eligibleVenues, Map<Long, Integer> venueTotalUsed) {
Long bestVenueId = null;
int minTotalUsed = Integer.MAX_VALUE;
for (MartialVenue venue : eligibleVenues) {
int used = venueTotalUsed.get(venue.getId());
if (used < minTotalUsed) {
minTotalUsed = used;
bestVenueId = venue.getId();
}
}
return bestVenueId;
}
private String findBestSlot(Long venueId, int projectDuration,
Map<String, Map<Long, Integer>> slotVenueUsed,
Map<String, TimeSlot> slotMap, String projectName) {
String bestSlotKey = null;
for (String slotKey : slotVenueUsed.keySet()) {
TimeSlot ts = slotMap.get(slotKey);
int used = slotVenueUsed.get(slotKey).get(venueId);
int remain = ts.getCapacity() - used;
if (remain >= projectDuration) {
bestSlotKey = slotKey;
break;
}
}
if (bestSlotKey == null) {
int maxRemain = -1;
for (String slotKey : slotVenueUsed.keySet()) {
TimeSlot ts = slotMap.get(slotKey);
int used = slotVenueUsed.get(slotKey).get(venueId);
int remain = ts.getCapacity() - used;
if (remain > maxRemain) {
maxRemain = remain;
bestSlotKey = slotKey;
}
}
log.warn("Project '{}' duration {} minutes cannot fit in single slot", projectName, projectDuration);
}
return bestSlotKey;
}
private void logVenueUsage(List<MartialVenue> venues, Map<Long, Integer> venueTotalUsed) {
log.info("=== Venue Usage Summary ===");
for (Map.Entry<Long, Integer> entry : venueTotalUsed.entrySet()) {
final Long vid = entry.getKey();
String vname = venues.stream()
.filter(v -> v.getId().equals(vid))
.findFirst()
.map(MartialVenue::getVenueName)
.orElse("Unknown");
log.info("Venue={}, Total load={} minutes", vname, entry.getValue());
}
}
}