refactor: Phase 2 - Extract ParticipantGroupingService from God Class

- Create ScheduleGroupData model class in schedule/model/
- Create ParticipantGroupingService in schedule/grouping/
- Modify MartialScheduleArrangeServiceImpl to use ParticipantGroupingService
- Remove internal ScheduleGroupData class and grouping methods
- All 438 tests passing

Related to Issue #11
This commit is contained in:
2026-01-17 00:03:10 +08:00
parent 596e4a274d
commit 017264fd0f
11 changed files with 303 additions and 388 deletions
Binary file not shown.
@@ -26,7 +26,9 @@ import org.springblade.modules.martial.pojo.entity.*;
import org.springblade.modules.martial.config.ScheduleConfig;
import org.springblade.modules.martial.service.IMartialScheduleArrangeService;
import org.springblade.modules.martial.service.schedule.generator.TimeSlotGenerator;
import org.springblade.modules.martial.service.schedule.grouping.ParticipantGroupingService;
import org.springframework.stereotype.Service;
import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData;
import org.springblade.modules.martial.service.schedule.model.TimeSlot;
import org.springframework.transaction.annotation.Transactional;
@@ -59,6 +61,7 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
private final ScheduleConfig scheduleConfig;
private final TimeSlotGenerator timeSlotGenerator;
private final ParticipantGroupingService participantGroupingService;
@Override
public List<Long> getUnlockedCompetitions() {
// 查询所有未锁定的赛事(schedule_status != 2)
@@ -372,249 +375,17 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
* @param project 项目
* @return 分组类别名称: 男子/女子/混合/团体
*/
private String getProjectCategoryName(MartialProject project) {
if (project == null || project.getCategory() == null) {
return "";
}
String category = project.getCategory();
switch (category) {
case "1": return "男子";
case "2": return "女子";
case "3": return "混合";
case "4": return "团体";
default: return category; // 如果是自定义文字,直接返回
}
}
/**
* 根据项目的分组类别过滤运动员
* @param athletes 运动员列表
* @param project 项目
* @return 过滤后的运动员列表
* Auto group participants
* @deprecated Use ParticipantGroupingService.autoGroup() directly
*/
private List<MartialAthlete> filterAthletesByProjectCategory(List<MartialAthlete> athletes, MartialProject project) {
if (project == null || project.getCategory() == null) {
return athletes;
}
String category = project.getCategory();
switch (category) {
case "1": // 男子项目,只保留男性运动员
return athletes.stream()
.filter(a -> a.getGender() == null || a.getGender() == 1)
.collect(Collectors.toList());
case "2": // 女子项目,只保留女性运动员
return athletes.stream()
.filter(a -> a.getGender() != null && a.getGender() == 2)
.collect(Collectors.toList());
case "3": // 混合项目,所有运动员
case "4": // 团体项目,所有运动员
default:
return athletes;
}
}
@Deprecated
private List<ScheduleGroupData> autoGroupParticipants(List<MartialAthlete> athletes) {
List<ScheduleGroupData> groups = new ArrayList<>();
int displayOrder = 1;
// 先加载所有项目信息(用于获取项目类型和名称)
Set<Long> projectIds = athletes.stream()
.map(MartialAthlete::getProjectId)
.filter(id -> id != null)
.collect(Collectors.toSet());
Map<Long, MartialProject> projectMap = new HashMap<>();
for (Long projectId : projectIds) {
MartialProject project = projectMapper.selectById(projectId);
if (project != null) {
projectMap.put(projectId, project);
}
return participantGroupingService.autoGroup(athletes);
}
// 分离集体和个人项目(根据项目表的type字段: 1=个人, 2=双人, 3=集体)
List<MartialAthlete> teamAthletes = athletes.stream()
.filter(a -> {
MartialProject project = projectMap.get(a.getProjectId());
return project != null && (project.getType() == 2 || project.getType() == 3);
})
.collect(Collectors.toList());
List<MartialAthlete> individualAthletes = athletes.stream()
.filter(a -> {
MartialProject project = projectMap.get(a.getProjectId());
return project != null && project.getType() == 1;
})
.collect(Collectors.toList());
// 集体项目分组:按"项目ID_组别"分组
Map<String, List<MartialAthlete>> teamGroupMap = teamAthletes.stream()
.collect(Collectors.groupingBy(a ->
String.valueOf(a.getProjectId())
));
for (Map.Entry<String, List<MartialAthlete>> entry : teamGroupMap.entrySet()) {
List<MartialAthlete> members = entry.getValue();
if (members.isEmpty()) continue;
MartialAthlete first = members.get(0);
MartialProject project = projectMap.get(first.getProjectId());
// 跳过没有项目信息的分组
if (project == null) {
log.warn("项目不存在, projectId: {}, 跳过该分组", first.getProjectId());
continue;
}
// 根据项目的分组类别过滤运动员(男子项目只保留男性,女子项目只保留女性)
members = filterAthletesByProjectCategory(members, project);
if (members.isEmpty()) {
log.info("项目 '{}' 过滤后无符合性别要求的运动员,跳过", project.getProjectName());
continue;
}
// 统计队伍数(按单位分组)
long teamCount = members.stream()
.map(MartialAthlete::getTeamName)
.filter(org -> org != null && !org.isEmpty())
.distinct()
.count();
ScheduleGroupData group = new ScheduleGroupData();
String projectName = project.getProjectName();
String categoryName = getProjectCategoryName(project);
group.setGroupName(projectName + (categoryName.isEmpty() ? "" : " " + categoryName));
group.setProjectId(first.getProjectId());
group.setProjectType((project.getType() == 2 || project.getType() == 3) ? 2 : 1); // type=2(双人)或type=3(集体)映射为projectType=2(集体)
group.setMaxParticipants(project.getMaxParticipants()); // 设置项目单位容纳人数
group.setCategory(categoryName); // 设置组别
group.setDisplayOrder(displayOrder++);
group.setTotalParticipants(members.size());
group.setTotalTeams((int) teamCount);
group.setAthletes(members);
// 计算预计时长:使用项目的 estimatedDuration
// 如果项目设置了时长,使用 队伍数 × 项目时长
// 否则使用默认计算: 队伍数 × 5分钟 + 间隔时间
int duration;
if (project.getEstimatedDuration() != null && project.getEstimatedDuration() > 0) {
duration = (int) teamCount * project.getEstimatedDuration();
log.debug("集体项目 '{}': 使用项目时长 {}分钟/队, {}队, 总计{}分钟",
projectName, project.getEstimatedDuration(), teamCount, duration);
} else {
duration = (int) teamCount * 5 + Math.max(0, (int) teamCount - 1) * 2;
log.debug("集体项目 '{}': 使用默认时长, {}队, 总计{}分钟", projectName, teamCount, duration);
}
group.setEstimatedDuration(duration);
groups.add(group);
}
// 个人项目分组:按"项目ID_组别"分组
Map<String, List<MartialAthlete>> individualGroupMap = individualAthletes.stream()
.collect(Collectors.groupingBy(a ->
String.valueOf(a.getProjectId())
));
for (Map.Entry<String, List<MartialAthlete>> entry : individualGroupMap.entrySet()) {
List<MartialAthlete> members = entry.getValue();
if (members.isEmpty()) continue;
MartialAthlete first = members.get(0);
MartialProject project = projectMap.get(first.getProjectId());
// 跳过没有项目信息的分组
if (project == null) {
log.warn("项目不存在, projectId: {}, 跳过该分组", first.getProjectId());
continue;
}
// 根据项目的分组类别过滤运动员(男子项目只保留男性,女子项目只保留女性)
members = filterAthletesByProjectCategory(members, project);
if (members.isEmpty()) {
log.info("项目 '{}' 过滤后无符合性别要求的运动员,跳过", project.getProjectName());
continue;
}
String projectName = project.getProjectName();
String categoryName = getProjectCategoryName(project);
// 计算单人时长
int durationPerPerson = scheduleConfig.getDefaultDurationPerPerson();
if (project.getEstimatedDuration() != null && project.getEstimatedDuration() > 0) {
durationPerPerson = project.getEstimatedDuration();
}
// 自动拆分大组:如果人数过多,拆分成多个小组
// 优先使用项目配置的单位容纳人数(maxParticipants)
int maxPeoplePerGroup;
Integer projectMax = project.getMaxParticipants();
if (projectMax != null && projectMax > 0) {
// 使用项目配置的单位容纳人数
maxPeoplePerGroup = projectMax;
log.debug("项目 '{}' 使用自定义容纳人数: {}", projectName, projectMax);
} else if (members.size() <= scheduleConfig.getMaxPeoplePerGroup()) {
// 35人以内不拆分
maxPeoplePerGroup = members.size();
} else {
// 超过35人,按每组30-35人拆分
maxPeoplePerGroup = scheduleConfig.getTargetPeoplePerGroup();
}
if (members.size() <= maxPeoplePerGroup) {
// 人数不多,不需要拆分
ScheduleGroupData group = new ScheduleGroupData();
group.setGroupName(projectName + " " + categoryName);
group.setProjectId(first.getProjectId());
group.setProjectType(1);
group.setMaxParticipants(maxPeoplePerGroup); // 设置项目单位容纳人数
group.setDisplayOrder(displayOrder++);
group.setCategory(categoryName); // 设置组别
group.setTotalParticipants(members.size());
group.setAthletes(members);
int duration = members.size() * durationPerPerson;
group.setEstimatedDuration(duration);
log.debug("个人项目 '{}': {}人, {}分钟/人, 总计{}分钟",
projectName + " " + categoryName, members.size(), durationPerPerson, duration);
groups.add(group);
} else {
// 人数太多,需要拆分成多个小组
int totalPeople = members.size();
int numSubGroups = (int) Math.ceil((double) totalPeople / maxPeoplePerGroup);
log.info("个人项目 '{}' 有{}人,将拆分成{}个小组(每组最多{}人)",
projectName + " " + categoryName, totalPeople, numSubGroups, maxPeoplePerGroup);
for (int i = 0; i < numSubGroups; i++) {
int startIdx = i * maxPeoplePerGroup;
int endIdx = Math.min(startIdx + maxPeoplePerGroup, totalPeople);
List<MartialAthlete> subGroupMembers = members.subList(startIdx, endIdx);
ScheduleGroupData group = new ScheduleGroupData();
group.setGroupName(projectName + " " + categoryName + "" + (i + 1) + "");
group.setProjectId(first.getProjectId());
group.setProjectType(1);
group.setMaxParticipants(maxPeoplePerGroup); // 设置项目单位容纳人数
group.setDisplayOrder(displayOrder++);
group.setCategory(categoryName); // 设置组别
group.setTotalParticipants(subGroupMembers.size());
group.setAthletes(new ArrayList<>(subGroupMembers));
int duration = subGroupMembers.size() * durationPerPerson;
group.setEstimatedDuration(duration);
log.debug(" 拆分子组 '第{}组': {}人, 总计{}分钟",
i + 1, subGroupMembers.size(), duration);
groups.add(group);
}
}
}
return groups;
}
/**
* 验证时间窗口容量是否足够容纳所有分组
@@ -918,154 +689,4 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
}
}
private static class ScheduleGroupData {
private String groupName;
private Long projectId;
private Integer projectType;
private Integer displayOrder;
private Integer totalParticipants;
private Integer totalTeams;
private Integer estimatedDuration;
private Integer maxParticipants; // 项目单位容纳人数
private String category; // 组别(男子/女子/混合)
private List<MartialAthlete> athletes;
// 分配结果
private Long assignedVenueId;
private String assignedVenueName;
private LocalDate assignedDate;
private String assignedTimePeriod;
private String assignedTimeSlot;
private Integer assignedTimeSlotIndex; // 时间段索引
// Getters and Setters
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public Integer getProjectType() {
return projectType;
}
public void setProjectType(Integer projectType) {
this.projectType = projectType;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
public Integer getTotalParticipants() {
return totalParticipants;
}
public void setTotalParticipants(Integer totalParticipants) {
this.totalParticipants = totalParticipants;
}
public Integer getTotalTeams() {
return totalTeams;
}
public void setTotalTeams(Integer totalTeams) {
this.totalTeams = totalTeams;
}
public Integer getEstimatedDuration() {
return estimatedDuration;
}
public void setEstimatedDuration(Integer estimatedDuration) {
this.estimatedDuration = estimatedDuration;
}
public Integer getMaxParticipants() {
return maxParticipants;
}
public void setMaxParticipants(Integer maxParticipants) {
this.maxParticipants = maxParticipants;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<MartialAthlete> getAthletes() {
return athletes;
}
public void setAthletes(List<MartialAthlete> athletes) {
this.athletes = athletes;
}
public Long getAssignedVenueId() {
return assignedVenueId;
}
public void setAssignedVenueId(Long assignedVenueId) {
this.assignedVenueId = assignedVenueId;
}
public String getAssignedVenueName() {
return assignedVenueName;
}
public void setAssignedVenueName(String assignedVenueName) {
this.assignedVenueName = assignedVenueName;
}
public LocalDate getAssignedDate() {
return assignedDate;
}
public void setAssignedDate(LocalDate assignedDate) {
this.assignedDate = assignedDate;
}
public String getAssignedTimePeriod() {
return assignedTimePeriod;
}
public void setAssignedTimePeriod(String assignedTimePeriod) {
this.assignedTimePeriod = assignedTimePeriod;
}
public String getAssignedTimeSlot() {
return assignedTimeSlot;
}
public void setAssignedTimeSlot(String assignedTimeSlot) {
this.assignedTimeSlot = assignedTimeSlot;
}
public Integer getAssignedTimeSlotIndex() {
return assignedTimeSlotIndex;
}
public void setAssignedTimeSlotIndex(Integer assignedTimeSlotIndex) {
this.assignedTimeSlotIndex = assignedTimeSlotIndex;
}
}
}
@@ -0,0 +1,252 @@
package org.springblade.modules.martial.service.schedule.grouping;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.modules.martial.config.ScheduleConfig;
import org.springblade.modules.martial.mapper.MartialProjectMapper;
import org.springblade.modules.martial.pojo.entity.MartialAthlete;
import org.springblade.modules.martial.pojo.entity.MartialProject;
import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* Participant grouping service
* Groups athletes by project and category for schedule arrangement
*
* @author Refactored from MartialScheduleArrangeServiceImpl
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ParticipantGroupingService {
private final MartialProjectMapper projectMapper;
private final ScheduleConfig scheduleConfig;
/**
* Auto group participants by project and category
*/
public List<ScheduleGroupData> autoGroup(List<MartialAthlete> athletes) {
List<ScheduleGroupData> groups = new ArrayList<>();
int displayOrder = 1;
Map<Long, MartialProject> projectMap = loadProjectMap(athletes);
List<MartialAthlete> teamAthletes = filterTeamAthletes(athletes, projectMap);
List<MartialAthlete> individualAthletes = filterIndividualAthletes(athletes, projectMap);
displayOrder = groupTeamAthletes(teamAthletes, projectMap, groups, displayOrder);
groupIndividualAthletes(individualAthletes, projectMap, groups, displayOrder);
return groups;
}
public String getProjectCategoryName(MartialProject project) {
if (project == null || project.getCategory() == null) {
return "";
}
switch (project.getCategory()) {
case "1": return "男子";
case "2": return "女子";
case "3": return "混合";
case "4": return "团体";
default: return project.getCategory();
}
}
public List<MartialAthlete> filterByCategory(List<MartialAthlete> athletes, MartialProject project) {
if (project == null || project.getCategory() == null) {
return athletes;
}
switch (project.getCategory()) {
case "1":
return athletes.stream()
.filter(a -> a.getGender() == null || a.getGender() == 1)
.collect(Collectors.toList());
case "2":
return athletes.stream()
.filter(a -> a.getGender() != null && a.getGender() == 2)
.collect(Collectors.toList());
default:
return athletes;
}
}
private Map<Long, MartialProject> loadProjectMap(List<MartialAthlete> athletes) {
Set<Long> projectIds = athletes.stream()
.map(MartialAthlete::getProjectId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, MartialProject> projectMap = new HashMap<>();
for (Long projectId : projectIds) {
MartialProject project = projectMapper.selectById(projectId);
if (project != null) {
projectMap.put(projectId, project);
}
}
return projectMap;
}
private List<MartialAthlete> filterTeamAthletes(List<MartialAthlete> athletes, Map<Long, MartialProject> projectMap) {
return athletes.stream()
.filter(a -> {
MartialProject project = projectMap.get(a.getProjectId());
return project != null && (project.getType() == 2 || project.getType() == 3);
})
.collect(Collectors.toList());
}
private List<MartialAthlete> filterIndividualAthletes(List<MartialAthlete> athletes, Map<Long, MartialProject> projectMap) {
return athletes.stream()
.filter(a -> {
MartialProject project = projectMap.get(a.getProjectId());
return project != null && project.getType() == 1;
})
.collect(Collectors.toList());
}
private int groupTeamAthletes(List<MartialAthlete> teamAthletes, Map<Long, MartialProject> projectMap,
List<ScheduleGroupData> groups, int displayOrder) {
Map<String, List<MartialAthlete>> teamGroupMap = teamAthletes.stream()
.collect(Collectors.groupingBy(a -> String.valueOf(a.getProjectId())));
for (Map.Entry<String, List<MartialAthlete>> entry : teamGroupMap.entrySet()) {
List<MartialAthlete> members = entry.getValue();
if (members.isEmpty()) continue;
MartialAthlete first = members.get(0);
MartialProject project = projectMap.get(first.getProjectId());
if (project == null) {
log.warn("Project not found, projectId: {}, skipping", first.getProjectId());
continue;
}
members = filterByCategory(members, project);
if (members.isEmpty()) {
log.info("Project '{}' has no athletes after gender filter, skipping", project.getProjectName());
continue;
}
long teamCount = members.stream()
.map(MartialAthlete::getTeamName)
.filter(org -> org != null && !org.isEmpty())
.distinct()
.count();
String projectName = project.getProjectName();
String categoryName = getProjectCategoryName(project);
ScheduleGroupData group = ScheduleGroupData.builder()
.groupName(projectName + (categoryName.isEmpty() ? "" : " " + categoryName))
.projectId(first.getProjectId())
.projectType((project.getType() == 2 || project.getType() == 3) ? 2 : 1)
.maxParticipants(project.getMaxParticipants())
.category(categoryName)
.displayOrder(displayOrder++)
.totalParticipants(members.size())
.totalTeams((int) teamCount)
.athletes(members)
.build();
int duration;
if (project.getEstimatedDuration() != null && project.getEstimatedDuration() > 0) {
duration = (int) teamCount * project.getEstimatedDuration();
} else {
duration = (int) teamCount * 5 + Math.max(0, (int) teamCount - 1) * 2;
}
group.setEstimatedDuration(duration);
groups.add(group);
}
return displayOrder;
}
private void groupIndividualAthletes(List<MartialAthlete> individualAthletes, Map<Long, MartialProject> projectMap,
List<ScheduleGroupData> groups, int displayOrder) {
Map<String, List<MartialAthlete>> individualGroupMap = individualAthletes.stream()
.collect(Collectors.groupingBy(a -> String.valueOf(a.getProjectId())));
for (Map.Entry<String, List<MartialAthlete>> entry : individualGroupMap.entrySet()) {
List<MartialAthlete> members = entry.getValue();
if (members.isEmpty()) continue;
MartialAthlete first = members.get(0);
MartialProject project = projectMap.get(first.getProjectId());
if (project == null) {
log.warn("Project not found, projectId: {}, skipping", first.getProjectId());
continue;
}
members = filterByCategory(members, project);
if (members.isEmpty()) continue;
String projectName = project.getProjectName();
String categoryName = getProjectCategoryName(project);
int durationPerPerson = (project.getEstimatedDuration() != null && project.getEstimatedDuration() > 0)
? project.getEstimatedDuration() : scheduleConfig.getDefaultDurationPerPerson();
int maxPeoplePerGroup = calculateMaxPeoplePerGroup(project, members.size());
if (members.size() <= maxPeoplePerGroup) {
ScheduleGroupData group = ScheduleGroupData.builder()
.groupName(projectName + " " + categoryName)
.projectId(first.getProjectId())
.projectType(1)
.maxParticipants(maxPeoplePerGroup)
.displayOrder(displayOrder++)
.category(categoryName)
.totalParticipants(members.size())
.athletes(members)
.estimatedDuration(members.size() * durationPerPerson)
.build();
groups.add(group);
} else {
displayOrder = splitIntoSubGroups(members, project, projectName, categoryName,
maxPeoplePerGroup, durationPerPerson, groups, displayOrder);
}
}
}
private int calculateMaxPeoplePerGroup(MartialProject project, int memberCount) {
Integer projectMax = project.getMaxParticipants();
if (projectMax != null && projectMax > 0) {
return projectMax;
} else if (memberCount <= scheduleConfig.getMaxPeoplePerGroup()) {
return memberCount;
} else {
return scheduleConfig.getTargetPeoplePerGroup();
}
}
private int splitIntoSubGroups(List<MartialAthlete> members, MartialProject project,
String projectName, String categoryName, int maxPeoplePerGroup,
int durationPerPerson, List<ScheduleGroupData> groups, int displayOrder) {
int totalPeople = members.size();
int numSubGroups = (int) Math.ceil((double) totalPeople / maxPeoplePerGroup);
log.info("Individual project '{}' has {} people, splitting into {} groups (max {} per group)",
projectName + " " + categoryName, totalPeople, numSubGroups, maxPeoplePerGroup);
for (int i = 0; i < numSubGroups; i++) {
int startIdx = i * maxPeoplePerGroup;
int endIdx = Math.min(startIdx + maxPeoplePerGroup, totalPeople);
List<MartialAthlete> subGroupMembers = new ArrayList<>(members.subList(startIdx, endIdx));
ScheduleGroupData group = ScheduleGroupData.builder()
.groupName(projectName + " " + categoryName + "" + (i + 1) + "")
.projectId(project.getId())
.projectType(1)
.maxParticipants(maxPeoplePerGroup)
.displayOrder(displayOrder++)
.category(categoryName)
.totalParticipants(subGroupMembers.size())
.athletes(subGroupMembers)
.estimatedDuration(subGroupMembers.size() * durationPerPerson)
.build();
groups.add(group);
}
return displayOrder;
}
}
@@ -0,0 +1,42 @@
package org.springblade.modules.martial.service.schedule.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import org.springblade.modules.martial.pojo.entity.MartialAthlete;
import java.time.LocalDate;
import java.util.List;
/**
* Schedule group data model
* Represents a group of participants for schedule arrangement
*
* @author Refactored from MartialScheduleArrangeServiceImpl
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleGroupData {
private String groupName;
private Long projectId;
private Integer projectType;
private Integer displayOrder;
private Integer totalParticipants;
private Integer totalTeams;
private Integer estimatedDuration;
private Integer maxParticipants;
private String category;
private List<MartialAthlete> athletes;
// Assignment results
private Long assignedVenueId;
private String assignedVenueName;
private LocalDate assignedDate;
private String assignedTimePeriod;
private String assignedTimeSlot;
private Integer assignedTimeSlotIndex;
}