From b77129fdc4625bf2f0548ec8d53397cf0ff30a7a Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:45 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=A1=9D=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../domain/sharing/SharingLogStatus.java | 8 ++ .../entity/sharing/SharingLogEntity.java | 76 ++++++++++++++++ .../sharing/SharingLogRepository.java | 44 +++++++++ .../V17__create_sharing_log_table.sql | 14 +++ .../entity/sharing/SharingLogEntityTest.java | 90 +++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 src/main/java/com/debatetimer/domain/sharing/SharingLogStatus.java create mode 100644 src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java create mode 100644 src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java create mode 100644 src/main/resources/db/migration/V17__create_sharing_log_table.sql create mode 100644 src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java diff --git a/src/main/java/com/debatetimer/domain/sharing/SharingLogStatus.java b/src/main/java/com/debatetimer/domain/sharing/SharingLogStatus.java new file mode 100644 index 00000000..6abf7c50 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/sharing/SharingLogStatus.java @@ -0,0 +1,8 @@ +package com.debatetimer.domain.sharing; + +public enum SharingLogStatus { + + SHARING, + FINISHED, + ABANDONED, +} diff --git a/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java b/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java new file mode 100644 index 00000000..005fccbd --- /dev/null +++ b/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java @@ -0,0 +1,76 @@ +package com.debatetimer.entity.sharing; + +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 라이브 공유 한 번(사회자의 공유 시작부터 종료까지)을 기록한다. + */ +@Entity +@Getter +@Table(name = "sharing_log") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SharingLogEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "table_id") + private long tableId; + + @Column(name = "member_id") + private long memberId; + + @NotNull + private LocalDateTime startedAt; + + @Nullable + private LocalDateTime endedAt; + + private int audienceCount; + + @NotNull + @Enumerated(EnumType.STRING) + private SharingLogStatus status; + + public SharingLogEntity(long tableId, long memberId, LocalDateTime startedAt) { + this.tableId = tableId; + this.memberId = memberId; + this.startedAt = startedAt; + this.audienceCount = 0; + this.status = SharingLogStatus.SHARING; + } + + public void finish(LocalDateTime endedAt) { + this.status = SharingLogStatus.FINISHED; + this.endedAt = endedAt; + } + + public void abandon(LocalDateTime endedAt) { + this.status = SharingLogStatus.ABANDONED; + this.endedAt = endedAt; + } + + public long getDurationSeconds() { + if (endedAt == null) { + return 0; + } + return Math.max(Duration.between(startedAt, endedAt).toSeconds(), 0); + } +} diff --git a/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java new file mode 100644 index 00000000..730b542f --- /dev/null +++ b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java @@ -0,0 +1,44 @@ +package com.debatetimer.repository.sharing; + +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.entity.sharing.SharingLogEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +public interface SharingLogRepository extends Repository { + + SharingLogEntity save(SharingLogEntity sharingLogEntity); + + Optional findById(long id); + + List findAll(); + + List findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLessThan( + SharingLogStatus status, + LocalDateTime from, + LocalDateTime to + ); + + // 여러 청중이 동시에 입장해도 누락되지 않도록 DB에서 원자적으로 증가시킨다 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE SharingLogEntity s SET s.audienceCount = s.audienceCount + 1 WHERE s.id = :id") + void increaseAudienceCount(@Param("id") long id); + + // 서버 재시작 등으로 추적이 끊긴 공유는 종료 시각을 알 수 없으므로 마지막 수정 시각으로 종료 처리한다 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE SharingLogEntity s + SET s.status = :abandoned, s.endedAt = s.modifiedAt + WHERE s.status = :sharing AND s.startedAt < :threshold + """) + int abandonStaleSharings( + @Param("abandoned") SharingLogStatus abandoned, + @Param("sharing") SharingLogStatus sharing, + @Param("threshold") LocalDateTime threshold + ); +} diff --git a/src/main/resources/db/migration/V17__create_sharing_log_table.sql b/src/main/resources/db/migration/V17__create_sharing_log_table.sql new file mode 100644 index 00000000..f32933ef --- /dev/null +++ b/src/main/resources/db/migration/V17__create_sharing_log_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE sharing_log +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + table_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NULL, + audience_count INT NOT NULL DEFAULT 0, + status ENUM ('SHARING','FINISHED','ABANDONED') NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE INDEX idx_sharing_log_status_started_at ON sharing_log (status, started_at); diff --git a/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java b/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java new file mode 100644 index 00000000..4e019464 --- /dev/null +++ b/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java @@ -0,0 +1,90 @@ +package com.debatetimer.entity.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.sharing.SharingLogStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SharingLogEntityTest { + + private static final LocalDateTime STARTED_AT = LocalDateTime.of(2026, 10, 7, 10, 0, 0); + + @Nested + class Create { + + @Test + void 공유_기록은_청중_없이_진행_중_상태로_시작한다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + assertAll( + () -> assertThat(sharingLog.getStatus()).isEqualTo(SharingLogStatus.SHARING), + () -> assertThat(sharingLog.getAudienceCount()).isZero(), + () -> assertThat(sharingLog.getEndedAt()).isNull() + ); + } + } + + @Nested + class Finish { + + @Test + void 종료하면_종료_상태와_종료_시각을_기록한다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + sharingLog.finish(STARTED_AT.plusMinutes(30)); + + assertAll( + () -> assertThat(sharingLog.getStatus()).isEqualTo(SharingLogStatus.FINISHED), + () -> assertThat(sharingLog.getEndedAt()).isEqualTo(STARTED_AT.plusMinutes(30)) + ); + } + } + + @Nested + class Abandon { + + @Test + void 중단하면_중단_상태와_종료_시각을_기록한다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + sharingLog.abandon(STARTED_AT.plusMinutes(5)); + + assertAll( + () -> assertThat(sharingLog.getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> assertThat(sharingLog.getEndedAt()).isEqualTo(STARTED_AT.plusMinutes(5)) + ); + } + } + + @Nested + class GetDurationSeconds { + + @Test + void 시작부터_종료까지의_시간을_초_단위로_계산한다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + sharingLog.finish(STARTED_AT.plusMinutes(52).plusSeconds(7)); + + assertThat(sharingLog.getDurationSeconds()).isEqualTo(52 * 60 + 7); + } + + @Test + void 종료되지_않은_공유는_0초이다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + assertThat(sharingLog.getDurationSeconds()).isZero(); + } + + @Test + void 종료_시각이_시작_시각보다_앞서면_0초이다() { + SharingLogEntity sharingLog = new SharingLogEntity(1L, 2L, STARTED_AT); + + sharingLog.finish(STARTED_AT.minusSeconds(1)); + + assertThat(sharingLog.getDurationSeconds()).isZero(); + } + } +} From 8e98ed8635185e489d48dbf1e9135c6901cb59a4 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:45 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[FEAT]=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=9D=98=20=ED=83=80=EC=9E=84=EB=B0=95=EC=8A=A4=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=A1=B0=ED=9A=8C=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 Opus 5.5 --- .../customize/CustomizeTableDomainRepository.java | 5 +++++ .../repository/customize/CustomizeTimeBoxRepository.java | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java index 1f1c1e04..96e345fd 100644 --- a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java @@ -80,6 +80,11 @@ public long getTotalTimeBoxTimes(long tableId) { return timeBoxRepository.sumTimeByTableId(tableId); } + @Transactional(readOnly = true) + public int countTimeBoxes(long tableId) { + return timeBoxRepository.countByCustomizeTableId(tableId); + } + private List toCustomizeTimeBoxes( List timeBoxEntities, List bellEntities diff --git a/src/main/java/com/debatetimer/repository/customize/CustomizeTimeBoxRepository.java b/src/main/java/com/debatetimer/repository/customize/CustomizeTimeBoxRepository.java index 6a50ad9f..922a4b57 100644 --- a/src/main/java/com/debatetimer/repository/customize/CustomizeTimeBoxRepository.java +++ b/src/main/java/com/debatetimer/repository/customize/CustomizeTimeBoxRepository.java @@ -13,6 +13,8 @@ public interface CustomizeTimeBoxRepository extends Repository findAllByCustomizeTable(CustomizeTableEntity table); + int countByCustomizeTableId(long tableId); + // 시간 총량제 타임 박스는 time이 비어 있을 수 있으므로 양 팀 시간(팀당 시간 x 2)으로 대신 계산 @Query(""" SELECT COALESCE(SUM(COALESCE(ctb.time, ctb.timePerTeam * 2, 0)), 0) From c20f2bd100ebc53d326d1bf23f0e216bee1e1dbe Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:45 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[FEAT]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EB=A3=B8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../sharing/ChairmanSessionRegistry.java | 10 ++++++--- .../sharing/ChairmanSessionRegistryTest.java | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/debatetimer/service/sharing/ChairmanSessionRegistry.java b/src/main/java/com/debatetimer/service/sharing/ChairmanSessionRegistry.java index ce9476f3..cbed1d09 100644 --- a/src/main/java/com/debatetimer/service/sharing/ChairmanSessionRegistry.java +++ b/src/main/java/com/debatetimer/service/sharing/ChairmanSessionRegistry.java @@ -82,16 +82,20 @@ public synchronized Optional findLatestSessionId(long roomId) { /** * STOMP 연결이 끊기면 그 연결로 활성 사회자가 된 세션을 해제한다. * 재연결한 뒤에 옛 연결의 종료가 늦게 도착할 수 있어, 현재 연결과 일치할 때만 해제한다. + * + * @return 활성 사회자를 해제한 룸 */ - public synchronized void release(String simpSessionId) { + public synchronized Optional release(String simpSessionId) { Long roomId = roomIdsBySimpSession.remove(simpSessionId); if (roomId == null) { - return; + return Optional.empty(); } ChairmanHolder holder = holders.get(roomId); - if (holder != null && simpSessionId.equals(holder.simpSessionId())) { + if (holder != null && holder.isConnected() && simpSessionId.equals(holder.simpSessionId())) { holders.put(roomId, holder.released(clock.instant())); + return Optional.of(roomId); } + return Optional.empty(); } /** diff --git a/src/test/java/com/debatetimer/service/sharing/ChairmanSessionRegistryTest.java b/src/test/java/com/debatetimer/service/sharing/ChairmanSessionRegistryTest.java index b52b4d06..8a04f9d0 100644 --- a/src/test/java/com/debatetimer/service/sharing/ChairmanSessionRegistryTest.java +++ b/src/test/java/com/debatetimer/service/sharing/ChairmanSessionRegistryTest.java @@ -118,6 +118,28 @@ class Claim { @Nested class Release { + @Test + void 활성_사회자의_연결이_끊기면_해제한_룸을_돌려준다() { + chairmanSessionRegistry.claim(ROOM_ID, "tab-a", "simp-1"); + + assertThat(chairmanSessionRegistry.release("simp-1")).contains(ROOM_ID); + } + + @Test + void 사회자로_등록되지_않은_연결이_끊기면_해제한_룸이_없다() { + chairmanSessionRegistry.claim(ROOM_ID, "tab-a", "simp-1"); + + assertThat(chairmanSessionRegistry.release("audience-simp")).isEmpty(); + } + + @Test + void 재연결_전의_옛_연결이_늦게_끊기면_해제한_룸이_없다() { + chairmanSessionRegistry.claim(ROOM_ID, "tab-a", "simp-1"); + chairmanSessionRegistry.claim(ROOM_ID, "tab-a", "simp-2"); + + assertThat(chairmanSessionRegistry.release("simp-1")).isEmpty(); + } + @Test void 활성_사회자의_연결이_끊기면_룸에_활성_사회자가_없다() { chairmanSessionRegistry.claim(ROOM_ID, "tab-a", "simp-1"); From a1777b538de01b013f75812c0983ff3b1fe8cd87 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:45 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=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 Opus 5.5 --- .../event/sharing/SharingFinishedEvent.java | 19 + .../service/sharing/SharingLogService.java | 236 ++++++++++++ .../sharing/SharingLogServiceTest.java | 341 ++++++++++++++++++ 3 files changed, 596 insertions(+) create mode 100644 src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java create mode 100644 src/main/java/com/debatetimer/service/sharing/SharingLogService.java create mode 100644 src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java diff --git a/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java b/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java new file mode 100644 index 00000000..6dcc6b3c --- /dev/null +++ b/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java @@ -0,0 +1,19 @@ +package com.debatetimer.event.sharing; + +import java.time.LocalDateTime; + +/** + * 사회자가 공유 종료(FINISHED)를 명시적으로 발행해 공유 기록이 종료되었음을 알린다. + */ +public record SharingFinishedEvent( + long sharingLogId, + long memberId, + String memberEmail, + long tableId, + String tableName, + LocalDateTime startedAt, + LocalDateTime endedAt, + long durationSeconds, + int audienceCount +) { +} diff --git a/src/main/java/com/debatetimer/service/sharing/SharingLogService.java b/src/main/java/com/debatetimer/service/sharing/SharingLogService.java new file mode 100644 index 00000000..2fd16b67 --- /dev/null +++ b/src/main/java/com/debatetimer/service/sharing/SharingLogService.java @@ -0,0 +1,236 @@ +package com.debatetimer.service.sharing; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.domain.sharing.TimerEvent; +import com.debatetimer.domain.sharing.TimerEventData; +import com.debatetimer.domain.sharing.TimerEventType; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.event.sharing.SharingFinishedEvent; +import com.debatetimer.repository.sharing.SharingLogRepository; +import jakarta.annotation.Nullable; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 라이브 공유 한 번을 공유 기록 한 행으로 추적한다. + * - 사회자가 공유를 시작하면 기록을 만들고, 사회자가 종료(FINISHED)를 발행할 때까지 같은 기록을 쓴다. + * - 사회자 연결이 끊긴 뒤 재연결 유예 시간 안에 다시 공유를 시작하면 같은 기록을 이어서 쓴다. + * - 유예 시간이 지나도록 돌아오지 않으면, 마지막 타임박스까지 진행했을 때는 종료로, 아니면 중단으로 기록한다. + */ +@Service +public class SharingLogService { + + static final Duration RECONNECT_GRACE = Duration.ofMinutes(1); + static final Duration STALE_THRESHOLD = Duration.ofHours(12); + + private final Map activeSharings = new ConcurrentHashMap<>(); + private final SharingLogRepository sharingLogRepository; + private final CustomizeTableDomainRepository customizeTableDomainRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + @Autowired + public SharingLogService( + SharingLogRepository sharingLogRepository, + CustomizeTableDomainRepository customizeTableDomainRepository, + ApplicationEventPublisher eventPublisher + ) { + this(sharingLogRepository, customizeTableDomainRepository, eventPublisher, Clock.systemDefaultZone()); + } + + SharingLogService( + SharingLogRepository sharingLogRepository, + CustomizeTableDomainRepository customizeTableDomainRepository, + ApplicationEventPublisher eventPublisher, + Clock clock + ) { + this.sharingLogRepository = sharingLogRepository; + this.customizeTableDomainRepository = customizeTableDomainRepository; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + /** + * 진행 중인 기록이 있으면(재연결·다른 기기로의 이전) 이어서 쓰고, 없으면 새 기록을 만든다. + * 끊긴 지 유예 시간이 지난 기록은 정리한 뒤 새 기록을 만든다. + */ + @Transactional + public synchronized void start(long roomId) { + Instant now = clock.instant(); + ActiveSharing active = activeSharings.get(roomId); + if (active != null && !active.isReconnectExpired(now)) { + activeSharings.put(roomId, active.reconnected()); + return; + } + if (active != null) { + close(roomId, active, now); + } + + CustomizeTable table = customizeTableDomainRepository.getById(roomId); + int timeBoxCount = customizeTableDomainRepository.countTimeBoxes(roomId); + SharingLogEntity sharingLog = sharingLogRepository.save( + new SharingLogEntity(roomId, table.getMember().getId(), toLocalDateTime(now))); + activeSharings.put(roomId, new ActiveSharing( + sharingLog.getId(), + table.getMember().getEmail(), + table.getName(), + timeBoxCount, + now, + null, + null + )); + } + + /** + * 끊긴 공유가 마지막 타임박스까지 진행됐는지 판단할 수 있도록, 청중 화면에 표시될 타임박스 순서를 기록한다. + * 이전·다음 이벤트는 이동하기 전 순서를 담고 있어, 이동한 뒤 순서로 바꿔 기록한다. + */ + public void recordEvent(long roomId, TimerEvent timerEvent) { + TimerEventData data = timerEvent.getTimerEventData(); + if (data == null) { + return; + } + int displayedSequence = toDisplayedSequence(timerEvent.getEventType(), data.getSequence()); + activeSharings.computeIfPresent(roomId, (id, active) -> active.withSequence(displayedSequence)); + } + + @Transactional + public synchronized void finish(long roomId) { + ActiveSharing active = activeSharings.remove(roomId); + if (active == null) { + return; + } + SharingLogEntity sharingLog = sharingLogRepository.findById(active.logId()).orElse(null); + if (sharingLog == null) { + return; + } + sharingLog.finish(toLocalDateTime(clock.instant())); + eventPublisher.publishEvent(new SharingFinishedEvent( + sharingLog.getId(), + sharingLog.getMemberId(), + active.memberEmail(), + sharingLog.getTableId(), + active.tableName(), + sharingLog.getStartedAt(), + sharingLog.getEndedAt(), + sharingLog.getDurationSeconds(), + sharingLog.getAudienceCount() + )); + } + + /** + * 청중이 입장할 때마다 청중 수를 늘린다. 같은 청중의 재입장도 함께 센다. + */ + @Transactional + public void joinAudience(long roomId) { + ActiveSharing active = activeSharings.get(roomId); + if (active == null) { + return; + } + sharingLogRepository.increaseAudienceCount(active.logId()); + } + + public synchronized void disconnect(long roomId) { + Instant now = clock.instant(); + activeSharings.computeIfPresent(roomId, (id, active) -> active.disconnected(now)); + } + + /** + * 재연결 유예 시간이 지난 공유와 너무 오래 이어진 공유를 정리한다. + * 서버 재시작 등으로 추적하지 못하게 된 기록도 중단으로 정리한다. + */ + @Transactional + public synchronized void closeExpired() { + Instant now = clock.instant(); + List expiredRoomIds = activeSharings.entrySet().stream() + .filter(entry -> entry.getValue().isReconnectExpired(now) || entry.getValue().isStale(now)) + .map(Map.Entry::getKey) + .toList(); + expiredRoomIds.forEach(roomId -> close(roomId, activeSharings.get(roomId), now)); + + sharingLogRepository.abandonStaleSharings( + SharingLogStatus.ABANDONED, + SharingLogStatus.SHARING, + toLocalDateTime(now.minus(STALE_THRESHOLD)) + ); + } + + private void close(long roomId, ActiveSharing active, Instant now) { + activeSharings.remove(roomId, active); + SharingLogEntity sharingLog = sharingLogRepository.findById(active.logId()).orElse(null); + if (sharingLog == null) { + return; + } + LocalDateTime endedAt = toLocalDateTime(active.disconnectedAt() == null ? now : active.disconnectedAt()); + if (active.isOnLastTimeBox()) { + sharingLog.finish(endedAt); + return; + } + sharingLog.abandon(endedAt); + } + + private int toDisplayedSequence(TimerEventType eventType, int sequence) { + if (eventType == TimerEventType.NEXT) { + return sequence + 1; + } + if (eventType == TimerEventType.BEFORE) { + return Math.max(sequence - 1, 0); + } + return sequence; + } + + private LocalDateTime toLocalDateTime(Instant instant) { + return LocalDateTime.ofInstant(instant, clock.getZone()); + } + + private record ActiveSharing( + long logId, + String memberEmail, + String tableName, + int timeBoxCount, + Instant startedAt, + @Nullable Integer lastSequence, + @Nullable Instant disconnectedAt + ) { + + // 타임박스 순서는 0부터 시작한다 + private boolean isOnLastTimeBox() { + return lastSequence != null && lastSequence >= timeBoxCount - 1; + } + + private boolean isReconnectExpired(Instant now) { + return disconnectedAt != null && !now.isBefore(disconnectedAt.plus(RECONNECT_GRACE)); + } + + private boolean isStale(Instant now) { + return !now.isBefore(startedAt.plus(STALE_THRESHOLD)); + } + + private ActiveSharing withSequence(int sequence) { + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, sequence, + disconnectedAt); + } + + private ActiveSharing disconnected(Instant at) { + if (disconnectedAt != null) { + return this; + } + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, lastSequence, at); + } + + private ActiveSharing reconnected() { + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, lastSequence, null); + } + } +} diff --git a/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java b/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java new file mode 100644 index 00000000..32f2a803 --- /dev/null +++ b/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java @@ -0,0 +1,341 @@ +package com.debatetimer.service.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.domain.sharing.TimerEvent; +import com.debatetimer.domain.sharing.TimerEventData; +import com.debatetimer.domain.sharing.TimerEventType; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.event.sharing.SharingFinishedEvent; +import com.debatetimer.repository.sharing.SharingLogRepository; +import com.debatetimer.service.BaseServiceTest; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +class SharingLogServiceTest extends BaseServiceTest { + + private static final Instant NOW = Instant.parse("2026-10-07T10:00:00Z"); + private static final int TIME_BOX_COUNT = 3; + + @Autowired + private SharingLogRepository sharingLogRepository; + + @Autowired + private CustomizeTableDomainRepository customizeTableDomainRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + private MutableClock clock; + private ApplicationEventPublisher eventPublisher; + private SharingLogService sharingLogService; + private Member member; + private long roomId; + + @BeforeEach + void setUp() { + clock = new MutableClock(NOW); + eventPublisher = mock(ApplicationEventPublisher.class); + sharingLogService = transactional(new SharingLogService( + sharingLogRepository, customizeTableDomainRepository, eventPublisher, clock)); + + member = memberGenerator.generate("chairman@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + for (int sequence = 1; sequence <= TIME_BOX_COUNT; sequence++) { + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, sequence); + } + roomId = table.getId(); + } + + // 시각을 조정하기 위해 직접 만든 서비스에도 트랜잭션을 적용한다 + private SharingLogService transactional(SharingLogService target) { + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice(new TransactionInterceptor( + transactionManager, new AnnotationTransactionAttributeSource())); + return (SharingLogService) proxyFactory.getProxy(); + } + + private SharingLogEntity onlyLog() { + List logs = sharingLogRepository.findAll(); + assertThat(logs).hasSize(1); + return logs.get(0); + } + + private TimerEvent event(TimerEventType eventType, int sequence) { + TimerEventData data = new TimerEventData(CustomizeBoxType.NORMAL, sequence, null, 30, true, null, null); + return new TimerEvent(eventType, data); + } + + private LocalDateTime at(Instant instant) { + return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); + } + + @Nested + class Start { + + @Test + void 공유를_시작하면_진행_중인_기록을_만든다() { + sharingLogService.start(roomId); + + SharingLogEntity log = onlyLog(); + assertAll( + () -> assertThat(log.getTableId()).isEqualTo(roomId), + () -> assertThat(log.getMemberId()).isEqualTo(member.getId()), + () -> assertThat(log.getStartedAt()).isEqualTo(at(NOW)), + () -> assertThat(log.getEndedAt()).isNull(), + () -> assertThat(log.getAudienceCount()).isZero(), + () -> assertThat(log.getStatus()).isEqualTo(SharingLogStatus.SHARING) + ); + } + + @Test + void 진행_중인_공유를_다시_시작하면_같은_기록을_쓴다() { + sharingLogService.start(roomId); + + sharingLogService.start(roomId); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); + } + + @Test + void 연결이_끊긴_뒤_유예_시간_안에_다시_시작하면_같은_기록을_쓴다() { + sharingLogService.start(roomId); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE.minusSeconds(1)); + + sharingLogService.start(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); + } + + @Test + void 연결이_끊긴_뒤_유예_시간이_지나_다시_시작하면_이전_기록을_정리하고_새_기록을_만든다() { + sharingLogService.start(roomId); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + + sharingLogService.start(roomId); + + List logs = sharingLogRepository.findAll(); + assertThat(logs).extracting(SharingLogEntity::getStatus) + .containsExactly(SharingLogStatus.ABANDONED, SharingLogStatus.SHARING); + } + } + + @Nested + class Finish { + + @Test + void 종료하면_종료_시각과_함께_기록을_종료하고_종료_이벤트를_발행한다() { + sharingLogService.start(roomId); + sharingLogService.joinAudience(roomId); + sharingLogService.joinAudience(roomId); + clock.advance(Duration.ofMinutes(30)); + + sharingLogService.finish(roomId); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SharingFinishedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + SharingFinishedEvent event = captor.getValue(); + SharingLogEntity log = onlyLog(); + assertAll( + () -> assertThat(log.getStatus()).isEqualTo(SharingLogStatus.FINISHED), + () -> assertThat(log.getEndedAt()).isEqualTo(at(NOW.plus(Duration.ofMinutes(30)))), + () -> assertThat(event.memberEmail()).isEqualTo("chairman@email.com"), + () -> assertThat(event.tableId()).isEqualTo(roomId), + () -> assertThat(event.durationSeconds()).isEqualTo(30 * 60), + () -> assertThat(event.audienceCount()).isEqualTo(2) + ); + } + + @Test + void 종료한_뒤_다시_시작하면_새_기록을_만든다() { + sharingLogService.start(roomId); + sharingLogService.finish(roomId); + + sharingLogService.start(roomId); + + assertThat(sharingLogRepository.findAll()).extracting(SharingLogEntity::getStatus) + .containsExactly(SharingLogStatus.FINISHED, SharingLogStatus.SHARING); + } + + @Test + void 진행_중인_공유가_없으면_종료_이벤트를_발행하지_않는다() { + sharingLogService.finish(roomId); + + verify(eventPublisher, never()).publishEvent(any(Object.class)); + } + } + + @Nested + class JoinAudience { + + @Test + void 청중이_입장할_때마다_청중_수를_늘린다() { + sharingLogService.start(roomId); + + sharingLogService.joinAudience(roomId); + sharingLogService.joinAudience(roomId); + sharingLogService.joinAudience(roomId); + + assertThat(onlyLog().getAudienceCount()).isEqualTo(3); + } + + @Test + void 진행_중인_공유가_없으면_기록하지_않는다() { + sharingLogService.joinAudience(roomId); + + assertThat(sharingLogRepository.findAll()).isEmpty(); + } + } + + @Nested + class CloseExpired { + + @Test + void 마지막_타임박스에서_연결이_끊긴_뒤_돌아오지_않으면_끊긴_시각으로_종료한다() { + sharingLogService.start(roomId); + sharingLogService.recordEvent(roomId, event(TimerEventType.PLAY, TIME_BOX_COUNT - 1)); + clock.advance(Duration.ofMinutes(20)); + Instant disconnectedAt = clock.instant(); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + + sharingLogService.closeExpired(); + + SharingLogEntity log = onlyLog(); + assertAll( + () -> assertThat(log.getStatus()).isEqualTo(SharingLogStatus.FINISHED), + () -> assertThat(log.getEndedAt()).isEqualTo(at(disconnectedAt)), + () -> verify(eventPublisher, never()).publishEvent(any(Object.class)) + ); + } + + @Test + void 다음_이벤트는_이동한_뒤_순서로_마지막_타임박스를_판단한다() { + sharingLogService.start(roomId); + sharingLogService.recordEvent(roomId, event(TimerEventType.NEXT, TIME_BOX_COUNT - 2)); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.FINISHED); + } + + @Test + void 이전_이벤트는_이동한_뒤_순서로_마지막_타임박스를_판단한다() { + sharingLogService.start(roomId); + sharingLogService.recordEvent(roomId, event(TimerEventType.BEFORE, TIME_BOX_COUNT - 1)); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED); + } + + @Test + void 마지막_타임박스_전에_연결이_끊긴_뒤_돌아오지_않으면_중단으로_기록한다() { + sharingLogService.start(roomId); + sharingLogService.recordEvent(roomId, event(TimerEventType.PLAY, 0)); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE); + + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED); + } + + @Test + void 유예_시간이_지나지_않았으면_정리하지_않는다() { + sharingLogService.start(roomId); + sharingLogService.disconnect(roomId); + clock.advance(SharingLogService.RECONNECT_GRACE.minusSeconds(1)); + + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); + } + + @Test + void 연결이_유지돼도_너무_오래_이어진_공유는_정리한다() { + sharingLogService.start(roomId); + clock.advance(SharingLogService.STALE_THRESHOLD); + + sharingLogService.closeExpired(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED); + } + + @Test + void 추적하지_못하게_된_오래된_기록은_중단으로_정리한다() { + LocalDateTime startedAt = at(NOW.minus(SharingLogService.STALE_THRESHOLD).minusSeconds(1)); + sharingLogRepository.save(new SharingLogEntity(roomId, member.getId(), startedAt)); + + sharingLogService.closeExpired(); + + SharingLogEntity log = onlyLog(); + assertAll( + () -> assertThat(log.getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> assertThat(log.getEndedAt()).isNotNull() + ); + } + } + + private static class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} From 9585e94c38ce5a7e66746b9fbd52d1a0e37a97f7 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=ED=9D=90=EB=A6=84=EC=97=90=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EA=B8=B0=EB=A1=9D=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../SharingRoomCleanupScheduler.java | 8 ++ .../service/sharing/SharingService.java | 33 +++++- .../service/sharing/SharingServiceTest.java | 106 +++++++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java index 42c3f7bf..e42764ac 100644 --- a/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java +++ b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java @@ -1,6 +1,7 @@ package com.debatetimer.scheduler; import com.debatetimer.service.sharing.ChairmanSessionRegistry; +import com.debatetimer.service.sharing.SharingLogService; import com.debatetimer.service.sharing.SharingRoomRegistry; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; @@ -11,13 +12,20 @@ public class SharingRoomCleanupScheduler { private static final long INTERVAL_MILLIS = 60 * 60 * 1000L; + private static final long SHARING_LOG_INTERVAL_MILLIS = 60 * 1000L; private final SharingRoomRegistry sharingRoomRegistry; private final ChairmanSessionRegistry chairmanSessionRegistry; + private final SharingLogService sharingLogService; @Scheduled(fixedRate = INTERVAL_MILLIS) public void cleanupExpiredRooms() { sharingRoomRegistry.removeExpired(); chairmanSessionRegistry.removeExpired(); } + + @Scheduled(fixedRate = SHARING_LOG_INTERVAL_MILLIS) + public void closeExpiredSharingLogs() { + sharingLogService.closeExpired(); + } } diff --git a/src/main/java/com/debatetimer/service/sharing/SharingService.java b/src/main/java/com/debatetimer/service/sharing/SharingService.java index 3bfedf20..6e28d70b 100644 --- a/src/main/java/com/debatetimer/service/sharing/SharingService.java +++ b/src/main/java/com/debatetimer/service/sharing/SharingService.java @@ -8,10 +8,12 @@ import com.debatetimer.dto.sharing.response.TimerEventDataResponse; import java.time.Clock; import java.util.Optional; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; +@Slf4j @Service public class SharingService { @@ -20,6 +22,7 @@ public class SharingService { private final SharingRoomRegistry sharingRoomRegistry; private final ChairmanSessionRegistry chairmanSessionRegistry; + private final SharingLogService sharingLogService; private final SimpMessageSendingOperations messagingTemplate; private final Clock clock; @@ -27,19 +30,22 @@ public class SharingService { public SharingService( SharingRoomRegistry sharingRoomRegistry, ChairmanSessionRegistry chairmanSessionRegistry, + SharingLogService sharingLogService, SimpMessageSendingOperations messagingTemplate ) { - this(sharingRoomRegistry, chairmanSessionRegistry, messagingTemplate, Clock.systemUTC()); + this(sharingRoomRegistry, chairmanSessionRegistry, sharingLogService, messagingTemplate, Clock.systemUTC()); } SharingService( SharingRoomRegistry sharingRoomRegistry, ChairmanSessionRegistry chairmanSessionRegistry, + SharingLogService sharingLogService, SimpMessageSendingOperations messagingTemplate, Clock clock ) { this.sharingRoomRegistry = sharingRoomRegistry; this.chairmanSessionRegistry = chairmanSessionRegistry; + this.sharingLogService = sharingLogService; this.messagingTemplate = messagingTemplate; this.clock = clock; } @@ -65,6 +71,7 @@ public void share(long roomId, String chairmanSessionId, SharingRequest request) updateRoomStatus(roomId, timerEvent.getEventType()); messagingTemplate.convertAndSend(ROOM_CHANNEL_PREFIX + roomId, createResponse(request, timerEvent)); + recordSharingLog(roomId, timerEvent); }); } @@ -77,6 +84,7 @@ public void startChairman(long roomId, String chairmanSessionId, String simpSess if (claimChairman(roomId, chairmanSessionId, simpSessionId).isAccepted()) { sharingRoomRegistry.reopen(roomId); sharingRoomRegistry.resetSyncRequest(roomId); + runSharingLog(() -> sharingLogService.start(roomId)); } }); } @@ -94,6 +102,7 @@ public void joinAudience(long roomId) { messagingTemplate.convertAndSend(ROOM_CHANNEL_PREFIX + roomId, new SharingResponse(TimerEventType.FINISHED)); return; } + runSharingLog(() -> sharingLogService.joinAudience(roomId)); if (!chairmanSessionRegistry.hasActiveChairman(roomId)) { messagingTemplate.convertAndSend(ROOM_CHANNEL_PREFIX + roomId, new SharingResponse(TimerEventType.CHAIRMAN_ABSENT)); @@ -106,7 +115,8 @@ public void joinAudience(long roomId) { } public void leave(String simpSessionId) { - chairmanSessionRegistry.release(simpSessionId); + chairmanSessionRegistry.release(simpSessionId) + .ifPresent(roomId -> runSharingLog(() -> sharingLogService.disconnect(roomId))); } /** @@ -139,6 +149,25 @@ private SharingResponse createResponse(SharingRequest request, TimerEvent timerE return new SharingResponse(request.eventType(), request.version(), clock.millis(), data); } + private void recordSharingLog(long roomId, TimerEvent timerEvent) { + if (timerEvent.getEventType() == TimerEventType.FINISHED) { + runSharingLog(() -> sharingLogService.finish(roomId)); + return; + } + runSharingLog(() -> sharingLogService.recordEvent(roomId, timerEvent)); + } + + /** + * 공유 기록은 통계용이므로, 기록에 실패해도 공유 중계는 계속한다. + */ + private void runSharingLog(Runnable action) { + try { + action.run(); + } catch (RuntimeException exception) { + log.warn("공유 기록에 실패했습니다", exception); + } + } + private void updateRoomStatus(long roomId, TimerEventType eventType) { if (eventType == TimerEventType.FINISHED) { sharingRoomRegistry.markFinished(roomId); diff --git a/src/test/java/com/debatetimer/service/sharing/SharingServiceTest.java b/src/test/java/com/debatetimer/service/sharing/SharingServiceTest.java index a52efbe9..2e3d5e74 100644 --- a/src/test/java/com/debatetimer/service/sharing/SharingServiceTest.java +++ b/src/test/java/com/debatetimer/service/sharing/SharingServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -42,6 +43,7 @@ class SharingServiceTest { private MutableClock clock; private SharingRoomRegistry sharingRoomRegistry; private ChairmanSessionRegistry chairmanSessionRegistry; + private SharingLogService sharingLogService; private SimpMessageSendingOperations messagingTemplate; private SharingService sharingService; @@ -51,7 +53,9 @@ void setUp() { sharingRoomRegistry = new SharingRoomRegistry(clock); chairmanSessionRegistry = new ChairmanSessionRegistry(clock); messagingTemplate = mock(SimpMessageSendingOperations.class); - sharingService = new SharingService(sharingRoomRegistry, chairmanSessionRegistry, messagingTemplate, clock); + sharingLogService = mock(SharingLogService.class); + sharingService = new SharingService( + sharingRoomRegistry, chairmanSessionRegistry, sharingLogService, messagingTemplate, clock); } private List sentResponses(int expectedCount) { @@ -322,6 +326,106 @@ class JoinAudience { } } + @Nested + class SharingLog { + + @Test + void 사회자가_공유를_시작하면_공유_기록을_시작한다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + verify(sharingLogService).start(ROOM_ID); + } + + @Test + void 밀려난_사회자가_다시_공유를_시작하면_공유_기록을_시작하지_않는다() { + sharingService.startChairman(ROOM_ID, "tab-a", "simp-1"); + sharingService.startChairman(ROOM_ID, "tab-b", "simp-2"); + + sharingService.startChairman(ROOM_ID, "tab-a", "simp-3"); + + verify(sharingLogService, times(2)).start(ROOM_ID); + } + + @Test + void 종료_이벤트를_중계하면_공유_기록을_종료한다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + sharingService.share(ROOM_ID, CHAIRMAN, new SharingRequest(TimerEventType.FINISHED, 1L, null)); + + verify(sharingLogService).finish(ROOM_ID); + } + + @Test + void 종료가_아닌_이벤트를_중계하면_진행_순서를_기록한다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + sharingService.share(ROOM_ID, CHAIRMAN, normalEvent(TimerEventType.NEXT, 1L)); + + assertAll( + () -> verify(sharingLogService).recordEvent(eq(ROOM_ID), any()), + () -> verify(sharingLogService, never()).finish(ROOM_ID) + ); + } + + @Test + void 중계하지_않은_이벤트는_기록하지_않는다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + sharingService.share(ROOM_ID, CHAIRMAN, normalEvent(TimerEventType.PLAY, 2L)); + + sharingService.share(ROOM_ID, CHAIRMAN, new SharingRequest(TimerEventType.FINISHED, 1L, null)); + + verify(sharingLogService, never()).finish(ROOM_ID); + } + + @Test + void 공유_기록에_실패해도_이벤트를_중계한다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + doThrow(new IllegalStateException("db down")) + .when(sharingLogService).finish(ROOM_ID); + + sharingService.share(ROOM_ID, CHAIRMAN, new SharingRequest(TimerEventType.FINISHED, 1L, null)); + + assertThat(sentResponses(1)).extracting(SharingResponse::eventType) + .containsExactly(TimerEventType.FINISHED); + } + + @Test + void 청중이_입장하면_청중_수를_기록한다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + sharingService.joinAudience(ROOM_ID); + + verify(sharingLogService).joinAudience(ROOM_ID); + } + + @Test + void 종료된_룸에_입장한_청중은_기록하지_않는다() { + sharingRoomRegistry.markFinished(ROOM_ID); + + sharingService.joinAudience(ROOM_ID); + + verify(sharingLogService, never()).joinAudience(ROOM_ID); + } + + @Test + void 활성_사회자의_연결이_끊기면_공유_기록에_알린다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + sharingService.leave(SIMP_SESSION); + + verify(sharingLogService).disconnect(ROOM_ID); + } + + @Test + void 사회자가_아닌_연결이_끊기면_공유_기록에_알리지_않는다() { + sharingService.startChairman(ROOM_ID, CHAIRMAN, SIMP_SESSION); + + sharingService.leave("audience-simp"); + + verify(sharingLogService, never()).disconnect(ROOM_ID); + } + } + private static class MutableClock extends Clock { private Instant instant; From 401b0e0704088caafa816131cc9ba0cc6a449055 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:53 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=EB=94=94?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../notifier/ConsoleSharingNotifier.java | 13 ++++++ .../client/notifier/DiscordNotifier.java | 17 +------- .../client/notifier/DiscordProperties.java | 5 ++- .../notifier/DiscordSharingNotifier.java | 24 +++++++++++ .../notifier/SharingFinishedMessage.java | 37 ++++++++++++++++ .../client/notifier/SharingNotifier.java | 8 ++++ .../com/debatetimer/config/AsyncConfig.java | 14 +++++++ .../debatetimer/config/NotifierConfig.java | 31 +++++++++++++- .../sharing/SharingFinishedListener.java | 31 ++++++++++++++ src/main/resources/application-dev.yml | 1 + src/main/resources/application-prod.yml | 1 + .../client/DiscordPropertiesTest.java | 13 +++++- .../notifier/DiscordSharingNotifierTest.java | 34 +++++++++++++++ .../notifier/SharingFinishedMessageTest.java | 38 +++++++++++++++++ .../sharing/SharingFinishedListenerTest.java | 42 +++++++++++++++++++ 15 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/debatetimer/client/notifier/ConsoleSharingNotifier.java create mode 100644 src/main/java/com/debatetimer/client/notifier/DiscordSharingNotifier.java create mode 100644 src/main/java/com/debatetimer/client/notifier/SharingFinishedMessage.java create mode 100644 src/main/java/com/debatetimer/client/notifier/SharingNotifier.java create mode 100644 src/main/java/com/debatetimer/config/AsyncConfig.java create mode 100644 src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java create mode 100644 src/test/java/com/debatetimer/client/notifier/DiscordSharingNotifierTest.java create mode 100644 src/test/java/com/debatetimer/client/notifier/SharingFinishedMessageTest.java create mode 100644 src/test/java/com/debatetimer/event/sharing/SharingFinishedListenerTest.java diff --git a/src/main/java/com/debatetimer/client/notifier/ConsoleSharingNotifier.java b/src/main/java/com/debatetimer/client/notifier/ConsoleSharingNotifier.java new file mode 100644 index 00000000..d9f8dcf0 --- /dev/null +++ b/src/main/java/com/debatetimer/client/notifier/ConsoleSharingNotifier.java @@ -0,0 +1,13 @@ +package com.debatetimer.client.notifier; + +import com.debatetimer.event.sharing.SharingFinishedEvent; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ConsoleSharingNotifier implements SharingNotifier { + + @Override + public void sendSharingFinished(SharingFinishedEvent event) { + log.info("공유 종료 정보가 채널로 발송되었습니다\n{}", SharingFinishedMessage.from(event)); + } +} diff --git a/src/main/java/com/debatetimer/client/notifier/DiscordNotifier.java b/src/main/java/com/debatetimer/client/notifier/DiscordNotifier.java index 0597deca..f850a388 100644 --- a/src/main/java/com/debatetimer/client/notifier/DiscordNotifier.java +++ b/src/main/java/com/debatetimer/client/notifier/DiscordNotifier.java @@ -1,13 +1,10 @@ package com.debatetimer.client.notifier; -import com.debatetimer.exception.custom.DTInitializationException; -import com.debatetimer.exception.errorcode.InitializationErrorCode; import io.micrometer.core.annotation.Timed; import java.util.Arrays; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; @Slf4j @@ -21,18 +18,9 @@ public class DiscordNotifier implements ErrorNotifier { private final DiscordProperties properties; private final JDA jda; - public DiscordNotifier(DiscordProperties discordProperties) { + public DiscordNotifier(DiscordProperties discordProperties, JDA jda) { this.properties = discordProperties; - this.jda = initializeJda(properties.getToken()); - } - - private JDA initializeJda(String token) { - try { - return JDABuilder.createDefault(token).build().awaitReady(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new DTInitializationException(InitializationErrorCode.JDA_INITIALIZATION_FAIL); - } + this.jda = jda; } @Timed(value = "discord.send_error_message") @@ -56,4 +44,3 @@ private String getStackTraceAsString(Throwable throwable) { .collect(Collectors.joining(DISCORD_LINE_SEPARATOR)); } } - diff --git a/src/main/java/com/debatetimer/client/notifier/DiscordProperties.java b/src/main/java/com/debatetimer/client/notifier/DiscordProperties.java index 0012e83e..88fdc859 100644 --- a/src/main/java/com/debatetimer/client/notifier/DiscordProperties.java +++ b/src/main/java/com/debatetimer/client/notifier/DiscordProperties.java @@ -11,12 +11,15 @@ public class DiscordProperties { private final String token; private final String channelId; + private final String sharingChannelId; - public DiscordProperties(String token, String channelId) { + public DiscordProperties(String token, String channelId, String sharingChannelId) { validate(token); validate(channelId); + validate(sharingChannelId); this.token = token; this.channelId = channelId; + this.sharingChannelId = sharingChannelId; } private void validate(String element) { diff --git a/src/main/java/com/debatetimer/client/notifier/DiscordSharingNotifier.java b/src/main/java/com/debatetimer/client/notifier/DiscordSharingNotifier.java new file mode 100644 index 00000000..b0e6efe0 --- /dev/null +++ b/src/main/java/com/debatetimer/client/notifier/DiscordSharingNotifier.java @@ -0,0 +1,24 @@ +package com.debatetimer.client.notifier; + +import com.debatetimer.event.sharing.SharingFinishedEvent; +import io.micrometer.core.annotation.Timed; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +public class DiscordSharingNotifier implements SharingNotifier { + + private final DiscordProperties properties; + private final JDA jda; + + public DiscordSharingNotifier(DiscordProperties properties, JDA jda) { + this.properties = properties; + this.jda = jda; + } + + @Timed(value = "discord.send_sharing_finished") + @Override + public void sendSharingFinished(SharingFinishedEvent event) { + TextChannel channel = jda.getTextChannelById(properties.getSharingChannelId()); + channel.sendMessage(SharingFinishedMessage.from(event)).queue(); + } +} diff --git a/src/main/java/com/debatetimer/client/notifier/SharingFinishedMessage.java b/src/main/java/com/debatetimer/client/notifier/SharingFinishedMessage.java new file mode 100644 index 00000000..90289531 --- /dev/null +++ b/src/main/java/com/debatetimer/client/notifier/SharingFinishedMessage.java @@ -0,0 +1,37 @@ +package com.debatetimer.client.notifier; + +import com.debatetimer.event.sharing.SharingFinishedEvent; +import java.time.format.DateTimeFormatter; + +final class SharingFinishedMessage { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + // 공유 기록 id는 공유를 시작할 때마다 하나씩 늘어나므로 누적 공유 횟수로 보여준다 + private static final String TEMPLATE = """ + ## 🎉 누적 **%,d**번째 라이브 공유 종료! + > 👤 **사회자** `%d` · %s + > 📋 **테이블** `%d` · %s + > ⏱️ **공유 시간** **%d분 %d초** + > 🕐 **진행** %s → %s + > 👥 **참여 청중** **%d명**"""; + + private SharingFinishedMessage() { + } + + static String from(SharingFinishedEvent event) { + return TEMPLATE.formatted( + event.sharingLogId(), + event.memberId(), + event.memberEmail(), + event.tableId(), + event.tableName(), + event.durationSeconds() / 60, + event.durationSeconds() % 60, + event.startedAt().format(DATE_TIME_FORMATTER), + event.endedAt().format(TIME_FORMATTER), + event.audienceCount() + ); + } +} diff --git a/src/main/java/com/debatetimer/client/notifier/SharingNotifier.java b/src/main/java/com/debatetimer/client/notifier/SharingNotifier.java new file mode 100644 index 00000000..dc655553 --- /dev/null +++ b/src/main/java/com/debatetimer/client/notifier/SharingNotifier.java @@ -0,0 +1,8 @@ +package com.debatetimer.client.notifier; + +import com.debatetimer.event.sharing.SharingFinishedEvent; + +public interface SharingNotifier { + + void sendSharingFinished(SharingFinishedEvent event); +} diff --git a/src/main/java/com/debatetimer/config/AsyncConfig.java b/src/main/java/com/debatetimer/config/AsyncConfig.java new file mode 100644 index 00000000..e1568d64 --- /dev/null +++ b/src/main/java/com/debatetimer/config/AsyncConfig.java @@ -0,0 +1,14 @@ +package com.debatetimer.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * 비동기 작업은 Spring Boot가 구성하는 기본 TaskExecutor(applicationTaskExecutor)에서 실행한다. + * Executor 빈을 따로 등록하면 기본 TaskExecutor 구성이 빠지므로 직접 등록하지 않는다. + */ +@Configuration +@EnableAsync +public class AsyncConfig { + +} diff --git a/src/main/java/com/debatetimer/config/NotifierConfig.java b/src/main/java/com/debatetimer/config/NotifierConfig.java index 04511cf7..54d98f55 100644 --- a/src/main/java/com/debatetimer/config/NotifierConfig.java +++ b/src/main/java/com/debatetimer/config/NotifierConfig.java @@ -2,11 +2,18 @@ import com.debatetimer.client.notifier.ConsoleNotifier; +import com.debatetimer.client.notifier.ConsoleSharingNotifier; import com.debatetimer.client.notifier.DiscordNotifier; import com.debatetimer.client.notifier.DiscordProperties; +import com.debatetimer.client.notifier.DiscordSharingNotifier; import com.debatetimer.client.notifier.ErrorNotifier; +import com.debatetimer.client.notifier.SharingNotifier; +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,9 +30,24 @@ public static class DiscordNotifierConfig { private final DiscordProperties discordProperties; + @Bean(destroyMethod = "shutdown") + public JDA jda() { + try { + return JDABuilder.createDefault(discordProperties.getToken()).build().awaitReady(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new DTInitializationException(InitializationErrorCode.JDA_INITIALIZATION_FAIL); + } + } + + @Bean + public ErrorNotifier discordNotifier(JDA jda) { + return new DiscordNotifier(discordProperties, jda); + } + @Bean - public ErrorNotifier discordNotifier() { - return new DiscordNotifier(discordProperties); + public SharingNotifier discordSharingNotifier(JDA jda) { + return new DiscordSharingNotifier(discordProperties, jda); } } @@ -37,5 +59,10 @@ public static class ConsoleNotifierConfig { public ErrorNotifier consoleNotifier() { return new ConsoleNotifier(); } + + @Bean + public SharingNotifier consoleSharingNotifier() { + return new ConsoleSharingNotifier(); + } } } diff --git a/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java b/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java new file mode 100644 index 00000000..4794f393 --- /dev/null +++ b/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java @@ -0,0 +1,31 @@ +package com.debatetimer.event.sharing; + +import com.debatetimer.client.notifier.SharingNotifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 공유 종료 기록이 저장된 뒤에 공유 통계를 알린다. + * 알림은 공유 중계와 무관하므로 별도 스레드에서 보내고, 실패해도 기록만 남긴다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SharingFinishedListener { + + private final SharingNotifier sharingNotifier; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleSharingFinished(SharingFinishedEvent event) { + try { + sharingNotifier.sendSharingFinished(event); + } catch (RuntimeException exception) { + log.warn("공유 종료 알림 발송에 실패했습니다. sharingLogId={}", event.sharingLogId(), exception); + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 46ebc8ed..e2447dc2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -38,6 +38,7 @@ jwt: discord: token: ${secret.discord.token} channelId: ${secret.discord.channelId} + sharingChannelId: ${secret.discord.sharingChannelId} logging: config: classpath:logging/log4j2-dev.yml diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cc4de59d..db9adc35 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -37,6 +37,7 @@ jwt: discord: token: ${secret.discord.token} channelId: ${secret.discord.channelId} + sharingChannelId: ${secret.discord.sharingChannelId} logging: config: classpath:logging/log4j2-prod.yml diff --git a/src/test/java/com/debatetimer/client/DiscordPropertiesTest.java b/src/test/java/com/debatetimer/client/DiscordPropertiesTest.java index 85fc72a4..918a80e1 100644 --- a/src/test/java/com/debatetimer/client/DiscordPropertiesTest.java +++ b/src/test/java/com/debatetimer/client/DiscordPropertiesTest.java @@ -19,7 +19,7 @@ class Validate { @NullAndEmptySource @ValueSource(strings = {"\n", "\t "}) void 디스코드봇_토큰이_비어있을_경우_예외를_발생시킨다(String empty) { - assertThatThrownBy(() -> new DiscordProperties(empty, "channelId")) + assertThatThrownBy(() -> new DiscordProperties(empty, "channelId", "sharingChannelId")) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.DISCORD_PROPERTIES_EMPTY.getMessage()); } @@ -28,7 +28,16 @@ class Validate { @NullAndEmptySource @ValueSource(strings = {"\n", "\t "}) void 디스코드_채널_아이디가_비어있을_경우_예외를_발생시킨다(String empty) { - assertThatThrownBy(() -> new DiscordProperties("token", empty)) + assertThatThrownBy(() -> new DiscordProperties("token", empty, "sharingChannelId")) + .isInstanceOf(DTInitializationException.class) + .hasMessage(InitializationErrorCode.DISCORD_PROPERTIES_EMPTY.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\n", "\t "}) + void 공유_알림_채널_아이디가_비어있을_경우_예외를_발생시킨다(String empty) { + assertThatThrownBy(() -> new DiscordProperties("token", "channelId", empty)) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.DISCORD_PROPERTIES_EMPTY.getMessage()); } diff --git a/src/test/java/com/debatetimer/client/notifier/DiscordSharingNotifierTest.java b/src/test/java/com/debatetimer/client/notifier/DiscordSharingNotifierTest.java new file mode 100644 index 00000000..2e29695d --- /dev/null +++ b/src/test/java/com/debatetimer/client/notifier/DiscordSharingNotifierTest.java @@ -0,0 +1,34 @@ +package com.debatetimer.client.notifier; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.debatetimer.event.sharing.SharingFinishedEvent; +import java.time.LocalDateTime; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import org.junit.jupiter.api.Test; + +class DiscordSharingNotifierTest { + + @Test + void 공유_알림_채널로_공유_종료_메시지를_보낸다() { + JDA jda = mock(JDA.class); + TextChannel channel = mock(TextChannel.class); + MessageCreateAction action = mock(MessageCreateAction.class); + when(jda.getTextChannelById("sharing-channel")).thenReturn(channel); + when(channel.sendMessage(anyString())).thenReturn(action); + DiscordProperties properties = new DiscordProperties("token", "error-channel", "sharing-channel"); + LocalDateTime startedAt = LocalDateTime.of(2026, 10, 7, 10, 0); + SharingFinishedEvent event = new SharingFinishedEvent( + 1L, 2L, "chairman@email.com", 3L, "테이블", startedAt, startedAt.plusMinutes(1), 60, 4); + + new DiscordSharingNotifier(properties, jda).sendSharingFinished(event); + + verify(channel).sendMessage(SharingFinishedMessage.from(event)); + verify(action).queue(); + } +} diff --git a/src/test/java/com/debatetimer/client/notifier/SharingFinishedMessageTest.java b/src/test/java/com/debatetimer/client/notifier/SharingFinishedMessageTest.java new file mode 100644 index 00000000..3d5ba42b --- /dev/null +++ b/src/test/java/com/debatetimer/client/notifier/SharingFinishedMessageTest.java @@ -0,0 +1,38 @@ +package com.debatetimer.client.notifier; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.event.sharing.SharingFinishedEvent; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class SharingFinishedMessageTest { + + private static final LocalDateTime STARTED_AT = LocalDateTime.of(2026, 10, 7, 10, 0, 0); + + private SharingFinishedEvent event(long sharingLogId, long durationSeconds) { + return new SharingFinishedEvent( + sharingLogId, 2L, "chairman@email.com", 3L, "토론 테이블", + STARTED_AT, STARTED_AT.plusSeconds(durationSeconds), durationSeconds, 15); + } + + @Test + void 공유_기록_id를_누적_공유_횟수로_강조한다() { + String message = SharingFinishedMessage.from(event(1234L, 60)); + + assertThat(message).startsWith("## 🎉 누적 **1,234**번째 라이브 공유 종료!"); + } + + @Test + void 사회자_테이블_공유_시간_청중_수를_담는다() { + String message = SharingFinishedMessage.from(event(1L, 52 * 60 + 7)); + + assertThat(message).contains( + "`2` · chairman@email.com", + "`3` · 토론 테이블", + "**52분 7초**", + "2026-10-07 10:00:00 → 10:52:07", + "**15명**" + ); + } +} diff --git a/src/test/java/com/debatetimer/event/sharing/SharingFinishedListenerTest.java b/src/test/java/com/debatetimer/event/sharing/SharingFinishedListenerTest.java new file mode 100644 index 00000000..c601a2eb --- /dev/null +++ b/src/test/java/com/debatetimer/event/sharing/SharingFinishedListenerTest.java @@ -0,0 +1,42 @@ +package com.debatetimer.event.sharing; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.debatetimer.client.notifier.SharingNotifier; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SharingFinishedListenerTest { + + private static final SharingFinishedEvent EVENT = new SharingFinishedEvent( + 1L, 2L, "chairman@email.com", 3L, "테이블", + LocalDateTime.of(2026, 10, 7, 10, 0), LocalDateTime.of(2026, 10, 7, 10, 30), 1800, 5); + + private SharingNotifier sharingNotifier; + private SharingFinishedListener listener; + + @BeforeEach + void setUp() { + sharingNotifier = mock(SharingNotifier.class); + listener = new SharingFinishedListener(sharingNotifier); + } + + @Test + void 공유가_종료되면_공유_통계를_알린다() { + listener.handleSharingFinished(EVENT); + + verify(sharingNotifier).sendSharingFinished(EVENT); + } + + @Test + void 알림_발송에_실패해도_예외를_던지지_않는다() { + doThrow(new IllegalStateException("discord down")).when(sharingNotifier).sendSharingFinished(any()); + + assertThatCode(() -> listener.handleSharingFinished(EVENT)).doesNotThrowAnyException(); + } +} From 7f8ca2a7a8170a0a6cd6a68d17d727a904972bb9 Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:53 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EA=B3=B5=EC=9C=A0=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20API=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 Opus 5.5 --- .../config/AuthenticationConfig.java | 3 +- .../controller/admin/AdminAuthorizer.java | 27 +++++ .../controller/admin/AdminProperties.java | 20 ++++ .../admin/AdminSharingController.java | 34 +++++++ .../dto/admin/SharingDailyStatResponse.java | 10 ++ .../dto/admin/SharingStatsResponse.java | 11 +++ .../exception/errorcode/ClientErrorCode.java | 5 + .../errorcode/InitializationErrorCode.java | 2 + .../service/admin/SharingStatsService.java | 62 ++++++++++++ src/main/resources/application-dev.yml | 3 + src/main/resources/application-prod.yml | 3 + .../controller/admin/AdminAuthorizerTest.java | 36 +++++++ .../controller/admin/AdminPropertiesTest.java | 26 +++++ .../admin/AdminSharingControllerTest.java | 60 ++++++++++++ .../admin/SharingStatsServiceTest.java | 98 +++++++++++++++++++ src/test/resources/application.yml | 3 + 16 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminAuthorizer.java create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminProperties.java create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminSharingController.java create mode 100644 src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/SharingStatsResponse.java create mode 100644 src/main/java/com/debatetimer/service/admin/SharingStatsService.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminAuthorizerTest.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminPropertiesTest.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminSharingControllerTest.java create mode 100644 src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java diff --git a/src/main/java/com/debatetimer/config/AuthenticationConfig.java b/src/main/java/com/debatetimer/config/AuthenticationConfig.java index 4afdc6cf..8240bc34 100644 --- a/src/main/java/com/debatetimer/config/AuthenticationConfig.java +++ b/src/main/java/com/debatetimer/config/AuthenticationConfig.java @@ -1,12 +1,13 @@ package com.debatetimer.config; import com.debatetimer.client.oauth.OAuthProperties; +import com.debatetimer.controller.admin.AdminProperties; import com.debatetimer.controller.tool.jwt.JwtTokenProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties({OAuthProperties.class, JwtTokenProperties.class}) +@EnableConfigurationProperties({OAuthProperties.class, JwtTokenProperties.class, AdminProperties.class}) public class AuthenticationConfig { } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminAuthorizer.java b/src/main/java/com/debatetimer/controller/admin/AdminAuthorizer.java new file mode 100644 index 00000000..20331142 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminAuthorizer.java @@ -0,0 +1,27 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import jakarta.annotation.Nullable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 어드민 비밀번호를 확인한다. 비교 시간으로 비밀번호를 추측할 수 없도록 고정 시간 비교를 쓴다. + */ +@Component +@RequiredArgsConstructor +public class AdminAuthorizer { + + private final AdminProperties adminProperties; + + public void authorize(@Nullable String password) { + if (password == null || !MessageDigest.isEqual( + password.getBytes(StandardCharsets.UTF_8), + adminProperties.getPassword().getBytes(StandardCharsets.UTF_8))) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ADMIN_PASSWORD); + } + } +} diff --git a/src/main/java/com/debatetimer/controller/admin/AdminProperties.java b/src/main/java/com/debatetimer/controller/admin/AdminProperties.java new file mode 100644 index 00000000..c3f0867d --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminProperties.java @@ -0,0 +1,20 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "admin") +public class AdminProperties { + + private final String password; + + public AdminProperties(String password) { + if (password == null || password.isBlank()) { + throw new DTInitializationException(InitializationErrorCode.ADMIN_PASSWORD_EMPTY); + } + this.password = password; + } +} diff --git a/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java b/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java new file mode 100644 index 00000000..d83aa29e --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java @@ -0,0 +1,34 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.dto.admin.SharingStatsResponse; +import com.debatetimer.service.admin.SharingStatsService; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AdminSharingController { + + public static final String ADMIN_PASSWORD_HEADER = "X-Admin-Password"; + + private final AdminAuthorizer adminAuthorizer; + private final SharingStatsService sharingStatsService; + + @GetMapping("/api/admin/sharing/stats") + @ResponseStatus(HttpStatus.OK) + public SharingStatsResponse getSharingStats( + @RequestHeader(name = ADMIN_PASSWORD_HEADER, required = false) String password, + @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + adminAuthorizer.authorize(password); + return sharingStatsService.getDailyStats(from, to); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java b/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java new file mode 100644 index 00000000..34e152c7 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.admin; + +import java.time.LocalDate; + +public record SharingDailyStatResponse( + LocalDate date, + int finishedCount, + double averageMinutes +) { +} diff --git a/src/main/java/com/debatetimer/dto/admin/SharingStatsResponse.java b/src/main/java/com/debatetimer/dto/admin/SharingStatsResponse.java new file mode 100644 index 00000000..38ceed68 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/SharingStatsResponse.java @@ -0,0 +1,11 @@ +package com.debatetimer.dto.admin; + +import java.time.LocalDate; +import java.util.List; + +public record SharingStatsResponse( + LocalDate from, + LocalDate to, + List days +) { +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 2cb5bbea..6c1e8f9e 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -5,6 +5,7 @@ import com.debatetimer.domain.customize.TableName; import com.debatetimer.domain.customize.TeamName; import com.debatetimer.entity.customize.CustomizeTimeBoxEntity; +import com.debatetimer.service.admin.SharingStatsService; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -83,6 +84,10 @@ public enum ClientErrorCode implements ResponseErrorCode { INVALID_TIME_BASED_TIMER_EVENT_DATA(HttpStatus.BAD_REQUEST, "잘못된 자유토론 타이머 이벤트 데이터입니다"), INVALID_TIMER_EVENT(HttpStatus.BAD_REQUEST, "잘못된 타이머 이벤트 데이터입니다"), INVALID_SYNC_TIMER_EVENT_DATA(HttpStatus.BAD_REQUEST, "잘못된 동기화 타이머 이벤트 데이터입니다"), + + INVALID_ADMIN_PASSWORD(HttpStatus.UNAUTHORIZED, "어드민 비밀번호가 올바르지 않습니다"), + INVALID_STATS_PERIOD(HttpStatus.BAD_REQUEST, "조회 기간은 시작일이 종료일보다 늦을 수 없고, 최대 %d일까지 조회할 수 있습니다" + .formatted(SharingStatsService.MAX_PERIOD_DAYS)), ; private final HttpStatus status; diff --git a/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java index 153067b0..2eb249a6 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java @@ -17,6 +17,8 @@ public enum InitializationErrorCode { JWT_SECRET_KEY_EMPTY("JWT secretKey 가 입력되지 않았습니다"), JWT_TOKEN_DURATION_EMPTY("토큰 만료 기간이 입력되지 않았습니다"), JWT_TOKEN_DURATION_INVALID("토큰 만료 기간은 양수이어야 합니다"), + + ADMIN_PASSWORD_EMPTY("어드민 비밀번호가 입력되지 않았습니다"), ; private final String message; diff --git a/src/main/java/com/debatetimer/service/admin/SharingStatsService.java b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java new file mode 100644 index 00000000..54854a5f --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java @@ -0,0 +1,62 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.dto.admin.SharingDailyStatResponse; +import com.debatetimer.dto.admin.SharingStatsResponse; +import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.sharing.SharingLogRepository; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 종료(FINISHED)된 공유를 시작일 기준으로 모아 일별 횟수와 평균 공유 시간을 계산한다. + */ +@Service +@RequiredArgsConstructor +public class SharingStatsService { + + public static final int MAX_PERIOD_DAYS = 366; + private static final double SECONDS_PER_MINUTE = 60.0; + + private final SharingLogRepository sharingLogRepository; + + @Transactional(readOnly = true) + public SharingStatsResponse getDailyStats(LocalDate from, LocalDate to) { + validatePeriod(from, to); + Map> logsByDate = sharingLogRepository + .findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLessThan( + SharingLogStatus.FINISHED, + from.atStartOfDay(), + to.plusDays(1).atStartOfDay() + ) + .stream() + .collect(Collectors.groupingBy(sharingLog -> sharingLog.getStartedAt().toLocalDate())); + + List days = from.datesUntil(to.plusDays(1)) + .map(date -> toDailyStat(date, logsByDate.getOrDefault(date, List.of()))) + .toList(); + return new SharingStatsResponse(from, to, days); + } + + private void validatePeriod(LocalDate from, LocalDate to) { + if (from.isAfter(to) || ChronoUnit.DAYS.between(from, to) >= MAX_PERIOD_DAYS) { + throw new DTClientErrorException(ClientErrorCode.INVALID_STATS_PERIOD); + } + } + + private SharingDailyStatResponse toDailyStat(LocalDate date, List logs) { + double averageMinutes = logs.stream() + .mapToLong(SharingLogEntity::getDurationSeconds) + .average() + .orElse(0) / SECONDS_PER_MINUTE; + return new SharingDailyStatResponse(date, logs.size(), Math.round(averageMinutes * 10) / 10.0); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e2447dc2..45350ab8 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -40,5 +40,8 @@ discord: channelId: ${secret.discord.channelId} sharingChannelId: ${secret.discord.sharingChannelId} +admin: + password: ${secret.admin.password} + logging: config: classpath:logging/log4j2-dev.yml diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index db9adc35..ffd17fd0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -39,5 +39,8 @@ discord: channelId: ${secret.discord.channelId} sharingChannelId: ${secret.discord.sharingChannelId} +admin: + password: ${secret.admin.password} + logging: config: classpath:logging/log4j2-prod.yml diff --git a/src/test/java/com/debatetimer/controller/admin/AdminAuthorizerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminAuthorizerTest.java new file mode 100644 index 00000000..ab7bfa98 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminAuthorizerTest.java @@ -0,0 +1,36 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class AdminAuthorizerTest { + + private final AdminAuthorizer adminAuthorizer = new AdminAuthorizer(new AdminProperties("admin-password")); + + @Nested + class Authorize { + + @Test + void 비밀번호가_일치하면_통과한다() { + assertThatCode(() -> adminAuthorizer.authorize("admin-password")) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"wrong-password", "admin-password ", "admin"}) + void 비밀번호가_일치하지_않으면_예외를_던진다(String password) { + assertThatThrownBy(() -> adminAuthorizer.authorize(password)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_ADMIN_PASSWORD.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminPropertiesTest.java b/src/test/java/com/debatetimer/controller/admin/AdminPropertiesTest.java new file mode 100644 index 00000000..97bdd86e --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminPropertiesTest.java @@ -0,0 +1,26 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class AdminPropertiesTest { + + @Nested + class Validate { + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\n", "\t "}) + void 어드민_비밀번호가_비어있을_경우_예외를_발생시킨다(String empty) { + assertThatThrownBy(() -> new AdminProperties(empty)) + .isInstanceOf(DTInitializationException.class) + .hasMessage(InitializationErrorCode.ADMIN_PASSWORD_EMPTY.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminSharingControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminSharingControllerTest.java new file mode 100644 index 00000000..f888838e --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminSharingControllerTest.java @@ -0,0 +1,60 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.dto.admin.SharingStatsResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class AdminSharingControllerTest extends BaseControllerTest { + + private static final String PASSWORD = "test-admin-password"; + + @Nested + class GetSharingStats { + + @Test + void 어드민_비밀번호가_맞으면_기간별_통계를_조회한다() { + SharingStatsResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/sharing/stats") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(SharingStatsResponse.class); + + assertThat(response.days()).hasSize(7); + } + + @Test + void 어드민_비밀번호가_틀리면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, "wrong-password") + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/sharing/stats") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 어드민_비밀번호가_없으면_조회할_수_없다() { + given() + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/sharing/stats") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 조회_기간이_잘못되면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("from", "2026-10-07") + .queryParam("to", "2026-10-01") + .when().get("/api/admin/sharing/stats") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + } +} diff --git a/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java b/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java new file mode 100644 index 00000000..58a3e843 --- /dev/null +++ b/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java @@ -0,0 +1,98 @@ +package com.debatetimer.service.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.admin.SharingDailyStatResponse; +import com.debatetimer.dto.admin.SharingStatsResponse; +import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.sharing.SharingLogRepository; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class SharingStatsServiceTest extends BaseServiceTest { + + private static final LocalDate DAY = LocalDate.of(2026, 10, 1); + + @Autowired + private SharingStatsService sharingStatsService; + + @Autowired + private SharingLogRepository sharingLogRepository; + + private Member member; + + @BeforeEach + void setUp() { + member = memberGenerator.generate("chairman@email.com"); + } + + private void saveFinished(LocalDateTime startedAt, long minutes) { + SharingLogEntity log = new SharingLogEntity(1L, member.getId(), startedAt); + log.finish(startedAt.plusMinutes(minutes)); + sharingLogRepository.save(log); + } + + private void saveAbandoned(LocalDateTime startedAt) { + SharingLogEntity log = new SharingLogEntity(1L, member.getId(), startedAt); + log.abandon(startedAt.plusMinutes(5)); + sharingLogRepository.save(log); + } + + @Nested + class GetDailyStats { + + @Test + void 종료된_공유만_시작일별로_횟수와_평균_시간을_계산한다() { + saveFinished(DAY.atTime(10, 0), 30); + saveFinished(DAY.atTime(23, 59), 61); + saveAbandoned(DAY.atTime(12, 0)); + saveFinished(DAY.plusDays(2).atTime(0, 0), 15); + + SharingStatsResponse response = sharingStatsService.getDailyStats(DAY, DAY.plusDays(2)); + + assertThat(response.days()).containsExactly( + new SharingDailyStatResponse(DAY, 2, 45.5), + new SharingDailyStatResponse(DAY.plusDays(1), 0, 0), + new SharingDailyStatResponse(DAY.plusDays(2), 1, 15) + ); + } + + @Test + void 기간_밖의_공유는_포함하지_않는다() { + saveFinished(DAY.minusDays(1).atTime(23, 59), 10); + saveFinished(DAY.plusDays(1).atTime(0, 0), 10); + + SharingStatsResponse response = sharingStatsService.getDailyStats(DAY, DAY); + + assertAll( + () -> assertThat(response.days()).hasSize(1), + () -> assertThat(response.days().get(0).finishedCount()).isZero() + ); + } + + @Test + void 시작일이_종료일보다_늦으면_예외를_던진다() { + assertThatThrownBy(() -> sharingStatsService.getDailyStats(DAY, DAY.minusDays(1))) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_STATS_PERIOD.getMessage()); + } + + @Test + void 최대_조회_기간을_넘으면_예외를_던진다() { + LocalDate to = DAY.plusDays(SharingStatsService.MAX_PERIOD_DAYS); + + assertThatThrownBy(() -> sharingStatsService.getDailyStats(DAY, to)) + .isInstanceOf(DTClientErrorException.class); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index fa923caa..f6e01fb9 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,6 +17,9 @@ jwt: access_token_expiration: 1h refresh_token_expiration: 1d +admin: + password: test-admin-password + --- spring: From 78b6274604e204de6fb36fe9bc1ea691df04d37a Mon Sep 17 00:00:00 2001 From: coli Date: Wed, 7 Oct 2026 01:19:53 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EA=B3=B5=EC=9C=A0=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=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 Opus 5.5 --- src/main/resources/static/admin.html | 261 +++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/main/resources/static/admin.html diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html new file mode 100644 index 00000000..5d5e866b --- /dev/null +++ b/src/main/resources/static/admin.html @@ -0,0 +1,261 @@ + + + + + + + Debate Timer · 라이브 공유 통계 + + + + +
+

라이브 공유 통계

+ +
+

어드민 비밀번호

+
+ + +
+
+
+ + +
+ + + +