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
2 changes: 1 addition & 1 deletion .github/workflows/Dev_CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 3
timeout-minutes: 15
env:
TEST_REPORT: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/Prod_CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 3
timeout-minutes: 15

steps:
- uses: actions/checkout@v4
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.debatetimer.config.sharing;

import com.debatetimer.controller.tool.jwt.AuthManager;
import com.debatetimer.domain.member.Member;
import com.debatetimer.exception.custom.DTClientErrorException;
import com.debatetimer.exception.errorcode.ClientErrorCode;
import com.debatetimer.service.auth.AuthService;
import com.debatetimer.service.customize.CustomizeService;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 사회자 채널 구독 권한을 확인한다.
* 사회자 채널을 구독하면 룸의 활성 사회자가 될 수 있으므로, 유효한 사회자 토큰을 가진 테이블 소유자만 허용한다.
*/
@Component
@RequiredArgsConstructor
public class ChairmanAuthorizer {

private final AuthManager authManager;
private final AuthService authService;
private final CustomizeService customizeService;

public void authorize(@Nullable String chairmanToken, long roomId) {
if (chairmanToken == null || chairmanToken.isBlank()) {
throw new DTClientErrorException(ClientErrorCode.UNAUTHORIZED_MEMBER);
}
String email = authManager.resolveChairmanToken(chairmanToken);
Member member = authService.getMember(email);
customizeService.validateTableOwner(roomId, member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public void configureMessageBroker(MessageBrokerRegistry registry) {

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 사회자의 구독(공유 시작)이 이어서 보낸 이벤트보다 먼저 처리되도록 세션별 수신 순서를 보존
registry.setPreserveReceiveOrder(true);
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(corsProperties.getCorsOrigin())
.withSockJS()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.debatetimer.controller.auth.AuthMember;
import com.debatetimer.controller.tool.jwt.AuthManager;
import com.debatetimer.domain.member.Member;
import com.debatetimer.domain.sharing.ChairmanTokenExpiration;
import com.debatetimer.dto.customize.response.CustomizeTableResponse;
import com.debatetimer.dto.sharing.response.ChairmanTokenResponse;
import com.debatetimer.service.customize.CustomizeService;
Expand All @@ -26,7 +27,7 @@ public ChairmanTokenResponse issueChairmanToken(
@PathVariable("tableId") long tableId
) {
long debateTime = customizeService.findDebateTime(tableId, member);
String chairmanToken = authManager.issueChairmanToken(member, debateTime * 2);
String chairmanToken = authManager.issueChairmanToken(member, ChairmanTokenExpiration.secondsFor(debateTime));
return new ChairmanTokenResponse(chairmanToken);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import com.debatetimer.controller.auth.AuthMember;
import com.debatetimer.domain.member.Member;
import com.debatetimer.dto.sharing.request.SharingRequest;
import com.debatetimer.exception.custom.DTClientErrorException;
import com.debatetimer.exception.errorcode.ClientErrorCode;
import com.debatetimer.service.sharing.SharingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
Expand All @@ -15,14 +18,20 @@
@RequiredArgsConstructor
public class SharingWebSocketController {

public static final String CHAIRMAN_SESSION_HEADER = "X-Chairman-Session";

private final SharingService sharingService;

@MessageMapping("/event/{roomId}")
public void share(
@AuthMember Member member,
@DestinationVariable(value = "roomId") long roomId,
@Header(name = CHAIRMAN_SESSION_HEADER, required = false) String chairmanSessionId,
@Valid @Payload SharingRequest request
) {
sharingService.share(roomId, request);
if (chairmanSessionId == null || chairmanSessionId.isBlank()) {
throw new DTClientErrorException(ClientErrorCode.CHAIRMAN_SESSION_REQUIRED);
}
sharingService.share(roomId, chairmanSessionId, request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.debatetimer.domain.sharing;

/**
* 서버가 사회자 채널(`/chairman/{roomId}`)로 보내는 알림 유형
*/
public enum ChairmanNoticeType {

/**
* 새 청중이 입장해 현재 상태 공유가 필요하다.
*/
SYNC_REQUEST,

/**
* 다른 사회자 세션이 활성 사회자가 되어 발행 권한을 잃었다.
*/
REPLACED,
;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.debatetimer.domain.sharing;

import java.time.Duration;

/**
* 사회자 토큰 유효시간 정책
* - 토론이 예상보다 길어져도 공유가 끊기지 않도록 전체 토론 시간의 2배를 준다.
* - 토론 시간이 짧거나 계산할 수 없는 테이블도 충분히 진행할 수 있도록 최소 유효시간을 보장한다.
*/
public final class ChairmanTokenExpiration {

static final Duration MIN_EXPIRATION = Duration.ofHours(3);
private static final int DEBATE_TIME_MULTIPLIER = 2;

private ChairmanTokenExpiration() {
}

public static long secondsFor(long debateTimeSeconds) {
return Math.max(debateTimeSeconds * DEBATE_TIME_MULTIPLIER, MIN_EXPIRATION.toSeconds());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public enum TimerEventType {
TEAM_SWITCH(Objects::nonNull),
SYNC(Objects::nonNull),
FINISHED(Objects::isNull),
// 활성 사회자가 없음을 청중에게 알리는 서버 전용 이벤트로, 사회자는 발행할 수 없다
CHAIRMAN_ABSENT(eventData -> false),
;

private final Predicate<Object> eventDataValidator;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package com.debatetimer.dto.sharing.request;

import com.debatetimer.domain.sharing.ChairmanNoticeType;
import jakarta.annotation.Nullable;

public record ChairmanSharingRequest(
long roomId
ChairmanNoticeType type,

long roomId,

@Nullable
String activeSessionId
) {

public static ChairmanSharingRequest syncRequest(long roomId) {
return new ChairmanSharingRequest(ChairmanNoticeType.SYNC_REQUEST, roomId, null);
}

public static ChairmanSharingRequest replaced(long roomId, String activeSessionId) {
return new ChairmanSharingRequest(ChairmanNoticeType.REPLACED, roomId, activeSessionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.debatetimer.event.sharing;

import com.debatetimer.service.sharing.SharingService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

/**
* 사회자의 연결이 끊기면 활성 사회자 등록을 해제해, 이후 입장한 청중이 사회자 부재를 바로 알 수 있게 한다.
*/
@Component
@RequiredArgsConstructor
public class ChairmanDisconnectListener {

private final SharingService sharingService;

@EventListener
public void handleDisconnect(SessionDisconnectEvent event) {
sharingService.leave(event.getSessionId());
}
}
Original file line number Diff line number Diff line change
@@ -1,41 +1,58 @@
package com.debatetimer.event.sharing;

import com.debatetimer.domain.sharing.TimerEventType;
import com.debatetimer.dto.sharing.request.ChairmanSharingRequest;
import com.debatetimer.dto.sharing.response.SharingResponse;
import com.debatetimer.config.sharing.ChairmanAuthorizer;
import com.debatetimer.controller.sharing.SharingWebSocketController;
import com.debatetimer.exception.custom.DTClientErrorException;
import com.debatetimer.exception.errorcode.ClientErrorCode;
import com.debatetimer.service.sharing.SharingRoomRegistry;
import com.debatetimer.service.sharing.SharingService;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.broker.SimpleBrokerMessageHandler;
import org.springframework.messaging.support.ExecutorChannelInterceptor;
import org.springframework.stereotype.Component;

/**
* simple broker가 청중의 룸 구독을 등록한 뒤에 후속 메시지를 보낸다.
* SessionSubscribeEvent는 구독 등록 전에 발행될 수 있어, 그 시점에 룸으로 보내면 새 청중이 받지 못할 수 있다.
* 사회자 채널 구독 권한을 확인하고, simple broker가 구독을 등록한 뒤에 후속 메시지를 보낸다.
* - 사회자 채널 구독은 활성 사회자가 되는 요청이므로, 구독이 등록되기 전(preSend)에 사회자 토큰과 테이블 소유 여부를 확인한다.
* - SessionSubscribeEvent는 구독 등록 전에 발행될 수 있어, 그 시점에 룸으로 보내면 새 청중이 받지 못할 수 있다.
*/
@Component
public class RoomSubscribeInterceptor implements ExecutorChannelInterceptor {

private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/";
private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/";

private final SimpMessagingTemplate messagingTemplate;
private final SharingRoomRegistry sharingRoomRegistry;
private final SharingService sharingService;
private final ChairmanAuthorizer chairmanAuthorizer;

public RoomSubscribeInterceptor(
@Lazy SimpMessagingTemplate messagingTemplate,
SharingRoomRegistry sharingRoomRegistry
@Lazy SharingService sharingService,
@Lazy ChairmanAuthorizer chairmanAuthorizer
) {
this.messagingTemplate = messagingTemplate;
this.sharingRoomRegistry = sharingRoomRegistry;
this.sharingService = sharingService;
this.chairmanAuthorizer = chairmanAuthorizer;
}

/**
* 권한이 없는 사회자 채널 구독은 예외를 던져 구독 자체를 거부한다.
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(message);
String destination = accessor.getDestination();
if (accessor.getMessageType() != SimpMessageType.SUBSCRIBE || destination == null
|| !destination.startsWith(CHAIRMAN_CHANNEL_PREFIX)) {
return message;
}

long roomId = parseRoomId(destination, CHAIRMAN_CHANNEL_PREFIX);
chairmanAuthorizer.authorize(accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION), roomId);
return message;
}

@Override
Expand All @@ -51,7 +68,7 @@ public void afterMessageHandled(Message<?> message, MessageChannel channel, Mess
}

if (destination.startsWith(CHAIRMAN_CHANNEL_PREFIX)) {
handleChairmanSubscribe(destination);
handleChairmanSubscribe(accessor, destination);
return;
}

Expand All @@ -61,23 +78,21 @@ public void afterMessageHandled(Message<?> message, MessageChannel channel, Mess
}

/**
* 사회자의 구독은 공유 시작을 뜻하므로, 이전에 종료된 룸이라도 다시 진행 상태로 되돌린다.
* 다른 기기에서 공유를 이어갈 수 있으므로 이전 세션의 버전 기준도 초기화한다.
* 사회자의 구독(preSend에서 권한 확인 완료)은 공유 시작을 뜻한다.
* 사회자 세션 식별자가 없는 구독은 발행 권한을 얻을 수 없으므로 활성 사회자로 등록하지 않는다.
*/
private void handleChairmanSubscribe(String destination) {
private void handleChairmanSubscribe(SimpMessageHeaderAccessor accessor, String destination) {
long roomId = parseRoomId(destination, CHAIRMAN_CHANNEL_PREFIX);
sharingRoomRegistry.reopen(roomId);
sharingRoomRegistry.resetVersion(roomId);
String chairmanSessionId = accessor.getFirstNativeHeader(SharingWebSocketController.CHAIRMAN_SESSION_HEADER);
if (chairmanSessionId == null || chairmanSessionId.isBlank()) {
return;
}
sharingService.startChairman(roomId, chairmanSessionId, accessor.getSessionId());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

private void handleAudienceSubscribe(String destination) {
long roomId = parseRoomId(destination, AUDIENCE_SUBSCRIBE_PREFIX);
if (sharingRoomRegistry.isFinished(roomId)) {
messagingTemplate.convertAndSend(AUDIENCE_SUBSCRIBE_PREFIX + roomId,
new SharingResponse(TimerEventType.FINISHED));
return;
}
messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId));
sharingService.joinAudience(roomId);
}

private long parseRoomId(String destination, String prefix) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public enum ClientErrorCode implements ResponseErrorCode {
ALREADY_VOTED_PARTICIPANT(HttpStatus.BAD_REQUEST, "이미 참여한 투표자 입니다"),

INVALID_ROOM_ID(HttpStatus.BAD_REQUEST, "잘못된 roomId 값입니다"),
CHAIRMAN_SESSION_REQUIRED(HttpStatus.BAD_REQUEST, "사회자 세션 식별자가 필요합니다"),

TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."),
NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ public interface CustomizeTimeBoxRepository extends Repository<CustomizeTimeBoxE

List<CustomizeTimeBoxEntity> findAllByCustomizeTable(CustomizeTableEntity table);

@Query("SELECT COALESCE(SUM(ctb.time), 0) FROM CustomizeTimeBoxEntity ctb WHERE ctb.customizeTable.id = :tableId")
// 시간 총량제 타임 박스는 time이 비어 있을 수 있으므로 양 팀 시간(팀당 시간 x 2)으로 대신 계산
@Query("""
SELECT COALESCE(SUM(COALESCE(ctb.time, ctb.timePerTeam * 2, 0)), 0)
FROM CustomizeTimeBoxEntity ctb
WHERE ctb.customizeTable.id = :tableId
""")
long sumTimeByTableId(long tableId);

@Query("DELETE FROM CustomizeTimeBoxEntity ctb WHERE ctb.customizeTable.id = :tableId")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.debatetimer.scheduler;

import com.debatetimer.service.sharing.ChairmanSessionRegistry;
import com.debatetimer.service.sharing.SharingRoomRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
Expand All @@ -12,9 +13,11 @@ public class SharingRoomCleanupScheduler {
private static final long INTERVAL_MILLIS = 60 * 60 * 1000L;

private final SharingRoomRegistry sharingRoomRegistry;
private final ChairmanSessionRegistry chairmanSessionRegistry;

@Scheduled(fixedRate = INTERVAL_MILLIS)
public void cleanupExpiredRooms() {
sharingRoomRegistry.removeExpired();
chairmanSessionRegistry.removeExpired();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public CustomizeTableResponse findTable(long tableId) {
return new CustomizeTableResponse(table, timeBoxes);
}

@Transactional(readOnly = true)
public void validateTableOwner(long tableId, Member member) {
customizeTableDomainRepository.getByIdAndMember(tableId, member);
}

@Transactional(readOnly = true)
public long findDebateTime(long tableId, Member member) {
CustomizeTable customizeTable = customizeTableDomainRepository.getByIdAndMember(tableId, member);
Expand Down
Loading
Loading