Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -24,8 +26,9 @@
import tools.jackson.databind.ObjectMapper;

/**
* 백오피스 스터디 목록 조립. DRAFT 포함 전 상태를 반환하고, 모집 상태는 최신 모집 회차의 <b>신청서 수</b>로 판정한다 — 공개 목록({@link
* StudyListService})·대시보드와 같다 (specs/dashboard/spec.md · study-recruit-status).
* 백오피스 스터디 목록 조립. DRAFT 포함 전 상태를 반환하고, 모집 상태는 최신 모집 회차의 <b>명부 ACTIVE 인원(CAPACITY_ROLES)</b>으로 판정한다
* — 공개 목록({@link StudyListService})·대시보드와 같다 (specs/dashboard/spec.md · study-recruit-status · 결정
* D4-A).
*/
@Service
@Transactional(readOnly = true)
Expand All @@ -34,21 +37,21 @@ 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;
private final ObjectMapper objectMapper;

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;
Expand Down Expand Up @@ -106,15 +109,12 @@ private List<BackofficeStudyListResponse.StudySummary> toSummaries(
Collectors.toMap(
StudyRecruitment::getStudyId, r -> r, (a, b) -> a));

List<Long> recruitmentIds =
latestRecruitments.values().stream().map(StudyRecruitment::getId).toList();
Map<Long, Long> 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<Long, Long> crewCounts =
studyParticipantRepository
.countCapacityHoldersByStudyIds(studyIds, ParticipantRole.CAPACITY_ROLES)
.stream()
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));

Map<Long, StudyProgram> programs =
studyProgramRepository
Expand Down Expand Up @@ -147,21 +147,26 @@ private List<BackofficeStudyListResponse.StudySummary> 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -52,21 +53,12 @@ public StudyListResponse list(StudyListFilter filter, int offset, int limit) {
List<StudyRecruitment> latestRecruitments =
studyRecruitmentRepository.findLatestByStudyIdIn(studyIds);

// 신청자 수는 최신 모집 회차의 STUDY_APPLICATION 수 — 스펙 study-recruit-status/spec.md:46
List<Long> recruitmentIds =
latestRecruitments.stream().map(StudyRecruitment::getId).toList();
Map<Long, Long> applicationsByRecruitmentId =
studyApplicationRepository.countByRecruitmentIdIn(recruitmentIds).stream()
// 정원을 차지하는 인원 — 명부 ACTIVE 중 정원 역할 (결정 D4-A, study-recruit-status/spec.md)
Map<Long, Long> crewCounts =
studyParticipantRepository
.countCapacityHoldersByStudyIds(studyIds, ParticipantRole.CAPACITY_ROLES)
.stream()
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
// studyId → 신청자 수 (최신 모집 회차 기준)
Map<Long, Long> applicants =
latestRecruitments.stream()
.collect(
Collectors.toMap(
StudyRecruitment::getStudyId,
r ->
applicationsByRecruitmentId.getOrDefault(
r.getId(), 0L)));

Map<Long, Instant> deadlines =
latestRecruitments.stream()
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -375,7 +376,7 @@ private StudyDetailResponse toDetail(Study study) {
() ->
new BusinessException(
ErrorCode.NOT_FOUND, "스터디 프로그램을 찾을 수 없습니다.")),
applicantCount(latestRecruitment),
crewCount(study),
recruitDeadlineAt,
recruitmentCapacity,
studyDiscordLinkRepository.existsByStudyId(study.getId()));
Expand Down Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
Loading
Loading