Skip to content
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.retry:spring-retry'
implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import moadong.club.payload.request.ClubApplicationFormEditRequest;
import moadong.club.service.ClubApplyAdminService;
import moadong.global.payload.Response;
import moadong.sse.service.ApplicantsStatusShareSse;
import moadong.user.annotation.CurrentUser;
import moadong.user.payload.CustomUserDetails;
import org.springframework.http.ResponseEntity;
Expand All @@ -31,6 +32,7 @@
public class ClubApplyAdminController {

private final ClubApplyAdminService clubApplyAdminService;
private final ApplicantsStatusShareSse sse;

@PostMapping("/application")
@Operation(summary = "클럽 지원서 양식 생성", description = "클럽 지원서 양식을 생성합니다")
Expand Down Expand Up @@ -116,17 +118,17 @@ public ResponseEntity<?> removeApplicant(@PathVariable String applicationFormId,
return Response.ok("success delete applicant");
}

@GetMapping(value = "/applicant/{applicationFormId}/events", produces = "text/event-stream")
@GetMapping(value = "/applicant/{applicationFormId}/sse", produces = "text/event-stream")
@Operation(summary = "지원자 상태 변경 실시간 이벤트",
description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.")
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public SseEmitter getApplicantStatusEvents(HttpServletResponse response,
@PathVariable String applicationFormId,
@PathVariable String applicationFormId,
@CurrentUser CustomUserDetails user) {
response.addHeader("X-Accel-Buffering", "no");
response.addHeader("Cache-Control", "no-cache");
return clubApplyAdminService.createSseConnection(applicationFormId, user);
return sse.createSseSession(applicationFormId, user);
}

}
188 changes: 38 additions & 150 deletions backend/src/main/java/moadong/club/service/ClubApplyAdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,17 @@
import moadong.global.exception.ErrorCode;
import moadong.global.exception.RestApiException;
import moadong.global.util.AESCipher;
import moadong.sse.service.ApplicantsStatusShareSse;
import moadong.user.payload.CustomUserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;

Expand All @@ -38,12 +36,7 @@ public class ClubApplyAdminService {
private final ClubApplicantsRepository clubApplicantsRepository;
private final AESCipher cipher;
private final ClubApplicationFormsRepositoryCustom clubApplicationFormsRepositoryCustom;

// SSE 연결 관리
private final Map<String, SseEmitter> sseConnections = new ConcurrentHashMap<>();

// SSE Emitter 타임아웃 (5분)
private static final long SSE_EMITTER_TIME_OUT = 300000L;
private final ApplicantsStatusShareSse applicantsStatusShareSse;

private record OptionItem(int year, SemesterTerm term) {
}
Expand Down Expand Up @@ -73,8 +66,7 @@ private List<OptionItem> buildOptionItems(LocalDate baseDate, int count) {
private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) {
LocalDate baseDate = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate();
List<OptionItem> items = buildOptionItems(baseDate, 3);
boolean allowed = items.stream()
.anyMatch(it -> it.year() == semesterYear && it.term() == semesterTerm);
boolean allowed = items.stream().anyMatch(it -> it.year() == semesterYear && it.term() == semesterTerm);
if (!allowed) {
throw new RestApiException(ErrorCode.APPLICATION_SEMESTER_INVALID);
}
Expand All @@ -84,28 +76,22 @@ private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) {
public void createClubApplicationForm(CustomUserDetails user, ClubApplicationFormCreateRequest request) {
validateSemester(request.semesterYear(), request.semesterTerm());

ClubApplicationForm clubApplicationForm = createApplicationForm(
ClubApplicationForm.builder()
.clubId(user.getClubId())
.build(),
request);
ClubApplicationForm clubApplicationForm = createApplicationForm(ClubApplicationForm.builder().clubId(user.getClubId()).build(), request);
clubApplicationFormsRepository.save(clubApplicationForm);
}

@Transactional
public void editClubApplication(String applicationFormId, CustomUserDetails user, ClubApplicationFormEditRequest request) {

ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));

clubApplicationForm.updateEditedAt();
clubApplicationFormsRepository.save(updateApplicationForm(clubApplicationForm, request));
}

@Transactional //test 사용
public void editClubApplicationQuestion(String applicationFormId, CustomUserDetails user, ClubApplicationFormEditRequest request) {
ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));

updateApplicationForm(clubApplicationForm, request);
clubApplicationForm.updateEditedAt();
Expand All @@ -114,23 +100,19 @@ public void editClubApplicationQuestion(String applicationFormId, CustomUserDeta
}

