From 5d4cdc984d69e660d695e075869ff38296fdb390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A4=80=ED=98=81?= Date: Mon, 13 Apr 2026 11:13:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?#168=20[Feat]=20=EA=B4=80=EC=A0=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=88=AC=ED=91=9C=20%=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20SSE=20=EA=B5=AC=EB=8F=85=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../vote/controller/VoteController.java | 26 +++++++ .../vote/service/BattleVoteServiceImpl.java | 4 + .../domain/vote/sse/SseEmitterRegistry.java | 77 +++++++++++++++++++ .../domain/vote/sse/VoteEventListener.java | 24 ++++++ .../domain/vote/sse/VoteUpdatedEvent.java | 4 + .../swyp/picke/global/config/AsyncConfig.java | 2 + 6 files changed, 137 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/vote/sse/SseEmitterRegistry.java create mode 100644 src/main/java/com/swyp/picke/domain/vote/sse/VoteEventListener.java create mode 100644 src/main/java/com/swyp/picke/domain/vote/sse/VoteUpdatedEvent.java diff --git a/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java b/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java index 0c864ca1..8cda6dc1 100644 --- a/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java +++ b/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java @@ -11,10 +11,14 @@ import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.domain.vote.service.PollVoteService; import com.swyp.picke.domain.vote.service.QuizVoteService; +import com.swyp.picke.domain.vote.sse.SseEmitterRegistry; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -24,7 +28,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +@Slf4j @Tag(name = "투표 API", description = "배틀/퀴즈/투표 투표 처리") @RestController @RequestMapping("/api/v1") @@ -34,6 +40,7 @@ public class VoteController { private final BattleVoteService battleVoteService; private final QuizVoteService quizVoteService; private final PollVoteService pollVoteService; + private final SseEmitterRegistry sseEmitterRegistry; @Operation(summary = "[퀴즈] 답안 제출") @PostMapping("/battles/{battleId}/quiz-vote") @@ -99,6 +106,25 @@ public ApiResponse getVoteStats(@PathVariable Long battleId) return ApiResponse.onSuccess(battleVoteService.getVoteStats(battleId)); } + @Operation(summary = "[배틀] 투표 통계 실시간 구독", description = "post 투표 완료 후 투표 % 실시간 업데이트를 SSE로 수신합니다. 페이지 이탈 시 클라이언트에서 EventSource.close()를 호출해야 합니다.") + @GetMapping(value = "/battles/{battleId}/vote-stats/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamVoteStats( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId + ) { + SseEmitter emitter = sseEmitterRegistry.register(battleId, userId); + + try { + VoteStatsResponse stats = battleVoteService.getVoteStats(battleId); + emitter.send(SseEmitter.event().name("vote-stats").data(stats)); + } catch (IOException e) { + log.warn("SSE 초기 데이터 전송 실패 - battleId: {}, userId: {}", battleId, userId); + sseEmitterRegistry.remove(battleId, userId); + } + + return emitter; + } + @Operation(summary = "[배틀] 내 투표 내역 조회", description = "특정 배틀에 대한 내 사전/사후 투표 내역과 현재 상태를 조회합니다.") @GetMapping("/battles/{battleId}/votes/me") public ApiResponse getMyVote( diff --git a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java index 74342d90..761e2d59 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java @@ -16,12 +16,14 @@ import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.domain.vote.sse.VoteUpdatedEvent; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +37,7 @@ public class BattleVoteServiceImpl implements BattleVoteService { private final BattleOptionRepository battleOptionRepository; private final UserRepository userRepository; private final UserBattleService userBattleService; + private final ApplicationEventPublisher eventPublisher; @Override public BattleOption findPreVoteOption(Long battleId, Long userId) { @@ -151,6 +154,7 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque vote.doPostVote(option); userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); + eventPublisher.publishEvent(new VoteUpdatedEvent(battleId)); return new VoteResultResponse(vote.getId(), UserBattleStep.COMPLETED); } diff --git a/src/main/java/com/swyp/picke/domain/vote/sse/SseEmitterRegistry.java b/src/main/java/com/swyp/picke/domain/vote/sse/SseEmitterRegistry.java new file mode 100644 index 00000000..40b0d503 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/sse/SseEmitterRegistry.java @@ -0,0 +1,77 @@ +package com.swyp.picke.domain.vote.sse; + +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +public class SseEmitterRegistry { + + private static final long TIMEOUT_MS = 180_000L; // 3분 + + // battleId -> (userId -> SseEmitter) + private final Map> emitters = new ConcurrentHashMap<>(); + + public SseEmitter register(Long battleId, Long userId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + + // 기존 연결이 있으면 먼저 제거 + remove(battleId, userId); + + emitters.computeIfAbsent(battleId, k -> new ConcurrentHashMap<>()) + .put(userId, emitter); + + emitter.onCompletion(() -> remove(battleId, userId)); + emitter.onTimeout(() -> remove(battleId, userId)); + emitter.onError(e -> remove(battleId, userId)); + + return emitter; + } + + public void remove(Long battleId, Long userId) { + Map battleEmitters = emitters.get(battleId); + if (battleEmitters == null) return; + + battleEmitters.remove(userId); + if (battleEmitters.isEmpty()) { + emitters.remove(battleId); + } + } + + public void sendToAll(Long battleId, VoteStatsResponse stats) { + Map battleEmitters = emitters.get(battleId); + if (battleEmitters == null || battleEmitters.isEmpty()) return; + + battleEmitters.forEach((userId, emitter) -> { + try { + emitter.send(SseEmitter.event() + .name("vote-stats") + .data(stats)); + } catch (IOException e) { + log.warn("SSE 전송 실패 - battleId: {}, userId: {}", battleId, userId); + remove(battleId, userId); + } + }); + } + + // nginx/프록시 idle 끊김 방지용 heartbeat (30초마다) + @Scheduled(fixedDelay = 30_000) + public void sendHeartbeat() { + emitters.forEach((battleId, battleEmitters) -> + battleEmitters.forEach((userId, emitter) -> { + try { + emitter.send(SseEmitter.event().comment("ping")); + } catch (IOException e) { + remove(battleId, userId); + } + }) + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/vote/sse/VoteEventListener.java b/src/main/java/com/swyp/picke/domain/vote/sse/VoteEventListener.java new file mode 100644 index 00000000..5e07835f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/sse/VoteEventListener.java @@ -0,0 +1,24 @@ +package com.swyp.picke.domain.vote.sse; + +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; +import com.swyp.picke.domain.vote.service.BattleVoteService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class VoteEventListener { + + private final BattleVoteService battleVoteService; + private final SseEmitterRegistry sseEmitterRegistry; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVoteUpdated(VoteUpdatedEvent event) { + VoteStatsResponse stats = battleVoteService.getVoteStats(event.battleId()); + sseEmitterRegistry.sendToAll(event.battleId(), stats); + } +} diff --git a/src/main/java/com/swyp/picke/domain/vote/sse/VoteUpdatedEvent.java b/src/main/java/com/swyp/picke/domain/vote/sse/VoteUpdatedEvent.java new file mode 100644 index 00000000..c7451e5c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/sse/VoteUpdatedEvent.java @@ -0,0 +1,4 @@ +package com.swyp.picke.domain.vote.sse; + +public record VoteUpdatedEvent(Long battleId) { +} diff --git a/src/main/java/com/swyp/picke/global/config/AsyncConfig.java b/src/main/java/com/swyp/picke/global/config/AsyncConfig.java index 9812e96a..baf99e07 100644 --- a/src/main/java/com/swyp/picke/global/config/AsyncConfig.java +++ b/src/main/java/com/swyp/picke/global/config/AsyncConfig.java @@ -2,8 +2,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @Configuration @EnableAsync +@EnableScheduling public class AsyncConfig { } From f5a47650afc0a7f5d2027995df9974812dfd146f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A4=80=ED=98=81?= Date: Mon, 13 Apr 2026 17:27:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?#168=20[Feat]=20=EB=B2=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20=EC=A3=BC=EA=B0=84=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EC=82=B0=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../battle/repository/BattleRepository.java | 1 + .../PerspectiveCommentRepository.java | 3 + .../scheduler/BestCommentScheduler.java | 68 +++++++++++++++++++ .../picke/domain/user/enums/CreditType.java | 2 +- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index d4d5dd31..51eb6031 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -56,6 +56,7 @@ public interface BattleRepository extends JpaRepository { // 6. 전체 배틀 목록 조회 Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); + List findByStatusAndDeletedAtIsNull(BattleStatus status); // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java index 0f0e0d30..b65fd2ac 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java @@ -22,4 +22,7 @@ public interface PerspectiveCommentRepository extends JpaRepository= :minLikeCount ORDER BY c.likeCount DESC") + List findTopCommentsByBattleId(@Param("battleId") Long battleId, @Param("minLikeCount") int minLikeCount, Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java b/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java new file mode 100644 index 00000000..ab7ff943 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.perspective.scheduler; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BestCommentScheduler { + + private static final int MIN_LIKE_COUNT = 10; + private static final int TOP_N = 3; + + private final BattleRepository battleRepository; + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final CreditService creditService; + + @Scheduled(cron = "0 0 0 * * MON") + public void awardBestComments() { + log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 시작"); + + List battles = battleRepository.findByStatusAndDeletedAtIsNull(BattleStatus.PUBLISHED); + + for (Battle battle : battles) { + try { + processBattle(battle.getId()); + } catch (Exception e) { + log.error("[BestCommentScheduler] battleId={} 처리 중 오류 발생: {}", battle.getId(), e.getMessage()); + } + } + + log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 완료"); + } + + @Transactional + public void processBattle(Long battleId) { + List topComments = perspectiveCommentRepository.findTopCommentsByBattleId( + battleId, + MIN_LIKE_COUNT, + PageRequest.of(0, TOP_N) + ); + + if (topComments.isEmpty()) { + return; + } + + for (PerspectiveComment comment : topComments) { + Long userId = comment.getUser().getId(); + Long commentId = comment.getId(); + + creditService.addCredit(userId, CreditType.BEST_COMMENT, commentId); + log.info("[BestCommentScheduler] 포인트 지급 - battleId={}, commentId={}, userId={}", battleId, commentId, userId); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java index 10f4639f..24da9951 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java @@ -7,7 +7,7 @@ public enum CreditType { BATTLE_VOTE(10), QUIZ_VOTE(5), MAJORITY_WIN(20), - BEST_COMMENT(100), + BEST_COMMENT(50), TOPIC_SUGGEST(30), TOPIC_ADOPTED(1000), AD_REWARD(50), From d8755653067cfcae8a1adf718eb4c5d3ea27d377 Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Tue, 14 Apr 2026 19:42:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FEAT]=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20API=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BestCommentSchedulerTestController.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java new file mode 100644 index 00000000..552c30c5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.scheduler.BestCommentScheduler; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "[Test] BestCommentScheduler", description = "스케줄러 테스트 API") +@RestController +@RequestMapping("/api/test/scheduler") +@RequiredArgsConstructor +public class BestCommentSchedulerTestController { + + private final BestCommentScheduler bestCommentScheduler; + + @Operation(summary = "베스트 댓글 정산 전체 실행", description = "PUBLISHED 상태 배틀 전체를 대상으로 베스트 댓글 포인트 정산을 즉시 실행합니다.") + @PostMapping("/best-comment") + public ApiResponse runAll() { + bestCommentScheduler.awardBestComments(); + return ApiResponse.onSuccess("베스트 댓글 정산 완료"); + } + + @Operation(summary = "베스트 댓글 정산 단건 실행", description = "특정 battleId에 대해서만 베스트 댓글 포인트 정산을 즉시 실행합니다.") + @PostMapping("/best-comment/battles/{battleId}") + public ApiResponse runByBattle(@PathVariable Long battleId) { + bestCommentScheduler.processBattle(battleId); + return ApiResponse.onSuccess("battleId=" + battleId + " 베스트 댓글 정산 완료"); + } +}