From 90a99a1a05b573ae470286c48dc5d8bf77c2c6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= Date: Fri, 9 Oct 2026 01:23:16 +0900 Subject: [PATCH] =?UTF-8?q?[FIX]=20=EB=A7=88=EC=A7=80=EB=A7=89=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EB=B0=95=EC=8A=A4=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=ED=83=88=20=EC=8B=9C=20=EC=A2=85=EB=A3=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EB=B0=8F=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EC=A0=95=EB=A6=AC=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- .../domain/sharing/ActiveSharing.java | 113 +++++++++++ .../domain/sharing/SharingLog.java | 37 ++++ .../domain/sharing/TimerEventType.java | 14 ++ .../CustomizeTableDomainRepository.java | 6 + .../sharing/SharingLogDomainRepository.java | 68 +++++++ .../entity/sharing/SharingLogEntity.java | 33 ++-- .../event/sharing/SharingFinishedEvent.java | 2 +- .../sharing/SharingFinishedListener.java | 3 +- .../exception/errorcode/ClientErrorCode.java | 1 + .../customize/CustomizeTableRepository.java | 10 + .../sharing/SharingLogRepository.java | 21 ++ .../SharingRoomCleanupScheduler.java | 1 + .../service/admin/SharingStatsService.java | 21 +- .../service/sharing/SharingLogService.java | 182 ++++++------------ .../domain/sharing/ActiveSharingTest.java | 119 ++++++++++++ .../domain/sharing/SharingLogTest.java | 57 ++++++ .../domain/sharing/TimerEventTypeTest.java | 25 +++ .../CustomizeTableDomainRepositoryTest.java | 14 ++ .../SharingLogDomainRepositoryTest.java | 156 +++++++++++++++ .../entity/sharing/SharingLogEntityTest.java | 90 --------- .../admin/SharingStatsServiceTest.java | 15 +- .../sharing/SharingLogServiceTest.java | 107 ++++++---- 22 files changed, 800 insertions(+), 295 deletions(-) create mode 100644 src/main/java/com/debatetimer/domain/sharing/ActiveSharing.java create mode 100644 src/main/java/com/debatetimer/domain/sharing/SharingLog.java create mode 100644 src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java create mode 100644 src/test/java/com/debatetimer/domain/sharing/ActiveSharingTest.java create mode 100644 src/test/java/com/debatetimer/domain/sharing/SharingLogTest.java create mode 100644 src/test/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepositoryTest.java delete mode 100644 src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java diff --git a/src/main/java/com/debatetimer/domain/sharing/ActiveSharing.java b/src/main/java/com/debatetimer/domain/sharing/ActiveSharing.java new file mode 100644 index 00000000..0f4eea20 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/sharing/ActiveSharing.java @@ -0,0 +1,113 @@ +package com.debatetimer.domain.sharing; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.Getter; + +/** + * 기록 중인 라이브 공유 한 번의 진행 상태 + * - 사회자 연결이 끊긴 뒤 재연결 유예 시간이 지나거나, 공유가 너무 오래 이어지면 만료된다. + * - 마지막 타임박스까지 진행한 공유는 사회자가 직접 종료하지 않아도 끝까지 진행한 것으로 본다. + * - 연결이 끊긴 채 만료되면 끊긴 시각을 종료 시각으로 본다. + */ +public class ActiveSharing { + + public static final Duration RECONNECT_GRACE = Duration.ofMinutes(1); + public static final Duration STALE_THRESHOLD = Duration.ofHours(12); + + @Getter + private final long logId; + + @Getter + @NotNull + private final String memberEmail; + + @Getter + @NotNull + private final String tableName; + + private final int timeBoxCount; + + @NotNull + private final LocalDateTime startedAt; + + @Nullable + private final Integer lastSequence; + + @Nullable + private final LocalDateTime disconnectedAt; + + public ActiveSharing( + long logId, + String memberEmail, + String tableName, + int timeBoxCount, + LocalDateTime startedAt + ) { + this(logId, memberEmail, tableName, timeBoxCount, startedAt, null, null); + } + + private ActiveSharing( + long logId, + String memberEmail, + String tableName, + int timeBoxCount, + LocalDateTime startedAt, + @Nullable Integer lastSequence, + @Nullable LocalDateTime disconnectedAt + ) { + this.logId = logId; + this.memberEmail = memberEmail; + this.tableName = tableName; + this.timeBoxCount = timeBoxCount; + this.startedAt = startedAt; + this.lastSequence = lastSequence; + this.disconnectedAt = disconnectedAt; + } + + /** + * 끊긴 공유가 마지막 타임박스까지 진행됐는지 판단할 수 있도록, 청중 화면에 표시될 타임박스 순서를 기록한다. + */ + public ActiveSharing record(TimerEvent timerEvent) { + TimerEventData data = timerEvent.getTimerEventData(); + if (data == null) { + return this; + } + int displayedSequence = timerEvent.getEventType().toDisplayedSequence(data.getSequence()); + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, displayedSequence, + disconnectedAt); + } + + public ActiveSharing disconnect(LocalDateTime at) { + if (disconnectedAt != null) { + return this; + } + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, lastSequence, at); + } + + public ActiveSharing reconnect() { + return new ActiveSharing(logId, memberEmail, tableName, timeBoxCount, startedAt, lastSequence, null); + } + + public boolean isReconnectExpired(LocalDateTime now) { + return disconnectedAt != null && !now.isBefore(disconnectedAt.plus(RECONNECT_GRACE)); + } + + public boolean isExpired(LocalDateTime now) { + return isReconnectExpired(now) || !now.isBefore(startedAt.plus(STALE_THRESHOLD)); + } + + // 타임박스 순서는 0부터 시작한다 + public boolean isCompleted() { + return lastSequence != null && lastSequence >= timeBoxCount - 1; + } + + public LocalDateTime endedAt(LocalDateTime now) { + if (disconnectedAt == null) { + return now; + } + return disconnectedAt; + } +} diff --git a/src/main/java/com/debatetimer/domain/sharing/SharingLog.java b/src/main/java/com/debatetimer/domain/sharing/SharingLog.java new file mode 100644 index 00000000..4c32c18e --- /dev/null +++ b/src/main/java/com/debatetimer/domain/sharing/SharingLog.java @@ -0,0 +1,37 @@ +package com.debatetimer.domain.sharing; + +import jakarta.annotation.Nullable; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 라이브 공유 한 번(사회자의 공유 시작부터 종료까지)의 기록 + */ +@Getter +@RequiredArgsConstructor +public class SharingLog { + + private final Long id; + private final long tableId; + private final long memberId; + private final LocalDateTime startedAt; + + @Nullable + private final LocalDateTime endedAt; + + private final int audienceCount; + private final SharingLogStatus status; + + public SharingLog(long tableId, long memberId, LocalDateTime startedAt) { + this(null, tableId, memberId, startedAt, null, 0, SharingLogStatus.SHARING); + } + + 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/domain/sharing/TimerEventType.java b/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java index a5b4120d..0908b5e4 100644 --- a/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java +++ b/src/main/java/com/debatetimer/domain/sharing/TimerEventType.java @@ -32,4 +32,18 @@ public void validateEventData(Object eventData) { public boolean isSync() { return this == SYNC; } + + /** + * 이벤트가 반영된 뒤 청중 화면에 표시될 타임박스 순서를 구한다. + * 이전·다음 이벤트는 이동하기 전 순서를 담고 있어, 이동한 뒤 순서로 바꾼다. + */ + public int toDisplayedSequence(int sequence) { + if (this == NEXT) { + return sequence + 1; + } + if (this == BEFORE) { + return Math.max(sequence - 1, 0); + } + return sequence; + } } diff --git a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java index 96e345fd..cddd6a9b 100644 --- a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java @@ -59,6 +59,12 @@ public CustomizeTable getById(long tableId) { .toDomain(); } + @Transactional(readOnly = true) + public CustomizeTable getWithMemberById(long tableId) { + return tableRepository.getWithMemberById(tableId) + .toDomain(); + } + @Transactional(readOnly = true) public List getMemberCustomizeTimeBoxes(long tableId, Member member) { CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member); diff --git a/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java new file mode 100644 index 00000000..861dcc5f --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java @@ -0,0 +1,68 @@ +package com.debatetimer.domainrepository.sharing; + +import com.debatetimer.domain.sharing.SharingLog; +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.repository.sharing.SharingLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class SharingLogDomainRepository { + + private final SharingLogRepository sharingLogRepository; + + @Transactional + public SharingLog create(SharingLog sharingLog) { + return sharingLogRepository.save(new SharingLogEntity(sharingLog)) + .toDomain(); + } + + @Transactional(readOnly = true) + public SharingLog getById(long id) { + return sharingLogRepository.getById(id) + .toDomain(); + } + + @Transactional(readOnly = true) + public List findAllFinishedStartedBetween(LocalDateTime from, LocalDateTime to) { + return sharingLogRepository.findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLessThan( + SharingLogStatus.FINISHED, from, to) + .stream() + .map(SharingLogEntity::toDomain) + .toList(); + } + + @Transactional + public void increaseAudienceCount(long id) { + sharingLogRepository.increaseAudienceCount(id); + } + + /** + * 진행 중인 기록을 종료한다. 다른 경로에서 이미 끝난 기록이면 바꾸지 않고 빈 값을 반환한다. + */ + @Transactional + public Optional finish(long id, LocalDateTime endedAt) { + int updated = sharingLogRepository.updateStatusIfSharing( + id, SharingLogStatus.FINISHED, endedAt, SharingLogStatus.SHARING); + if (updated == 0) { + return Optional.empty(); + } + return Optional.of(getById(id)); + } + + @Transactional + public void abandon(long id, LocalDateTime endedAt) { + sharingLogRepository.updateStatusIfSharing(id, SharingLogStatus.ABANDONED, endedAt, SharingLogStatus.SHARING); + } + + @Transactional + public void abandonStartedBefore(LocalDateTime threshold) { + sharingLogRepository.abandonStaleSharings(SharingLogStatus.ABANDONED, SharingLogStatus.SHARING, threshold); + } +} diff --git a/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java b/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java index 005fccbd..618af760 100644 --- a/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java +++ b/src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java @@ -1,5 +1,6 @@ package com.debatetimer.entity.sharing; +import com.debatetimer.domain.sharing.SharingLog; import com.debatetimer.domain.sharing.SharingLogStatus; import com.debatetimer.entity.BaseTimeEntity; import jakarta.annotation.Nullable; @@ -12,7 +13,6 @@ 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; @@ -49,28 +49,17 @@ public class SharingLogEntity extends BaseTimeEntity { @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 SharingLogEntity(SharingLog sharingLog) { + this.id = sharingLog.getId(); + this.tableId = sharingLog.getTableId(); + this.memberId = sharingLog.getMemberId(); + this.startedAt = sharingLog.getStartedAt(); + this.endedAt = sharingLog.getEndedAt(); + this.audienceCount = sharingLog.getAudienceCount(); + this.status = sharingLog.getStatus(); } - 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); + public SharingLog toDomain() { + return new SharingLog(id, tableId, memberId, startedAt, endedAt, audienceCount, status); } } diff --git a/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java b/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java index 6dcc6b3c..1d113cff 100644 --- a/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java +++ b/src/main/java/com/debatetimer/event/sharing/SharingFinishedEvent.java @@ -3,7 +3,7 @@ import java.time.LocalDateTime; /** - * 사회자가 공유 종료(FINISHED)를 명시적으로 발행해 공유 기록이 종료되었음을 알린다. + * 공유 기록이 종료되었음을 알린다. 사회자가 직접 종료했거나, 마지막 타임박스에서 끊긴 뒤 돌아오지 않은 경우이다. */ public record SharingFinishedEvent( long sharingLogId, diff --git a/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java b/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java index 4794f393..69804aea 100644 --- a/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java +++ b/src/main/java/com/debatetimer/event/sharing/SharingFinishedListener.java @@ -10,6 +10,7 @@ /** * 공유 종료 기록이 저장된 뒤에 공유 통계를 알린다. + * 종료 이벤트는 기록을 저장한 트랜잭션이 끝난 뒤 발행되므로, 트랜잭션 밖에서 발행돼도 알린다. * 알림은 공유 중계와 무관하므로 별도 스레드에서 보내고, 실패해도 기록만 남긴다. */ @Slf4j @@ -20,7 +21,7 @@ public class SharingFinishedListener { private final SharingNotifier sharingNotifier; @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleSharingFinished(SharingFinishedEvent event) { try { sharingNotifier.sendSharingFinished(event); diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 6c1e8f9e..728bf014 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -60,6 +60,7 @@ public enum ClientErrorCode implements ResponseErrorCode { TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."), NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), POLL_NOT_FOUND(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다."), + SHARING_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "공유 기록을 찾을 수 없습니다."), UNAUTHORIZED_MEMBER(HttpStatus.UNAUTHORIZED, "접근 권한이 없습니다"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰 기한이 만료되었습니다"), diff --git a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java index 00f32b0f..5e320257 100644 --- a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java +++ b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java @@ -6,6 +6,7 @@ import com.debatetimer.exception.errorcode.ClientErrorCode; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.repository.Repository; public interface CustomizeTableRepository extends Repository { @@ -28,5 +29,14 @@ default CustomizeTableEntity getById(long tableId) { .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND)); } + // 트랜잭션 밖에서도 테이블 소유 회원 정보를 쓸 수 있도록 함께 조회한다 + @EntityGraph(attributePaths = "member") + Optional findWithMemberById(long id); + + default CustomizeTableEntity getWithMemberById(long tableId) { + return findWithMemberById(tableId) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND)); + } + void delete(CustomizeTableEntity table); } diff --git a/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java index 730b542f..a4da9b2d 100644 --- a/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java +++ b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java @@ -2,6 +2,8 @@ import com.debatetimer.domain.sharing.SharingLogStatus; import com.debatetimer.entity.sharing.SharingLogEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -24,11 +26,30 @@ List findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLe LocalDateTime to ); + default SharingLogEntity getById(long id) { + return findById(id) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.SHARING_LOG_NOT_FOUND)); + } + // 여러 청중이 동시에 입장해도 누락되지 않도록 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 = :status, s.endedAt = :endedAt + WHERE s.id = :id AND s.status = :sharing + """) + int updateStatusIfSharing( + @Param("id") long id, + @Param("status") SharingLogStatus status, + @Param("endedAt") LocalDateTime endedAt, + @Param("sharing") SharingLogStatus sharing + ); + // 서버 재시작 등으로 추적이 끊긴 공유는 종료 시각을 알 수 없으므로 마지막 수정 시각으로 종료 처리한다 @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" diff --git a/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java index e42764ac..be5579c4 100644 --- a/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java +++ b/src/main/java/com/debatetimer/scheduler/SharingRoomCleanupScheduler.java @@ -22,6 +22,7 @@ public class SharingRoomCleanupScheduler { public void cleanupExpiredRooms() { sharingRoomRegistry.removeExpired(); chairmanSessionRegistry.removeExpired(); + sharingLogService.abandonUntracked(); } @Scheduled(fixedRate = SHARING_LOG_INTERVAL_MILLIS) diff --git a/src/main/java/com/debatetimer/service/admin/SharingStatsService.java b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java index 54854a5f..500d6050 100644 --- a/src/main/java/com/debatetimer/service/admin/SharingStatsService.java +++ b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java @@ -1,12 +1,11 @@ package com.debatetimer.service.admin; -import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.domain.sharing.SharingLog; +import com.debatetimer.domainrepository.sharing.SharingLogDomainRepository; 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; @@ -14,7 +13,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * 종료(FINISHED)된 공유를 시작일 기준으로 모아 일별 횟수와 평균 공유 시간을 계산한다. @@ -26,17 +24,12 @@ public class SharingStatsService { public static final int MAX_PERIOD_DAYS = 366; private static final double SECONDS_PER_MINUTE = 60.0; - private final SharingLogRepository sharingLogRepository; + private final SharingLogDomainRepository sharingLogDomainRepository; - @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() - ) + Map> logsByDate = sharingLogDomainRepository + .findAllFinishedStartedBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()) .stream() .collect(Collectors.groupingBy(sharingLog -> sharingLog.getStartedAt().toLocalDate())); @@ -52,9 +45,9 @@ private void validatePeriod(LocalDate from, LocalDate to) { } } - private SharingDailyStatResponse toDailyStat(LocalDate date, List logs) { + private SharingDailyStatResponse toDailyStat(LocalDate date, List logs) { double averageMinutes = logs.stream() - .mapToLong(SharingLogEntity::getDurationSeconds) + .mapToLong(SharingLog::getDurationSeconds) .average() .orElse(0) / SECONDS_PER_MINUTE; return new SharingDailyStatResponse(date, logs.size(), Math.round(averageMinutes * 10) / 10.0); diff --git a/src/main/java/com/debatetimer/service/sharing/SharingLogService.java b/src/main/java/com/debatetimer/service/sharing/SharingLogService.java index 2fd16b67..14976f36 100644 --- a/src/main/java/com/debatetimer/service/sharing/SharingLogService.java +++ b/src/main/java/com/debatetimer/service/sharing/SharingLogService.java @@ -1,18 +1,13 @@ package com.debatetimer.service.sharing; import com.debatetimer.domain.customize.CustomizeTable; -import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.domain.sharing.ActiveSharing; +import com.debatetimer.domain.sharing.SharingLog; 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.domainrepository.sharing.SharingLogDomainRepository; 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; @@ -20,42 +15,40 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * 라이브 공유 한 번을 공유 기록 한 행으로 추적한다. * - 사회자가 공유를 시작하면 기록을 만들고, 사회자가 종료(FINISHED)를 발행할 때까지 같은 기록을 쓴다. * - 사회자 연결이 끊긴 뒤 재연결 유예 시간 안에 다시 공유를 시작하면 같은 기록을 이어서 쓴다. - * - 유예 시간이 지나도록 돌아오지 않으면, 마지막 타임박스까지 진행했을 때는 종료로, 아니면 중단으로 기록한다. + * - 만료된 공유는 끝까지 진행했으면 종료로, 아니면 중단으로 기록한다. 만료 판단은 {@link ActiveSharing}이 한다. + * - 종료로 기록할 때는 사회자가 직접 종료했는지와 관계없이 종료 이벤트를 발행한다. + * - 기록의 상태는 한 행 단위 UPDATE로 바꾸므로, 트랜잭션은 각 저장소 호출이 갖는다. */ @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 SharingLogDomainRepository sharingLogDomainRepository; private final CustomizeTableDomainRepository customizeTableDomainRepository; private final ApplicationEventPublisher eventPublisher; private final Clock clock; @Autowired public SharingLogService( - SharingLogRepository sharingLogRepository, + SharingLogDomainRepository sharingLogDomainRepository, CustomizeTableDomainRepository customizeTableDomainRepository, ApplicationEventPublisher eventPublisher ) { - this(sharingLogRepository, customizeTableDomainRepository, eventPublisher, Clock.systemDefaultZone()); + this(sharingLogDomainRepository, customizeTableDomainRepository, eventPublisher, Clock.systemDefaultZone()); } SharingLogService( - SharingLogRepository sharingLogRepository, + SharingLogDomainRepository sharingLogDomainRepository, CustomizeTableDomainRepository customizeTableDomainRepository, ApplicationEventPublisher eventPublisher, Clock clock ) { - this.sharingLogRepository = sharingLogRepository; + this.sharingLogDomainRepository = sharingLogDomainRepository; this.customizeTableDomainRepository = customizeTableDomainRepository; this.eventPublisher = eventPublisher; this.clock = clock; @@ -65,172 +58,105 @@ public SharingLogService( * 진행 중인 기록이 있으면(재연결·다른 기기로의 이전) 이어서 쓰고, 없으면 새 기록을 만든다. * 끊긴 지 유예 시간이 지난 기록은 정리한 뒤 새 기록을 만든다. */ - @Transactional public synchronized void start(long roomId) { - Instant now = clock.instant(); + LocalDateTime now = LocalDateTime.now(clock); ActiveSharing active = activeSharings.get(roomId); if (active != null && !active.isReconnectExpired(now)) { - activeSharings.put(roomId, active.reconnected()); + activeSharings.put(roomId, active.reconnect()); return; } if (active != null) { close(roomId, active, now); } - CustomizeTable table = customizeTableDomainRepository.getById(roomId); + CustomizeTable table = customizeTableDomainRepository.getWithMemberById(roomId); int timeBoxCount = customizeTableDomainRepository.countTimeBoxes(roomId); - SharingLogEntity sharingLog = sharingLogRepository.save( - new SharingLogEntity(roomId, table.getMember().getId(), toLocalDateTime(now))); + SharingLog sharingLog = sharingLogDomainRepository.create( + new SharingLog(roomId, table.getMember().getId(), now)); activeSharings.put(roomId, new ActiveSharing( sharingLog.getId(), table.getMember().getEmail(), table.getName(), timeBoxCount, - now, - null, - null + now )); } - /** - * 끊긴 공유가 마지막 타임박스까지 진행됐는지 판단할 수 있도록, 청중 화면에 표시될 타임박스 순서를 기록한다. - * 이전·다음 이벤트는 이동하기 전 순서를 담고 있어, 이동한 뒤 순서로 바꿔 기록한다. - */ 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)); + activeSharings.computeIfPresent(roomId, (id, active) -> active.record(timerEvent)); } - @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() - )); + sharingLogDomainRepository.finish(active.getLogId(), LocalDateTime.now(clock)) + .ifPresent(sharingLog -> publishFinished(active, sharingLog)); } /** * 청중이 입장할 때마다 청중 수를 늘린다. 같은 청중의 재입장도 함께 센다. */ - @Transactional public void joinAudience(long roomId) { ActiveSharing active = activeSharings.get(roomId); if (active == null) { return; } - sharingLogRepository.increaseAudienceCount(active.logId()); + sharingLogDomainRepository.increaseAudienceCount(active.getLogId()); } public synchronized void disconnect(long roomId) { - Instant now = clock.instant(); - activeSharings.computeIfPresent(roomId, (id, active) -> active.disconnected(now)); + LocalDateTime now = LocalDateTime.now(clock); + activeSharings.computeIfPresent(roomId, (id, active) -> active.disconnect(now)); } /** * 재연결 유예 시간이 지난 공유와 너무 오래 이어진 공유를 정리한다. - * 서버 재시작 등으로 추적하지 못하게 된 기록도 중단으로 정리한다. + * 진행 중인 공유가 없으면 아무것도 하지 않는다. */ - @Transactional public synchronized void closeExpired() { - Instant now = clock.instant(); + if (activeSharings.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(clock); List expiredRoomIds = activeSharings.entrySet().stream() - .filter(entry -> entry.getValue().isReconnectExpired(now) || entry.getValue().isStale(now)) + .filter(entry -> entry.getValue().isExpired(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)) - ); + /** + * 서버 재시작 등으로 추적하지 못하게 된 기록을 중단으로 정리한다. + * 오래된 기록만 대상으로 하므로 진행 중인 공유의 정리보다 드물게 실행해도 된다. + */ + public void abandonUntracked() { + sharingLogDomainRepository.abandonStartedBefore(LocalDateTime.now(clock).minus(ActiveSharing.STALE_THRESHOLD)); } - private void close(long roomId, ActiveSharing active, Instant now) { + private void close(long roomId, ActiveSharing active, LocalDateTime 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); + LocalDateTime endedAt = active.endedAt(now); + if (active.isCompleted()) { + sharingLogDomainRepository.finish(active.getLogId(), endedAt) + .ifPresent(sharingLog -> publishFinished(active, sharingLog)); return; } - sharingLog.abandon(endedAt); + sharingLogDomainRepository.abandon(active.getLogId(), 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); - } + private void publishFinished(ActiveSharing active, SharingLog sharingLog) { + eventPublisher.publishEvent(new SharingFinishedEvent( + sharingLog.getId(), + sharingLog.getMemberId(), + active.getMemberEmail(), + sharingLog.getTableId(), + active.getTableName(), + sharingLog.getStartedAt(), + sharingLog.getEndedAt(), + sharingLog.getDurationSeconds(), + sharingLog.getAudienceCount() + )); } } diff --git a/src/test/java/com/debatetimer/domain/sharing/ActiveSharingTest.java b/src/test/java/com/debatetimer/domain/sharing/ActiveSharingTest.java new file mode 100644 index 00000000..3edf8b74 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/sharing/ActiveSharingTest.java @@ -0,0 +1,119 @@ +package com.debatetimer.domain.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.customize.CustomizeBoxType; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ActiveSharingTest { + + private static final LocalDateTime STARTED_AT = LocalDateTime.of(2026, 10, 7, 10, 0); + private static final int TIME_BOX_COUNT = 3; + + private ActiveSharing activeSharing() { + return new ActiveSharing(1L, "chairman@email.com", "테이블", TIME_BOX_COUNT, STARTED_AT); + } + + private TimerEvent event(TimerEventType eventType, int sequence) { + TimerEventData data = new TimerEventData(CustomizeBoxType.NORMAL, sequence, null, 30, true, null, null); + return new TimerEvent(eventType, data); + } + + @Nested + class IsCompleted { + + @Test + void 마지막_타임박스까지_진행했으면_끝까지_진행한_것이다() { + ActiveSharing active = activeSharing().record(event(TimerEventType.PLAY, TIME_BOX_COUNT - 1)); + + assertThat(active.isCompleted()).isTrue(); + } + + @Test + void 마지막_타임박스_전이면_끝까지_진행하지_않은_것이다() { + ActiveSharing active = activeSharing().record(event(TimerEventType.PLAY, TIME_BOX_COUNT - 2)); + + assertThat(active.isCompleted()).isFalse(); + } + + @Test + void 진행한_타임박스가_없으면_끝까지_진행하지_않은_것이다() { + assertThat(activeSharing().isCompleted()).isFalse(); + } + + @Test + void 다음_이벤트는_이동한_뒤_순서로_판단한다() { + ActiveSharing active = activeSharing().record(event(TimerEventType.NEXT, TIME_BOX_COUNT - 2)); + + assertThat(active.isCompleted()).isTrue(); + } + + @Test + void 데이터가_없는_이벤트는_진행_순서를_바꾸지_않는다() { + ActiveSharing active = activeSharing() + .record(event(TimerEventType.PLAY, TIME_BOX_COUNT - 1)) + .record(new TimerEvent(TimerEventType.FINISHED)); + + assertThat(active.isCompleted()).isTrue(); + } + } + + @Nested + class IsExpired { + + @Test + void 연결이_끊긴_뒤_유예_시간이_지나면_만료된다() { + LocalDateTime disconnectedAt = STARTED_AT.plusMinutes(10); + ActiveSharing active = activeSharing().disconnect(disconnectedAt); + + assertAll( + () -> assertThat(active.isExpired(disconnectedAt.plus(ActiveSharing.RECONNECT_GRACE) + .minusSeconds(1))).isFalse(), + () -> assertThat(active.isExpired(disconnectedAt.plus(ActiveSharing.RECONNECT_GRACE))).isTrue() + ); + } + + @Test + void 다시_연결되면_만료되지_않는다() { + LocalDateTime disconnectedAt = STARTED_AT.plusMinutes(10); + ActiveSharing active = activeSharing().disconnect(disconnectedAt).reconnect(); + + assertThat(active.isExpired(disconnectedAt.plus(ActiveSharing.RECONNECT_GRACE))).isFalse(); + } + + @Test + void 연결이_유지돼도_너무_오래_이어지면_만료된다() { + ActiveSharing active = activeSharing(); + + assertAll( + () -> assertThat(active.isExpired(STARTED_AT.plus(ActiveSharing.STALE_THRESHOLD) + .minusSeconds(1))).isFalse(), + () -> assertThat(active.isExpired(STARTED_AT.plus(ActiveSharing.STALE_THRESHOLD))).isTrue() + ); + } + } + + @Nested + class EndedAt { + + @Test + void 연결이_끊긴_채_끝나면_처음_끊긴_시각을_종료_시각으로_본다() { + LocalDateTime disconnectedAt = STARTED_AT.plusMinutes(10); + ActiveSharing active = activeSharing() + .disconnect(disconnectedAt) + .disconnect(disconnectedAt.plusMinutes(5)); + + assertThat(active.endedAt(disconnectedAt.plusMinutes(30))).isEqualTo(disconnectedAt); + } + + @Test + void 연결된_채_끝나면_현재_시각을_종료_시각으로_본다() { + LocalDateTime now = STARTED_AT.plusHours(12); + + assertThat(activeSharing().endedAt(now)).isEqualTo(now); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/sharing/SharingLogTest.java b/src/test/java/com/debatetimer/domain/sharing/SharingLogTest.java new file mode 100644 index 00000000..fe293b5b --- /dev/null +++ b/src/test/java/com/debatetimer/domain/sharing/SharingLogTest.java @@ -0,0 +1,57 @@ +package com.debatetimer.domain.sharing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SharingLogTest { + + private static final LocalDateTime STARTED_AT = LocalDateTime.of(2026, 10, 7, 10, 0, 0); + + private SharingLog ended(LocalDateTime endedAt) { + return new SharingLog(1L, 1L, 2L, STARTED_AT, endedAt, 0, SharingLogStatus.FINISHED); + } + + @Nested + class Create { + + @Test + void 공유_기록은_청중_없이_진행_중_상태로_시작한다() { + SharingLog sharingLog = new SharingLog(1L, 2L, STARTED_AT); + + assertAll( + () -> assertThat(sharingLog.getStatus()).isEqualTo(SharingLogStatus.SHARING), + () -> assertThat(sharingLog.getAudienceCount()).isZero(), + () -> assertThat(sharingLog.getEndedAt()).isNull() + ); + } + } + + @Nested + class GetDurationSeconds { + + @Test + void 시작부터_종료까지의_시간을_초_단위로_계산한다() { + SharingLog sharingLog = ended(STARTED_AT.plusMinutes(52).plusSeconds(7)); + + assertThat(sharingLog.getDurationSeconds()).isEqualTo(52 * 60 + 7); + } + + @Test + void 종료되지_않은_공유는_0초이다() { + SharingLog sharingLog = new SharingLog(1L, 2L, STARTED_AT); + + assertThat(sharingLog.getDurationSeconds()).isZero(); + } + + @Test + void 종료_시각이_시작_시각보다_앞서면_0초이다() { + SharingLog sharingLog = ended(STARTED_AT.minusSeconds(1)); + + assertThat(sharingLog.getDurationSeconds()).isZero(); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java b/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java index e4aee2b5..4d940711 100644 --- a/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java +++ b/src/test/java/com/debatetimer/domain/sharing/TimerEventTypeTest.java @@ -88,4 +88,29 @@ class IsSync { assertThat(eventType.isSync()).isFalse(); } } + + @Nested + class ToDisplayedSequence { + + @Test + void 다음_이벤트는_이동한_뒤_순서를_구한다() { + assertThat(TimerEventType.NEXT.toDisplayedSequence(1)).isEqualTo(2); + } + + @Test + void 이전_이벤트는_이동한_뒤_순서를_구한다() { + assertThat(TimerEventType.BEFORE.toDisplayedSequence(1)).isZero(); + } + + @Test + void 첫_순서에서_이전_이벤트가_와도_첫_순서를_유지한다() { + assertThat(TimerEventType.BEFORE.toDisplayedSequence(0)).isZero(); + } + + @EnumSource(value = TimerEventType.class, names = {"NEXT", "BEFORE"}, mode = EnumSource.Mode.EXCLUDE) + @ParameterizedTest + void 이동하지_않는_이벤트는_순서를_그대로_쓴다(TimerEventType eventType) { + assertThat(eventType.toDisplayedSequence(1)).isEqualTo(1); + } + } } diff --git a/src/test/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepositoryTest.java index 08a3df7c..2e204674 100644 --- a/src/test/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepositoryTest.java @@ -64,6 +64,20 @@ class GetByIdAndMember { } } + @Nested + class GetWithMemberById { + + @Test + void 트랜잭션_밖에서도_테이블_소유_회원_정보를_쓸_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = tableEntityGenerator.generate(member); + + CustomizeTable foundTable = customizeTableDomainRepository.getWithMemberById(table.getId()); + + assertThat(foundTable.getMember().getEmail()).isEqualTo("email@email.com"); + } + } + @Nested class GetCustomizeTimeBoxes { diff --git a/src/test/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepositoryTest.java new file mode 100644 index 00000000..4acd9a0f --- /dev/null +++ b/src/test/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepositoryTest.java @@ -0,0 +1,156 @@ +package com.debatetimer.domainrepository.sharing; + +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.sharing.SharingLog; +import com.debatetimer.domain.sharing.SharingLogStatus; +import com.debatetimer.domainrepository.BaseDomainRepositoryTest; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class SharingLogDomainRepositoryTest extends BaseDomainRepositoryTest { + + private static final LocalDateTime STARTED_AT = LocalDateTime.of(2026, 10, 7, 10, 0, 0); + + @Autowired + private SharingLogDomainRepository sharingLogDomainRepository; + + private SharingLog createSharing() { + return sharingLogDomainRepository.create(new SharingLog(1L, 2L, STARTED_AT)); + } + + @Nested + class Create { + + @Test + void 공유_기록을_생성한다() { + SharingLog created = createSharing(); + + SharingLog found = sharingLogDomainRepository.getById(created.getId()); + assertAll( + () -> assertThat(found.getTableId()).isEqualTo(1L), + () -> assertThat(found.getMemberId()).isEqualTo(2L), + () -> assertThat(found.getStartedAt()).isEqualTo(STARTED_AT), + () -> assertThat(found.getStatus()).isEqualTo(SharingLogStatus.SHARING) + ); + } + } + + @Nested + class GetById { + + @Test + void 공유_기록이_없으면_예외를_던진다() { + assertThatThrownBy(() -> sharingLogDomainRepository.getById(1L)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.SHARING_LOG_NOT_FOUND.getMessage()); + } + } + + @Nested + class IncreaseAudienceCount { + + @Test + void 청중_수를_늘린다() { + SharingLog created = createSharing(); + + sharingLogDomainRepository.increaseAudienceCount(created.getId()); + sharingLogDomainRepository.increaseAudienceCount(created.getId()); + + assertThat(sharingLogDomainRepository.getById(created.getId()).getAudienceCount()).isEqualTo(2); + } + } + + @Nested + class Finish { + + @Test + void 진행_중인_기록을_종료하고_종료된_기록을_반환한다() { + SharingLog created = createSharing(); + sharingLogDomainRepository.increaseAudienceCount(created.getId()); + LocalDateTime endedAt = STARTED_AT.plusMinutes(30); + + Optional finished = sharingLogDomainRepository.finish(created.getId(), endedAt); + + assertThat(finished).hasValueSatisfying(sharingLog -> assertAll( + () -> assertThat(sharingLog.getStatus()).isEqualTo(SharingLogStatus.FINISHED), + () -> assertThat(sharingLog.getEndedAt()).isEqualTo(endedAt), + () -> assertThat(sharingLog.getDurationSeconds()).isEqualTo(30 * 60), + () -> assertThat(sharingLog.getAudienceCount()).isEqualTo(1) + )); + } + + @Test + void 이미_끝난_기록은_바꾸지_않고_빈_값을_반환한다() { + SharingLog created = createSharing(); + sharingLogDomainRepository.abandon(created.getId(), STARTED_AT.plusMinutes(5)); + + Optional finished = sharingLogDomainRepository.finish(created.getId(), STARTED_AT.plusMinutes(30)); + + SharingLog found = sharingLogDomainRepository.getById(created.getId()); + assertAll( + () -> assertThat(finished).isEmpty(), + () -> assertThat(found.getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> assertThat(found.getEndedAt()).isEqualTo(STARTED_AT.plusMinutes(5)) + ); + } + } + + @Nested + class Abandon { + + @Test + void 진행_중인_기록을_중단한다() { + SharingLog created = createSharing(); + + sharingLogDomainRepository.abandon(created.getId(), STARTED_AT.plusMinutes(5)); + + SharingLog found = sharingLogDomainRepository.getById(created.getId()); + assertAll( + () -> assertThat(found.getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> assertThat(found.getEndedAt()).isEqualTo(STARTED_AT.plusMinutes(5)) + ); + } + + @Test + void 이미_종료된_기록은_중단으로_바꾸지_않는다() { + SharingLog created = createSharing(); + sharingLogDomainRepository.finish(created.getId(), STARTED_AT.plusMinutes(30)); + + sharingLogDomainRepository.abandon(created.getId(), STARTED_AT.plusMinutes(40)); + + assertThat(sharingLogDomainRepository.getById(created.getId()).getStatus()) + .isEqualTo(SharingLogStatus.FINISHED); + } + } + + @Nested + class FindAllFinishedStartedBetween { + + @Test + void 기간_안에_시작해_종료된_기록만_찾는다() { + SharingLog finished = createSharing(); + sharingLogDomainRepository.finish(finished.getId(), STARTED_AT.plusMinutes(30)); + SharingLog abandoned = createSharing(); + sharingLogDomainRepository.abandon(abandoned.getId(), STARTED_AT.plusMinutes(30)); + createSharing(); + + assertAll( + () -> assertThat(sharingLogDomainRepository.findAllFinishedStartedBetween( + STARTED_AT, STARTED_AT.plusDays(1))) + .extracting(SharingLog::getId) + .containsExactly(finished.getId()), + () -> assertThat(sharingLogDomainRepository.findAllFinishedStartedBetween( + STARTED_AT.plusSeconds(1), STARTED_AT.plusDays(1))) + .isEmpty() + ); + } + } +} diff --git a/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java b/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java deleted file mode 100644 index 4e019464..00000000 --- a/src/test/java/com/debatetimer/entity/sharing/SharingLogEntityTest.java +++ /dev/null @@ -1,90 +0,0 @@ -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(); - } - } -} diff --git a/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java b/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java index 58a3e843..5898efaa 100644 --- a/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java +++ b/src/test/java/com/debatetimer/service/admin/SharingStatsServiceTest.java @@ -5,6 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertAll; import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.sharing.SharingLog; +import com.debatetimer.domain.sharing.SharingLogStatus; import com.debatetimer.dto.admin.SharingDailyStatResponse; import com.debatetimer.dto.admin.SharingStatsResponse; import com.debatetimer.entity.sharing.SharingLogEntity; @@ -37,15 +39,16 @@ void setUp() { } private void saveFinished(LocalDateTime startedAt, long minutes) { - SharingLogEntity log = new SharingLogEntity(1L, member.getId(), startedAt); - log.finish(startedAt.plusMinutes(minutes)); - sharingLogRepository.save(log); + save(startedAt, startedAt.plusMinutes(minutes), SharingLogStatus.FINISHED); } private void saveAbandoned(LocalDateTime startedAt) { - SharingLogEntity log = new SharingLogEntity(1L, member.getId(), startedAt); - log.abandon(startedAt.plusMinutes(5)); - sharingLogRepository.save(log); + save(startedAt, startedAt.plusMinutes(5), SharingLogStatus.ABANDONED); + } + + private void save(LocalDateTime startedAt, LocalDateTime endedAt, SharingLogStatus status) { + sharingLogRepository.save(new SharingLogEntity( + new SharingLog(null, 1L, member.getId(), startedAt, endedAt, 0, status))); } @Nested diff --git a/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java b/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java index 32f2a803..81024b3a 100644 --- a/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java +++ b/src/test/java/com/debatetimer/service/sharing/SharingLogServiceTest.java @@ -9,11 +9,14 @@ import com.debatetimer.domain.customize.CustomizeBoxType; import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.sharing.ActiveSharing; +import com.debatetimer.domain.sharing.SharingLog; 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.domainrepository.sharing.SharingLogDomainRepository; import com.debatetimer.entity.customize.CustomizeTableEntity; import com.debatetimer.entity.sharing.SharingLogEntity; import com.debatetimer.event.sharing.SharingFinishedEvent; @@ -30,12 +33,8 @@ 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 { @@ -46,10 +45,10 @@ class SharingLogServiceTest extends BaseServiceTest { private SharingLogRepository sharingLogRepository; @Autowired - private CustomizeTableDomainRepository customizeTableDomainRepository; + private SharingLogDomainRepository sharingLogDomainRepository; @Autowired - private PlatformTransactionManager transactionManager; + private CustomizeTableDomainRepository customizeTableDomainRepository; private MutableClock clock; private ApplicationEventPublisher eventPublisher; @@ -61,8 +60,8 @@ class SharingLogServiceTest extends BaseServiceTest { void setUp() { clock = new MutableClock(NOW); eventPublisher = mock(ApplicationEventPublisher.class); - sharingLogService = transactional(new SharingLogService( - sharingLogRepository, customizeTableDomainRepository, eventPublisher, clock)); + sharingLogService = new SharingLogService( + sharingLogDomainRepository, customizeTableDomainRepository, eventPublisher, clock); member = memberGenerator.generate("chairman@email.com"); CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); @@ -72,15 +71,6 @@ void setUp() { 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); @@ -127,10 +117,10 @@ class Start { void 연결이_끊긴_뒤_유예_시간_안에_다시_시작하면_같은_기록을_쓴다() { sharingLogService.start(roomId); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE.minusSeconds(1)); + clock.advance(ActiveSharing.RECONNECT_GRACE.minusSeconds(1)); sharingLogService.start(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.closeExpired(); assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); @@ -140,7 +130,7 @@ class Start { void 연결이_끊긴_뒤_유예_시간이_지나_다시_시작하면_이전_기록을_정리하고_새_기록을_만든다() { sharingLogService.start(roomId); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.start(roomId); @@ -221,21 +211,29 @@ class JoinAudience { class CloseExpired { @Test - void 마지막_타임박스에서_연결이_끊긴_뒤_돌아오지_않으면_끊긴_시각으로_종료한다() { + void 마지막_타임박스에서_연결이_끊긴_뒤_돌아오지_않으면_끊긴_시각으로_종료하고_종료_이벤트를_발행한다() { sharingLogService.start(roomId); + sharingLogService.joinAudience(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); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.closeExpired(); + 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(disconnectedAt)), - () -> verify(eventPublisher, never()).publishEvent(any(Object.class)) + () -> assertThat(event.sharingLogId()).isEqualTo(log.getId()), + () -> assertThat(event.memberEmail()).isEqualTo("chairman@email.com"), + () -> assertThat(event.tableId()).isEqualTo(roomId), + () -> assertThat(event.durationSeconds()).isEqualTo(20 * 60), + () -> assertThat(event.audienceCount()).isEqualTo(1) ); } @@ -244,7 +242,7 @@ class CloseExpired { sharingLogService.start(roomId); sharingLogService.recordEvent(roomId, event(TimerEventType.NEXT, TIME_BOX_COUNT - 2)); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.closeExpired(); @@ -256,7 +254,7 @@ class CloseExpired { sharingLogService.start(roomId); sharingLogService.recordEvent(roomId, event(TimerEventType.BEFORE, TIME_BOX_COUNT - 1)); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.closeExpired(); @@ -264,22 +262,41 @@ class CloseExpired { } @Test - void 마지막_타임박스_전에_연결이_끊긴_뒤_돌아오지_않으면_중단으로_기록한다() { + void 마지막_타임박스_전에_연결이_끊긴_뒤_돌아오지_않으면_중단으로_기록하고_종료_이벤트를_발행하지_않는다() { sharingLogService.start(roomId); sharingLogService.recordEvent(roomId, event(TimerEventType.PLAY, 0)); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE); + clock.advance(ActiveSharing.RECONNECT_GRACE); sharingLogService.closeExpired(); - assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED); + assertAll( + () -> assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> verify(eventPublisher, never()).publishEvent(any(Object.class)) + ); + } + + @Test + void 다른_경로에서_이미_정리된_기록이면_종료_이벤트를_발행하지_않는다() { + sharingLogService.start(roomId); + sharingLogService.recordEvent(roomId, event(TimerEventType.PLAY, TIME_BOX_COUNT - 1)); + sharingLogService.disconnect(roomId); + clock.advance(ActiveSharing.STALE_THRESHOLD.plusSeconds(1)); + sharingLogService.abandonUntracked(); + + sharingLogService.closeExpired(); + + assertAll( + () -> assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.ABANDONED), + () -> verify(eventPublisher, never()).publishEvent(any(Object.class)) + ); } @Test void 유예_시간이_지나지_않았으면_정리하지_않는다() { sharingLogService.start(roomId); sharingLogService.disconnect(roomId); - clock.advance(SharingLogService.RECONNECT_GRACE.minusSeconds(1)); + clock.advance(ActiveSharing.RECONNECT_GRACE.minusSeconds(1)); sharingLogService.closeExpired(); @@ -289,7 +306,7 @@ class CloseExpired { @Test void 연결이_유지돼도_너무_오래_이어진_공유는_정리한다() { sharingLogService.start(roomId); - clock.advance(SharingLogService.STALE_THRESHOLD); + clock.advance(ActiveSharing.STALE_THRESHOLD); sharingLogService.closeExpired(); @@ -297,18 +314,42 @@ class CloseExpired { } @Test - void 추적하지_못하게_된_오래된_기록은_중단으로_정리한다() { - LocalDateTime startedAt = at(NOW.minus(SharingLogService.STALE_THRESHOLD).minusSeconds(1)); - sharingLogRepository.save(new SharingLogEntity(roomId, member.getId(), startedAt)); + void 추적하지_못하게_된_오래된_기록은_정리하지_않는다() { + LocalDateTime startedAt = at(NOW.minus(ActiveSharing.STALE_THRESHOLD).minusSeconds(1)); + sharingLogDomainRepository.create(new SharingLog(roomId, member.getId(), startedAt)); sharingLogService.closeExpired(); + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); + } + } + + @Nested + class AbandonUntracked { + + @Test + void 추적하지_못하게_된_오래된_기록은_중단으로_정리한다() { + LocalDateTime startedAt = at(NOW.minus(ActiveSharing.STALE_THRESHOLD).minusSeconds(1)); + sharingLogDomainRepository.create(new SharingLog(roomId, member.getId(), startedAt)); + + sharingLogService.abandonUntracked(); + SharingLogEntity log = onlyLog(); assertAll( () -> assertThat(log.getStatus()).isEqualTo(SharingLogStatus.ABANDONED), () -> assertThat(log.getEndedAt()).isNotNull() ); } + + @Test + void 오래되지_않은_기록은_정리하지_않는다() { + LocalDateTime startedAt = at(NOW.minus(ActiveSharing.STALE_THRESHOLD).plusSeconds(1)); + sharingLogDomainRepository.create(new SharingLog(roomId, member.getId(), startedAt)); + + sharingLogService.abandonUntracked(); + + assertThat(onlyLog().getStatus()).isEqualTo(SharingLogStatus.SHARING); + } } private static class MutableClock extends Clock {