public ClubApplicationFormsResponse getClubApplicationForms(CustomUserDetails user) {
return ClubApplicationFormsResponse.builder()
.forms(clubApplicationFormsRepositoryCustom.findClubApplicationFormsByClubId(user.getClubId()))
.build();
return ClubApplicationFormsResponse.builder().forms(clubApplicationFormsRepositoryCustom.findClubApplicationFormsByClubId(user.getClubId())).build();
}

@Transactional
public void deleteClubApplicationForm(String applicationFormId, CustomUserDetails user) {
ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));

clubApplicantsRepository.deleteAllByFormId(applicationForm.getId());
clubApplicationFormsRepository.delete(applicationForm);
}

public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUserDetails user) {
ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));

List<ClubApplicant> submittedApplications = clubApplicantsRepository.findAllByFormId(applicationFormId);

Expand All @@ -150,23 +132,13 @@ public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUs
}
}

return ClubApplyInfoResponse.builder()
.total(applications.size())
.reviewRequired(reviewRequired)
.scheduledInterview(scheduledInterview)
.accepted(accepted)
.applicants(applications)
.build();
return ClubApplyInfoResponse.builder().total(applications.size()).reviewRequired(reviewRequired).scheduledInterview(scheduledInterview).accepted(accepted).applicants(applications).build();
}

private ClubApplicant sortApplicationAnswers(ClubApplicationForm application, ClubApplicant app) {
Map<Long, ClubQuestionAnswer> answerMap = app.getAnswers().stream()
.collect(Collectors.toMap(ClubQuestionAnswer::getId, answer -> answer));
Map<Long, ClubQuestionAnswer> answerMap = app.getAnswers().stream().collect(Collectors.toMap(ClubQuestionAnswer::getId, answer -> answer));

List<ClubQuestionAnswer> sortedAnswers = application.getQuestions().stream()
.map(question -> answerMap.get(question.getId()))
.filter(Objects::nonNull)
.collect(Collectors.toList());
List<ClubQuestionAnswer> sortedAnswers = application.getQuestions().stream().map(question -> answerMap.get(question.getId())).filter(Objects::nonNull).collect(Collectors.toList());

app.updateAnswers(sortedAnswers);
return app;
Expand All @@ -176,9 +148,7 @@ private ClubApplicant sortApplicationAnswers(ClubApplicationForm application, Cl
public void editApplicantDetail(String applicationFormId, List<ClubApplicantEditRequest> request, CustomUserDetails user) {
String clubId = user.getClubId();

Map<String, ClubApplicantEditRequest> requestMap = request.stream()
.collect(Collectors.toMap(ClubApplicantEditRequest::applicantId,
Function.identity(), (prev, next) -> next));
Map<String, ClubApplicantEditRequest> requestMap = request.stream().collect(Collectors.toMap(ClubApplicantEditRequest::applicantId, Function.identity(), (prev, next) -> next));

List<String> applicationIds = new ArrayList<>(requestMap.keySet());
List<ClubApplicant> application = clubApplicantsRepository.findAllByIdInAndFormId(applicationIds, applicationFormId);
Expand All @@ -187,27 +157,27 @@ public void editApplicantDetail(String applicationFormId, List<ClubApplicantEdit
throw new RestApiException(ErrorCode.APPLICANT_NOT_FOUND);
}

List<ApplicantStatusEvent> events = new ArrayList<>();

application.forEach(app -> {
ClubApplicantEditRequest editRequest = requestMap.get(app.getId());
app.updateMemo(editRequest.memo());
app.updateStatus(editRequest.status());

// SSE 이벤트 발송
ApplicantStatusEvent event = new ApplicantStatusEvent(
app.getId(),
editRequest.status(),
editRequest.memo(),
ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(),
clubId,
applicationFormId
);

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
CompletableFuture.runAsync(() -> sendStatusChangeEvent(clubId, applicationFormId, event));
}
});
events.add(new ApplicantStatusEvent(app.getId(), editRequest.status(), editRequest.memo(), ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(), clubId, applicationFormId));
});

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
events.forEach(event -> {
try {
applicantsStatusShareSse.publishStatusChangeEvent(clubId, applicationFormId, event);
} catch (Exception e) {
log.error("SSE publish failed. clubId={}, formId={}, applicantId={}", clubId, applicationFormId, event.applicantId(), e);
}
});
}
});

