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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/main/java/com/debatetimer/domain/sharing/ActiveSharing.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/debatetimer/domain/sharing/SharingLog.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/debatetimer/domain/sharing/TimerEventType.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomizeTimeBox> getMemberCustomizeTimeBoxes(long tableId, Member member) {
CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SharingLog> 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<SharingLog> 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);
}
}
33 changes: 11 additions & 22 deletions src/main/java/com/debatetimer/entity/sharing/SharingLogEntity.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import java.time.LocalDateTime;

/**
* 사회자가 공유 종료(FINISHED)를 명시적으로 발행해 공유 기록이 종료되었음을 알린다.
* 공유 기록이 종료되었음을 알린다. 사회자가 직접 종료했거나, 마지막 타임박스에서 끊긴 뒤 돌아오지 않은 경우이다.
*/
public record SharingFinishedEvent(
long sharingLogId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

/**
* 공유 종료 기록이 저장된 뒤에 공유 통계를 알린다.
* 종료 이벤트는 기록을 저장한 트랜잭션이 끝난 뒤 발행되므로, 트랜잭션 밖에서 발행돼도 알린다.
* 알림은 공유 중계와 무관하므로 별도 스레드에서 보내고, 실패해도 기록만 남긴다.
*/
@Slf4j
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "토큰 기한이 만료되었습니다"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomizeTableEntity, Long> {
Expand All @@ -28,5 +29,14 @@ default CustomizeTableEntity getById(long tableId) {
.orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND));
}

// 트랜잭션 밖에서도 테이블 소유 회원 정보를 쓸 수 있도록 함께 조회한다
@EntityGraph(attributePaths = "member")
Optional<CustomizeTableEntity> findWithMemberById(long id);

default CustomizeTableEntity getWithMemberById(long tableId) {
return findWithMemberById(tableId)
.orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND));
}

void delete(CustomizeTableEntity table);
}
Loading
Loading