diff --git a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListService.java b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListService.java index 5daec886..ddd42c16 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListService.java +++ b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListService.java @@ -4,7 +4,8 @@ import com.studyclub.common.error.ErrorCode; import com.studyclub.domain.account.Account; import com.studyclub.domain.account.AccountRepository; -import com.studyclub.domain.application.StudyApplicationRepository; +import com.studyclub.domain.participant.ParticipantRole; +import com.studyclub.domain.participant.StudyParticipantRepository; import com.studyclub.domain.study.Study; import com.studyclub.domain.study.StudyProgram; import com.studyclub.domain.study.StudyProgramRepository; @@ -15,6 +16,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,8 +26,9 @@ import tools.jackson.databind.ObjectMapper; /** - * 백오피스 스터디 목록 조립. DRAFT 포함 전 상태를 반환하고, 모집 상태는 최신 모집 회차의 신청서 수로 판정한다 — 공개 목록({@link - * StudyListService})·대시보드와 같다 (specs/dashboard/spec.md · study-recruit-status). + * 백오피스 스터디 목록 조립. DRAFT 포함 전 상태를 반환하고, 모집 상태는 최신 모집 회차의 명부 ACTIVE 인원(CAPACITY_ROLES)으로 판정한다 + * — 공개 목록({@link StudyListService})·대시보드와 같다 (specs/dashboard/spec.md · study-recruit-status · 결정 + * D4-A). */ @Service @Transactional(readOnly = true) @@ -34,7 +37,7 @@ public class BackofficeStudyListService { private static final Logger log = LoggerFactory.getLogger(BackofficeStudyListService.class); private final BackofficeStudyDao backofficeStudyDao; - private final StudyApplicationRepository studyApplicationRepository; + private final StudyParticipantRepository studyParticipantRepository; private final StudyRecruitmentRepository studyRecruitmentRepository; private final StudyProgramRepository studyProgramRepository; private final AccountRepository accountRepository; @@ -42,13 +45,13 @@ public class BackofficeStudyListService { public BackofficeStudyListService( BackofficeStudyDao backofficeStudyDao, - StudyApplicationRepository studyApplicationRepository, + StudyParticipantRepository studyParticipantRepository, StudyRecruitmentRepository studyRecruitmentRepository, StudyProgramRepository studyProgramRepository, AccountRepository accountRepository, ObjectMapper objectMapper) { this.backofficeStudyDao = backofficeStudyDao; - this.studyApplicationRepository = studyApplicationRepository; + this.studyParticipantRepository = studyParticipantRepository; this.studyRecruitmentRepository = studyRecruitmentRepository; this.studyProgramRepository = studyProgramRepository; this.accountRepository = accountRepository; @@ -106,15 +109,12 @@ private List toSummaries( Collectors.toMap( StudyRecruitment::getStudyId, r -> r, (a, b) -> a)); - List recruitmentIds = - latestRecruitments.values().stream().map(StudyRecruitment::getId).toList(); - Map applicationsByRecruitmentId = - recruitmentIds.isEmpty() - ? Map.of() - : studyApplicationRepository.countByRecruitmentIdIn(recruitmentIds).stream() - .collect( - Collectors.toMap( - row -> (Long) row[0], row -> (Long) row[1])); + // 정원을 차지하는 인원 — 명부 ACTIVE 중 정원 역할 (결정 D4-A, study-recruit-status/spec.md) + Map crewCounts = + studyParticipantRepository + .countCapacityHoldersByStudyIds(studyIds, ParticipantRole.CAPACITY_ROLES) + .stream() + .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); Map programs = studyProgramRepository @@ -147,21 +147,26 @@ private List toSummaries( : null; Instant deadline = recruitment != null ? recruitment.getRecruitDeadlineAt() : null; - long applicants = - recruitment == null - ? 0L - : applicationsByRecruitmentId.getOrDefault( - recruitment.getId(), 0L); + long crewCount = crewCounts.getOrDefault(study.getId(), 0L); + StudyProgram program = + Optional.ofNullable(programs.get(study.getProgramId())) + .orElseThrow( + () -> + new BusinessException( + ErrorCode.NOT_FOUND, + "스터디 프로그램을 찾을 수 없습니다: " + + study + .getProgramId())); return new BackofficeStudyListResponse.StudySummary( study.getId(), study.getProgramId(), study.getTitle(), study.getStatus(), study.getCategory(), - programs.get(study.getProgramId()).getStudyKind(), + program.getStudyKind(), capacity, - applicants, - study.recruitStatus(applicants, deadline, capacity, asOf), + crewCount, + study.recruitStatus(crewCount, deadline, capacity, asOf), recruitment != null ? recruitment.getStartAt() : null, deadline, study.getStartAt(), diff --git a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java index b14840d9..06aca707 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java +++ b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java @@ -20,17 +20,22 @@ */ @Repository class BackofficeStudyQueryDao implements BackofficeStudyDao { - private static final String APPLICANTS = - "(SELECT COUNT(a.ID) FROM STUDY_APPLICATION a WHERE a.RECRUITMENT_ID = r.ID)"; + // 정원을 차지하는 인원 — 명부 ACTIVE 중 정원 역할 (결정 D4-A, study-recruit-status/spec.md). + // Study.recruitStatus() 에 crewCount 로 넘기는 값과 같은 기준이다. + private static final String CREW_COUNT = + "(SELECT COUNT(p.ID) FROM STUDY_PARTICIPANT p" + + " WHERE p.STUDY_ID = s.ID" + + " AND p.STATUS = 'ACTIVE'" + + " AND p.PARTICIPANT_ROLE IN ('MEMBER', 'LEADER'))"; - // Study.recruitStatus()의 조회 조건. 최신 모집 회차의 모든 신청서를 세며 계정/명부로 거르지 않는다. + // Study.recruitStatus()의 조회 조건. 명부 ACTIVE 인원을 세며 신청서 수를 쓰지 않는다 (결정 D4-A). // OPEN의 null 마감·null 정원·모집 회차 없음도 기존 엔티티와 동일하게 처리한다. // 정책 변경 시 AdminStudyQueryIntegrationTest의 엔티티 대비 검증도 함께 유지한다. private static final String RECRUITING = "(s.STATUS = 'OPEN'" + " AND (r.RECRUIT_DEADLINE_AT IS NULL OR r.RECRUIT_DEADLINE_AT > :asOf)" + " AND (r.RECRUITMENT_CAPACITY IS NULL OR " - + APPLICANTS + + CREW_COUNT + " < r.RECRUITMENT_CAPACITY))"; private static final String RECRUIT_ORDER = "CASE WHEN " + RECRUITING + " THEN 0 ELSE 1 END"; @@ -213,7 +218,7 @@ private static String orderBy(BackofficeStudyListFilter filter) { case "RECRUIT_START" -> "r.START_AT"; case "RECRUIT_END" -> "r.RECRUIT_DEADLINE_AT"; case "RECRUIT" -> RECRUIT_ORDER; - case "APPLICANTS" -> APPLICANTS; + case "APPLICANTS" -> CREW_COUNT; case "START_AT" -> "s.START_AT"; case "FORM" -> HAS_FORM; case "VISIBILITY" -> "CASE WHEN s.STATUS = 'DRAFT' THEN 0 ELSE 1 END"; diff --git a/backend/api/src/main/java/com/studyclub/api/study/StudyListService.java b/backend/api/src/main/java/com/studyclub/api/study/StudyListService.java index a5b518ef..1a499ac3 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/StudyListService.java +++ b/backend/api/src/main/java/com/studyclub/api/study/StudyListService.java @@ -2,7 +2,8 @@ import com.studyclub.common.error.BusinessException; import com.studyclub.common.error.ErrorCode; -import com.studyclub.domain.application.StudyApplicationRepository; +import com.studyclub.domain.participant.ParticipantRole; +import com.studyclub.domain.participant.StudyParticipantRepository; import com.studyclub.domain.study.Study; import com.studyclub.domain.study.StudyProgram; import com.studyclub.domain.study.StudyProgramRepository; @@ -25,17 +26,17 @@ public class StudyListService { private final StudyListDao studyListDao; - private final StudyApplicationRepository studyApplicationRepository; + private final StudyParticipantRepository studyParticipantRepository; private final StudyRecruitmentRepository studyRecruitmentRepository; private final StudyProgramRepository studyProgramRepository; public StudyListService( StudyListDao studyListDao, - StudyApplicationRepository studyApplicationRepository, + StudyParticipantRepository studyParticipantRepository, StudyRecruitmentRepository studyRecruitmentRepository, StudyProgramRepository studyProgramRepository) { this.studyListDao = studyListDao; - this.studyApplicationRepository = studyApplicationRepository; + this.studyParticipantRepository = studyParticipantRepository; this.studyRecruitmentRepository = studyRecruitmentRepository; this.studyProgramRepository = studyProgramRepository; } @@ -52,21 +53,12 @@ public StudyListResponse list(StudyListFilter filter, int offset, int limit) { List latestRecruitments = studyRecruitmentRepository.findLatestByStudyIdIn(studyIds); - // 신청자 수는 최신 모집 회차의 STUDY_APPLICATION 수 — 스펙 study-recruit-status/spec.md:46 - List recruitmentIds = - latestRecruitments.stream().map(StudyRecruitment::getId).toList(); - Map applicationsByRecruitmentId = - studyApplicationRepository.countByRecruitmentIdIn(recruitmentIds).stream() + // 정원을 차지하는 인원 — 명부 ACTIVE 중 정원 역할 (결정 D4-A, study-recruit-status/spec.md) + Map crewCounts = + studyParticipantRepository + .countCapacityHoldersByStudyIds(studyIds, ParticipantRole.CAPACITY_ROLES) + .stream() .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); - // studyId → 신청자 수 (최신 모집 회차 기준) - Map applicants = - latestRecruitments.stream() - .collect( - Collectors.toMap( - StudyRecruitment::getStudyId, - r -> - applicationsByRecruitmentId.getOrDefault( - r.getId(), 0L))); Map deadlines = latestRecruitments.stream() @@ -107,7 +99,7 @@ public StudyListResponse list(StudyListFilter filter, int offset, int limit) { + study .getProgramId())) .getStudyKind(), - applicants.getOrDefault(study.getId(), 0L), + crewCounts.getOrDefault(study.getId(), 0L), deadlines.get(study.getId()), capacities.get(study.getId()))) .toList(); diff --git a/backend/api/src/main/java/com/studyclub/api/study/StudyService.java b/backend/api/src/main/java/com/studyclub/api/study/StudyService.java index 50afb343..21906cf2 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/StudyService.java +++ b/backend/api/src/main/java/com/studyclub/api/study/StudyService.java @@ -10,6 +10,7 @@ import com.studyclub.domain.bookmark.StudyBookmarkRepository; import com.studyclub.domain.discord.StudyDiscordLinkRepository; import com.studyclub.domain.notice.StudyNoticeRepository; +import com.studyclub.domain.participant.ParticipantRole; import com.studyclub.domain.participant.StudyParticipantRepository; import com.studyclub.domain.storage.StoragePurpose; import com.studyclub.domain.study.Study; @@ -375,7 +376,7 @@ private StudyDetailResponse toDetail(Study study) { () -> new BusinessException( ErrorCode.NOT_FOUND, "스터디 프로그램을 찾을 수 없습니다.")), - applicantCount(latestRecruitment), + crewCount(study), recruitDeadlineAt, recruitmentCapacity, studyDiscordLinkRepository.existsByStudyId(study.getId())); @@ -426,13 +427,11 @@ private static boolean isHttpUrlOrBlank(String value) { return value == null || value.isBlank() || HTTP_URL.matcher(value.trim()).matches(); } - /** 최신 모집 회차의 STUDY_APPLICATION 수 — 목록과 동일한 기준 (study-recruit-status/spec.md:46). */ - private long applicantCount(StudyRecruitment latestRecruitment) { - if (latestRecruitment == null) { - return 0; - } - return studyApplicationRepository - .countByRecruitmentIdIn(List.of(latestRecruitment.getId())) + /** 명부 ACTIVE 중 정원 역할 인원 — 목록과 동일한 기준 (결정 D4-A, study-recruit-status/spec.md). */ + private long crewCount(Study study) { + return studyParticipantRepository + .countCapacityHoldersByStudyIds( + List.of(study.getId()), ParticipantRole.CAPACITY_ROLES) .stream() .findFirst() .map(row -> (Long) row[1]) diff --git a/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java index 00d5ed70..648eafa4 100644 --- a/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java @@ -27,7 +27,7 @@ import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; -/** 대시보드 stats API — 권한·핵심 집계·모집 보드 신청서 수 기준. specs/dashboard/spec.md */ +/** 대시보드 stats API — 권한·핵심 집계·모집 보드 명부 기준. specs/dashboard/spec.md */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestRestTemplate class AdminStatsIntegrationTest { @@ -39,6 +39,7 @@ class AdminStatsIntegrationTest { private static final Long ONGOING_STUDY = 9801L; private static final Long OPEN_STUDY = 9802L; private static final Long GROUP_ID = 9811L; + private static final Long OPEN_STUDY_GROUP_ID = 9812L; @Autowired TestRestTemplate rest; @Autowired JwtService jwtService; @@ -68,7 +69,8 @@ void seed() { insertParticipant(CREW_A, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now); insertParticipant(CREW_B, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now); insertRecruitment(OPEN_STUDY, 1, Instant.now().plus(2, ChronoUnit.DAYS), now); - insertApplication(CREW_A, latestRecruitmentId(OPEN_STUDY), now); + insertGroup(OPEN_STUDY_GROUP_ID, OPEN_STUDY, now); + insertParticipant(CREW_A, OPEN_STUDY, OPEN_STUDY_GROUP_ID, ParticipantStatus.ACTIVE, now); } @Test @@ -188,7 +190,7 @@ void recruitmentBoardMatchesAdminListOrder() { } @Test - @DisplayName("성공 - study-board RECRUITMENT 는 신청서 수로 정원 마감·CLOSING_SOON") + @DisplayName("성공 - study-board RECRUITMENT 는 명부 기준으로 정원 마감·CLOSING_SOON (결정 D4-A)") void recruitmentBoardCapacityAndClosingSoon() { var response = rest.exchange( @@ -332,7 +334,8 @@ private void clean() { ONGOING_STUDY, OPEN_STUDY); jdbcTemplate.update("DELETE FROM STUDY_MEETING WHERE STUDY_GROUP_ID = ?", GROUP_ID); - jdbcTemplate.update("DELETE FROM STUDY_GROUP WHERE ID = ?", GROUP_ID); + jdbcTemplate.update( + "DELETE FROM STUDY_GROUP WHERE ID IN (?, ?)", GROUP_ID, OPEN_STUDY_GROUP_ID); jdbcTemplate.update( "DELETE FROM STUDY_RECRUITMENT WHERE STUDY_ID IN (?, ?)", ONGOING_STUDY, @@ -438,20 +441,4 @@ private void insertRecruitment( now, now); } - - private long latestRecruitmentId(Long studyId) { - return jdbcTemplate.queryForObject( - "SELECT MAX(ID) FROM STUDY_RECRUITMENT WHERE STUDY_ID = ?", Long.class, studyId); - } - - private void insertApplication(Long accountId, long recruitmentId, Timestamp now) { - jdbcTemplate.update( - "INSERT INTO STUDY_APPLICATION (ACCOUNT_ID, RECRUITMENT_ID, FORM_ANSWER," - + " CREATED_AT, UPDATED_AT) VALUES (?, ?, CAST(? AS JSON), ?, ?)", - accountId, - recruitmentId, - "{\"discordNickname\":\"n\"}", - now, - now); - } } diff --git a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyListIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyListIntegrationTest.java index 2debcffe..b3d777ec 100644 --- a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyListIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyListIntegrationTest.java @@ -46,6 +46,8 @@ class AdminStudyListIntegrationTest { private static final Long CLUB_STUDY_ID = 9603L; // 클럽의 다음 기수 — CLUB_STUDY_ID 와 같은 프로그램에 붙는다 private static final Long NEXT_COHORT_ID = 9604L; + // participant 삽입에 사용할 그룹 ID — FK 제약 충족용 + private static final Long STUDY_GROUP_ID = 9611L; @Autowired TestRestTemplate rest; @Autowired JwtService jwtService; @@ -78,6 +80,8 @@ void seed() { insertRecruitment(STUDY_ID, 20, now); insertRecruitment(DRAFT_STUDY_ID, null, now); insertRecruitment(CLUB_STUDY_ID, 15, now); + // participant 삽입 테스트를 위한 그룹 — FK 제약 충족 + insertGroup(STUDY_GROUP_ID, STUDY_ID, now); } @Test @@ -303,13 +307,12 @@ void rejectsNonAdmin() { @SuppressWarnings("unchecked") @Test - @DisplayName("성공 - 최신 모집 회차 신청서 수가 정원에 닿으면 recruitStatus 는 RECRUIT_CLOSED 다") - void recruitClosedWhenApplicationsReachCapacity() { + @DisplayName("성공 - 명부 ACTIVE 가 정원에 닿으면 recruitStatus 는 RECRUIT_CLOSED 다 (결정 D4-A)") + void recruitClosedWhenRosterReachesCapacity() { Timestamp now = Timestamp.from(Instant.now()); setCapacity(STUDY_ID, 2); - long recruitmentId = latestRecruitmentId(STUDY_ID); - insertApplication(ADMIN_ID, recruitmentId, now); - insertApplication(MEMBER_ID, recruitmentId, now); + insertParticipant(ADMIN_ID, STUDY_ID, ParticipantStatus.ACTIVE, now); + insertParticipant(MEMBER_ID, STUDY_ID, ParticipantStatus.ACTIVE, now); Map study = itemById(listStudies(), STUDY_ID); @@ -319,12 +322,12 @@ void recruitClosedWhenApplicationsReachCapacity() { } @Test - @DisplayName("성공 - 명부 ACTIVE 만으로는 정원을 채우지 않는다 — 신청서 수 기준이다") - void activeRosterDoesNotCloseRecruitmentAlone() { + @DisplayName("성공 - 명부 WITHDRAWN 은 정원을 채우지 않는다 — 하차 후 빈자리가 열린다 (결정 D4-A)") + void withdrawnRosterDoesNotFillCapacity() { Timestamp now = Timestamp.from(Instant.now()); setCapacity(STUDY_ID, 2); - insertParticipant(ADMIN_ID, STUDY_ID, ParticipantStatus.ACTIVE, now); - insertParticipant(MEMBER_ID, STUDY_ID, ParticipantStatus.ACTIVE, now); + insertParticipant(ADMIN_ID, STUDY_ID, ParticipantStatus.WITHDRAWN, now); + insertParticipant(MEMBER_ID, STUDY_ID, ParticipantStatus.WITHDRAWN, now); Map study = itemById(listStudies(), STUDY_ID); @@ -489,7 +492,7 @@ private void insertParticipant( + " PARTICIPANT_ROLE, JOINED_AT, CREATED_AT, UPDATED_AT)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", accountId, - 0L, + STUDY_GROUP_ID, studyId, status.name(), ParticipantRole.MEMBER.name(), @@ -534,6 +537,7 @@ private void cleanSeedRows() { STUDY_ID, DRAFT_STUDY_ID, CLUB_STUDY_ID); + jdbcTemplate.update("DELETE FROM STUDY_GROUP WHERE ID = ?", STUDY_GROUP_ID); jdbcTemplate.update( "DELETE FROM STUDY_RECRUITMENT WHERE STUDY_ID IN (?, ?, ?)", STUDY_ID, @@ -620,6 +624,17 @@ private long latestRecruitmentId(Long studyId) { "SELECT MAX(ID) FROM STUDY_RECRUITMENT WHERE STUDY_ID = ?", Long.class, studyId); } + private void insertGroup(Long id, Long studyId, Timestamp now) { + jdbcTemplate.update( + "INSERT INTO STUDY_GROUP (ID, STUDY_ID, NAME, CREATED_AT, UPDATED_AT)" + + " VALUES (?, ?, ?, ?, ?)", + id, + studyId, + "1반", + now, + now); + } + private void insertApplication(Long accountId, long recruitmentId, Timestamp now) { jdbcTemplate.update( "INSERT INTO STUDY_APPLICATION (ACCOUNT_ID, RECRUITMENT_ID, FORM_ANSWER," diff --git a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java index 391293b3..20411bbd 100644 --- a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java @@ -100,7 +100,9 @@ void recruitmentMatchesDomain() { for (Integer capacity : new Integer[] {null, 1, 2}) { study(id, "상태 " + id, "", status, null); recruitment(id, id, deadline, capacity); - application(id, id); + // 정원 판정은 명부 기준 (결정 D4-A) — 1명 ACTIVE 참여자 삽입 + group(id, id); + participant(id, id, id, "ACTIVE"); RecruitStatus expected = Study.builder() .status(status) @@ -140,19 +142,23 @@ void recruitmentMatchesDomain() { } @Test - @DisplayName("최신 모집 회차만 보고 탈퇴 계정의 신청서도 현재 공통 기준대로 센다") - void latestRecruitmentAndMissingAccount() { - study(1, "최신 회차", "", StudyStatus.OPEN, null); + @DisplayName("명부 ACTIVE 가 정원에 닿으면 RECRUIT_CLOSED — 하차(WITHDRAWN)는 자리를 비운다 (결정 D4-A)") + void rosterBasedCapacity() { + study(1, "명부 정원", "", StudyStatus.OPEN, null); recruitment(10, 1, AS_OF.plusSeconds(100), 1); - application(10, 10); // 이전 회차는 정원이 차 있다 - recruitment(11, 1, AS_OF.plusSeconds(100), 1); + group(10, 1); + + // WITHDRAWN 참여자는 정원을 차지하지 않는다 — 모집중으로 보인다 + participant(10, 1, 10, "WITHDRAWN"); assertThat(ids(page(null, AdminStudyRecruitFilter.RECRUITING, null, null, null))) .containsExactly(1L); - application(11, 11); // ACCOUNT 행이 없는 신청서도 포함 + + // ACTIVE 참여자가 정원(1)에 닿으면 모집 마감 + participant(11, 1, 10, "ACTIVE"); assertThat(ids(page(null, AdminStudyRecruitFilter.NOT_RECRUITING, null, null, null))) .containsExactly(1L); var item = page(null, null, null, null, null).items().getFirst(); - assertThat(item.currentApplicants()).isEqualTo(1); + assertThat(item.currentApplicants()).isEqualTo(1); // ACTIVE 1명만 assertThat(item.recruitStatus()).isEqualTo(RecruitStatus.RECRUIT_CLOSED); } @@ -259,13 +265,12 @@ void columnSortValues() { recruitment(1, 1, AS_OF.plusSeconds(200), null); recruitment(2, 2, null, null); recruitment(3, 3, AS_OF.plusSeconds(100), null); - application(1, 1); - application(2, 3); - jdbc.update( - "INSERT INTO STUDY_APPLICATION (ID,ACCOUNT_ID,RECRUITMENT_ID,FORM_ANSWER,CREATED_AT,UPDATED_AT)" - + " VALUES (3,999998,1,CAST('{}' AS JSON),?,?)", - Timestamp.from(AS_OF), - Timestamp.from(AS_OF)); + // APPLICANTS 정렬은 명부 기준 (결정 D4-A) — 스터디 1: 2명, 스터디 2: 0명, 스터디 3: 1명 + group(1, 1); + group(3, 3); + participant(1, 1, 1, "ACTIVE"); + participant(2, 1, 1, "ACTIVE"); + participant(3, 3, 3, "ACTIVE"); Map> expected = Map.ofEntries( Map.entry(BackofficeStudySort.TITLE_ASC, List.of(2L, 3L, 1L)), @@ -409,4 +414,29 @@ private void application(long id, long recruitmentId) { Timestamp.from(AS_OF), Timestamp.from(AS_OF)); } + + private void group(long id, long studyId) { + jdbc.update( + "INSERT INTO STUDY_GROUP (ID,STUDY_ID,NAME,CREATED_AT,UPDATED_AT) VALUES (?,?,'1반',?,?)", + id, + studyId, + Timestamp.from(AS_OF), + Timestamp.from(AS_OF)); + } + + /** status: ACTIVE | WITHDRAWN | PAUSED — ACCOUNT_ID 는 id 를 그대로 써서 같은 그룹 내 충돌 방지 */ + private void participant(long id, long studyId, long groupId, String status) { + jdbc.update( + "INSERT INTO STUDY_PARTICIPANT" + + " (ID,ACCOUNT_ID,STUDY_GROUP_ID,STUDY_ID,STATUS,PARTICIPANT_ROLE,JOINED_AT,CREATED_AT,UPDATED_AT)" + + " VALUES (?,?,?,?,?,'MEMBER',?,?,?)", + id, + id, + groupId, + studyId, + status, + Timestamp.from(AS_OF), + Timestamp.from(AS_OF), + Timestamp.from(AS_OF)); + } } diff --git a/backend/api/src/test/java/com/studyclub/api/study/StudyRecruitStatusIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/study/StudyRecruitStatusIntegrationTest.java index edccdc1f..d60f40a3 100644 --- a/backend/api/src/test/java/com/studyclub/api/study/StudyRecruitStatusIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/study/StudyRecruitStatusIntegrationTest.java @@ -2,10 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.studyclub.domain.application.StudyApplication; -import com.studyclub.domain.application.StudyApplicationRepository; +import com.studyclub.domain.participant.ParticipantRole; +import com.studyclub.domain.participant.ParticipantStatus; +import com.studyclub.domain.participant.StudyParticipant; +import com.studyclub.domain.participant.StudyParticipantRepository; import com.studyclub.domain.study.Study; import com.studyclub.domain.study.StudyCategory; +import com.studyclub.domain.study.StudyGroup; +import com.studyclub.domain.study.StudyGroupRepository; import com.studyclub.domain.study.StudyKind; import com.studyclub.domain.study.StudyProgram; import com.studyclub.domain.study.StudyProgramRepository; @@ -33,15 +37,17 @@ class StudyRecruitStatusIntegrationTest { @Autowired TestRestTemplate rest; @Autowired StudyProgramRepository studyProgramRepo; @Autowired StudyRepository studyRepo; - @Autowired StudyApplicationRepository applicationRepo; @Autowired StudyRecruitmentRepository recruitmentRepo; + @Autowired StudyGroupRepository studyGroupRepo; + @Autowired StudyParticipantRepository participantRepo; private final AtomicLong accountIdSeq = new AtomicLong(1); @BeforeEach void setUp() { - applicationRepo.deleteAll(); + participantRepo.deleteAll(); recruitmentRepo.deleteAll(); + studyGroupRepo.deleteAll(); studyRepo.deleteAll(); studyProgramRepo.deleteAll(); accountIdSeq.set(1); @@ -166,16 +172,21 @@ private Study createStudy(StudyStatus status, Integer capacity, Instant recruitD .recruitDeadlineAt(recruitDeadline) .recruitmentCapacity(capacity) .build()); + studyGroupRepo.save(new StudyGroup(study.getId(), "1반", Instant.now(), null, null)); return study; } + /** 명부에 ACTIVE MEMBER 참여자를 추가한다 — 정원 판정은 명부 기준 (결정 D4-A). */ private void enroll(Study study) { - var recruitment = recruitmentRepo.findByStudyId(study.getId()).get(0); - applicationRepo.save( - StudyApplication.builder() + var group = studyGroupRepo.findByStudyId(study.getId()).get(0); + participantRepo.save( + StudyParticipant.builder() .accountId(accountIdSeq.getAndIncrement()) - .recruitmentId(recruitment.getId()) - .formAnswer("{}") + .studyId(study.getId()) + .studyGroupId(group.getId()) + .status(ParticipantStatus.ACTIVE) + .participantRole(ParticipantRole.MEMBER) + .joinedAt(Instant.now()) .build()); } diff --git a/backend/domain/src/main/java/com/studyclub/domain/study/Study.java b/backend/domain/src/main/java/com/studyclub/domain/study/Study.java index 166908dd..3b0cb678 100644 --- a/backend/domain/src/main/java/com/studyclub/domain/study/Study.java +++ b/backend/domain/src/main/java/com/studyclub/domain/study/Study.java @@ -160,30 +160,26 @@ public boolean isClosingSoon(Instant recruitDeadlineAt, Instant asOf) { *

{@code recruitDeadlineAt == null} 은 상시 모집이라 시각으로는 마감되지 않고, {@code recruitmentCapacity == * null} 은 무제한이라 정원으로도 마감되지 않는다. * - *

신청 수는 STUDY_APPLICATION 애그리거트 소관이라 밖에서 받는다. 정원과 같은 회차끼리 비교해야 하므로 최신 모집 회차의 신청 수 ({@code - * StudyApplicationRepository.countByRecruitmentIdIn}) 를 넘긴다. + *

정원을 차지하는 인원은 STUDY_PARTICIPANT 애그리거트 소관이라 밖에서 받는다. 현재 ACTIVE 명부({@code + * StudyParticipantRepository.countCapacityHoldersByStudyIds}) 를 넘긴다 (결정 D4-A). * *

{@code recruitDeadlineAt} · {@code recruitmentCapacity} 는 STUDY_RECRUITMENT 에서 가져온 값이다. */ public RecruitStatus recruitStatus( - long applicantCount, Instant recruitDeadlineAt, Integer recruitmentCapacity) { - return recruitStatus(applicantCount, recruitDeadlineAt, recruitmentCapacity, Instant.now()); + long crewCount, Instant recruitDeadlineAt, Integer recruitmentCapacity) { + return recruitStatus(crewCount, recruitDeadlineAt, recruitmentCapacity, Instant.now()); } /** * {@link #recruitStatus(long, Instant, Integer)} 와 같되 마감 판정에 {@code asOf} 를 쓴다 — 대시보드 집계 기준 시각. */ public RecruitStatus recruitStatus( - long applicantCount, - Instant recruitDeadlineAt, - Integer recruitmentCapacity, - Instant asOf) { + long crewCount, Instant recruitDeadlineAt, Integer recruitmentCapacity, Instant asOf) { if (status != StudyStatus.OPEN) { return null; } boolean deadlinePassed = recruitDeadlineAt != null && !asOf.isBefore(recruitDeadlineAt); - boolean capacityReached = - recruitmentCapacity != null && applicantCount >= recruitmentCapacity; + boolean capacityReached = recruitmentCapacity != null && crewCount >= recruitmentCapacity; return (deadlinePassed || capacityReached) ? RecruitStatus.RECRUIT_CLOSED : RecruitStatus.RECRUITING; @@ -196,7 +192,7 @@ public RecruitStatus recruitStatus( * RECRUITING}) → 나머지는 종료. 시작 전인데 모집이 마감된 스터디는 신청할 수 없으니 종료로 본다. */ public StudyPhase phase( - long applicantCount, Instant recruitDeadlineAt, Integer recruitmentCapacity) { + long crewCount, Instant recruitDeadlineAt, Integer recruitmentCapacity) { if (status == StudyStatus.DRAFT) { return null; } @@ -207,7 +203,7 @@ public StudyPhase phase( if (startAt != null && !now.isBefore(startAt)) { return StudyPhase.ONGOING; } - return recruitStatus(applicantCount, recruitDeadlineAt, recruitmentCapacity) + return recruitStatus(crewCount, recruitDeadlineAt, recruitmentCapacity) == RecruitStatus.RECRUITING ? StudyPhase.RECRUITING : StudyPhase.CLOSED; diff --git a/specs/study-recruit-status/spec.md b/specs/study-recruit-status/spec.md index 70764286..83b03b19 100644 --- a/specs/study-recruit-status/spec.md +++ b/specs/study-recruit-status/spec.md @@ -34,43 +34,41 @@ ``` status != OPEN → null now >= recruitDeadline → RECRUIT_CLOSED -recruitmentCapacity != null && 신청자수 >= recruitmentCapacity → RECRUIT_CLOSED +recruitmentCapacity != null && 현재 크루 수 >= recruitmentCapacity → RECRUIT_CLOSED 그 외 → RECRUITING ``` | 경계 | 결과 | 근거 | |---|---|---| | `now == recruitDeadline` (정확히 마감 시각) | `RECRUIT_CLOSED` | ERD 가 `now() >= RECRUIT_DEADLINE` 을 마감으로 쓴다. 마감 시각 그 순간은 이미 못 받는다 | -| `recruitmentCapacity == null` | 정원으로는 마감 안 됨 | 무제한. 신청자가 9999 여도 `RECRUITING` | -| 신청자수 `== recruitmentCapacity` | `RECRUIT_CLOSED` | 정원 "도달"이 마감. 초과까지 기다리지 않는다 | +| `recruitmentCapacity == null` | 정원으로는 마감 안 됨 | 무제한. 크루가 9999 여도 `RECRUITING` | +| 현재 크루 수 `== recruitmentCapacity` | `RECRUIT_CLOSED` | 정원 "도달"이 마감. 초과까지 기다리지 않는다 | -**정원·마감·신청자 수는 모집 회차 단위다.** `STUDY.CAPACITY` 는 없고 `STUDY_RECRUITMENT.RECRUITMENT_CAPACITY` 만 쓴다. 신청자 수는 그 회차의 `STUDY_APPLICATION` 수다. 모집 회차가 여러 개면 id 최대인 회차 1건을 기준으로 판정한다 ([study/spec.md](../study/spec.md)). 응답 필드명 `capacity` 는 유지하되 값은 그 회차의 `RECRUITMENT_CAPACITY` 다. +**정원·마감·현재 크루 수는 스터디 단위다.** `STUDY_RECRUITMENT.RECRUITMENT_CAPACITY` 를 정원으로 쓴다. **정원을 차지하는 인원은 명부 기준이다 — 하차(WITHDRAWN) 가 생기면 빈자리가 열린다 (결정 D4-A).** 현재 크루 수는 `STUDY_PARTICIPANT.STATUS = ACTIVE` 이면서 `PARTICIPANT_ROLE IN (MEMBER, LEADER)` 인 행의 수다. 모집 회차가 여러 개면 id 최대인 회차 1건을 기준으로 판정한다 ([study/spec.md](../study/spec.md)). 응답 필드명 `capacity` 는 유지하되 값은 그 회차의 `RECRUITMENT_CAPACITY` 다. -구현: `StudyCohort.recruitStatus(long applicantCount)` — `isClosingSoon()` 바로 옆. -신청자 수는 `STUDY_APPLICATION` 애그리거트 소관이라 엔티티가 직접 세지 않고 **인자로 받는다** -(애그리거트 사이는 ID 참조 — [ddd-guide](../../docs/backend-development-guide/ddd-guide.md)). +구현: `Study.recruitStatus(long crewCount)` — `isClosingSoon()` 바로 옆. +현재 크루 수는 `STUDY_PARTICIPANT` 애그리거트 소관이라 엔티티가 직접 세지 않고 **인자로 받는다** +(`StudyParticipantRepository.countCapacityHoldersByStudyIds` — 애그리거트 사이는 ID 참조, [ddd-guide](../../docs/backend-development-guide/ddd-guide.md)). -## 정원을 차지하는 신청 — `countByStudyIds` 수정 +## 정원을 차지하는 인원 — 명부 기준 (결정 D4-A) -`StudyApplicationRepository.countByCohortIds` 가 `REJECTED` 만 빼고 세고 있었다. -`WITHDRAWN`(철회) · `WAITLISTED`(대기자)까지 세는 값을 정원 도달 판정에 그대로 쓰면 -**철회한 사람이 자리를 잡고 있어서 모집이 닫힌다.** +신청(STUDY_APPLICATION)은 명부(STUDY_PARTICIPANT) 합류와 동시에 이루어지며 별도 상태 없이 행이 남는다. +하차(WITHDRAWN) 후 신청 행은 그대로 남지만 명부 행 상태가 WITHDRAWN 으로 바뀐다. +신청 행 수는 감소하지 않으므로 하차가 생겨도 모집이 "정원 마감"으로 고정되는 버그가 발생한다. -| `ApplicationStatus` | 정원 차지 | 왜 | +**정원을 차지하는 인원은 현재 명부(ACTIVE + CAPACITY_ROLES)로 센다.** + +| 명부 역할/상태 | 정원 차지 | 왜 | |---|---|---| -| `PENDING` | O | 심사 중이지만 자리는 잡고 있다. 정원이 찬 뒤 들어오는 신청이 `WAITLISTED` 가 되려면 `PENDING` 이 세어져야 한다 | -| `APPROVED` | O | | -| `REJECTED` | X | | -| `WITHDRAWN` | X | 자리를 비웠다 | -| `WAITLISTED` | X | 정의상 정원 **밖**이다 | +| ACTIVE + MEMBER | O | 현재 참여 중 | +| ACTIVE + LEADER | O | 네비게이터도 자리를 차지한다 | +| WITHDRAWN (모든 역할) | X | 하차로 자리를 비웠다 | +| PAUSED (모든 역할) | X | 휴식 중이므로 자리를 열어 둔다 | -**표시용 수와 정원용 수를 나누지 않고 쿼리 하나를 고쳤다.** 프론트가 이미 이 값을 +**표시(`currentApplicants`)와 정원 판정은 같은 수를 쓴다.** 프론트가 이 값을 `seats: { total: capacity, taken: currentApplicants }` 로 그리고 있어 -(`frontend/apps/core-front/src/features/studies/model.ts`), 두 수가 갈리면 정원 20 에 `21/20` 이 찍힌다. -"N명 신청" 표시와 "정원 도달" 판정은 같은 수여야 한다. - -> 대기자(`WAITLISTED`) 를 **만드는** 로직은 이 스펙 범위 밖 — 이슈 50 (정원 관리·대기자). -> 여기서는 이미 그 상태인 행을 어떻게 셀지만 정한다. +두 수가 갈리면 정원 20 에 `21/20` 이 찍힌다. +"N명 참여 중" 표시와 "정원 도달" 판정은 같은 수여야 한다. --- @@ -108,7 +106,7 @@ recruitmentCapacity != null && 신청자수 >= recruitmentCapacity → RECRUIT_C | 필드 | 타입 | NULL | 설명 | 소스 | |------|------|------|------|------| | cohort.recruitStatus | String | Y | `RECRUITING` / `RECRUIT_CLOSED`. `status != OPEN` 이면 null | 계산: 위 [판정 규칙](#판정-규칙) | -| cohort.currentApplicants | long | N | 정원을 차지하는 신청자 수 | 계산: `STUDY_APPLICATION` 중 `STATUS IN (PENDING, APPROVED)` **(판정 기준 변경)** | +| cohort.currentApplicants | long | N | 현재 참여 중인 크루 수 (명부 ACTIVE + CAPACITY_ROLES) | 계산: `STUDY_PARTICIPANT.STATUS=ACTIVE AND ROLE IN (MEMBER, LEADER)` | ### Error Responses @@ -168,7 +166,7 @@ recruitmentCapacity != null && 신청자수 >= recruitmentCapacity → RECRUIT_C | 위치 | 덮는 것 | |---|---| | `domain/.../StudyCohortTest` | 판정 규칙 단위 — 마감 시각 정확히 now / 1초 전 / 경과, 정원 도달·초과, `recruitmentCapacity` null, `DRAFT`·`CLOSED` → null | -| `api/.../StudyRecruitStatusIntegrationTest` | 목록·상세 응답에 필드가 실제로 실리는지, `REJECTED`·`WITHDRAWN`·`WAITLISTED` 가 정원을 안 차지하는지 | +| `api/.../StudyRecruitStatusIntegrationTest` | 목록·상세 응답에 필드가 실제로 실리는지, 마감 시각·정원 도달·무제한 정원·`STATUS != OPEN` 케이스 종단 검증 (명부 WITHDRAWN 하차 후 빈자리 오픈 케이스는 `AdminStudyListIntegrationTest`·`AdminStudyQueryIntegrationTest` 참조) | ## 퍼널·계측 @@ -195,3 +193,4 @@ recruitmentCapacity != null && 신청자수 >= recruitmentCapacity → RECRUIT_C |---|---|---| | 2026-09-15 | 최초 작성 — `RecruitStatus` 파생 상태 + `countByStudyIds` 정원 판정 기준 수정 | Notion 이슈 51 | | 2026-09-22 | 상시 모집·`UPCOMING`·`STUDY.CAPACITY`·`IS_HIDDEN` 폐지, `status` 5단계로 확장 (제안 — 백엔드 미반영) | 스키마 정리 제안 | +| 2026-11-07 | 정원 판정 기준을 신청 건수 → 명부 ACTIVE 로 변경 (결정 D4-A). 하차 후 빈자리가 열린다 | P1-10 |