clubApplicantsRepository.saveAll(application);
Expand Down Expand Up @@ -241,14 +211,10 @@ private ClubApplicationForm createApplicationForm(ClubApplicationForm clubApplic
private ClubApplicationForm updateApplicationForm(ClubApplicationForm clubApplicationForm, ClubApplicationFormEditRequest request) {
if (request.questions() != null)
clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions()));
if (request.title() != null)
clubApplicationForm.updateFormTitle(request.title());
if (request.description() != null)
clubApplicationForm.updateFormDescription(request.description());
if (request.active() != null)
clubApplicationForm.updateFormStatus(request.active());
if (request.formMode() != null)
clubApplicationForm.updateFormMode(request.formMode());
if (request.title() != null) clubApplicationForm.updateFormTitle(request.title());
if (request.description() != null) clubApplicationForm.updateFormDescription(request.description());
if (request.active() != null) clubApplicationForm.updateFormStatus(request.active());
if (request.formMode() != null) clubApplicationForm.updateFormMode(request.formMode());
if (request.externalApplicationUrl() != null)
clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl());

Expand All @@ -271,98 +237,20 @@ private List<ClubApplicationFormQuestion> buildClubFormQuestions(List<ClubApplyQ
List<ClubQuestionItem> items = new ArrayList<>();

Set<ClubApplyQuestion.QuestionItem> distinctQuestionItemList = new HashSet<>(question.items());
if (distinctQuestionItemList.size() != question.items().size()) throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS);
if (distinctQuestionItemList.size() != question.items().size())
throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS);

for (var item : question.items()) {
items.add(ClubQuestionItem.builder()
.value(item.value())
.build());
items.add(ClubQuestionItem.builder().value(item.value()).build());
}

ClubQuestionOption options = ClubQuestionOption.builder()
.required(question.options().required())
.build();
ClubQuestionOption options = ClubQuestionOption.builder().required(question.options().required()).build();

ClubApplicationFormQuestion clubApplicationFormQuestion = ClubApplicationFormQuestion.builder()
.id(question.id())
.title(question.title())
.description(question.description())
.type(question.type())
.options(options)
.items(items)
.build();
ClubApplicationFormQuestion clubApplicationFormQuestion = ClubApplicationFormQuestion.builder().id(question.id()).title(question.title()).description(question.description()).type(question.type()).options(options).items(items).build();

formQuestions.add(clubApplicationFormQuestion);
}

return formQuestions;
}

// SSE 연결 생성
public SseEmitter createSseConnection(String applicationFormId, CustomUserDetails user) {
String clubId = user.getClubId();

clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));


String connectionKey = clubId + "_" + applicationFormId + "_" + user.getId();
SseEmitter emitter = new SseEmitter(SSE_EMITTER_TIME_OUT);

// 기존 연결이 있으면 먼저 맵에서 제거한 뒤 정리하여 race condition 방지
SseEmitter prev = sseConnections.remove(connectionKey);
if (prev != null) {
try {
prev.complete();
} catch (Exception ignored) {
}
}

sseConnections.put(connectionKey, emitter);

emitter.onCompletion(() -> sseConnections.remove(connectionKey, emitter));
emitter.onTimeout(() -> sseConnections.remove(connectionKey, emitter));
emitter.onError((ex) -> sseConnections.remove(connectionKey, emitter));

// 초기 핸드셰이크 이벤트 전송 (프록시/버퍼로 인한 지연 감소)
try {
emitter.send(SseEmitter.event().name("connected").data("ok"));
} catch (Exception e) {
sseConnections.remove(connectionKey, emitter);
emitter.completeWithError(e);
}

return emitter;
}

// 이벤트 발송
private void sendStatusChangeEvent(String clubId, String applicationFormId, ApplicantStatusEvent event) {
// 안전한 prefix (뒤에 "_" 추가)
String connectionKeyPrefix = clubId + "_" + applicationFormId + "_";

// 동시성 문제 방지: 스냅샷을 만들어서 순회
List<Map.Entry<String, SseEmitter>> entries = sseConnections.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(connectionKeyPrefix))
.collect(Collectors.toList());

entries.forEach(entry -> {
String key = entry.getKey();
SseEmitter emitter = entry.getValue();

try {
emitter.send(SseEmitter.event()
.name("applicant-status-changed") // 이벤트 이름 지정
.data(event)); // 실제 데이터
} catch (Exception e) {
log.warn("SSE 이벤트 발송 실패: {}", e.getMessage());
// 동일 인스턴스일 때만 제거하여 race condition 방지
sseConnections.remove(key, emitter);
try {
emitter.completeWithError(e); // emitter 쪽도 정상 종료
} catch (Exception ignore) {
}
}
});
}

}
Loading
Loading