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
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
package likelion.mlb.backendProject.domain.chat.bus;


import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ChatRedisPublisher {

private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
// JVM 런타임 식별자를 사용하여 노드 구분 (재시작 시마다 변경됨)
private final String nodeId = System.getProperty("node.id",
java.lang.management.ManagementFactory.getRuntimeMXBean().getName());

public void publishToRoom(String roomId, Map<String, Object> payload) throws JsonProcessingException {
System.out.println("roomId = "+roomId);
System.out.println("payload = "+payload);
private final String nodeId = java.util.UUID.randomUUID().toString();

//payload.put("_src", nodeId()); // 루프 방지 태그
public void publishToRoom(UUID roomId, Map<String, Object> payload) {
try {
payload.put("_src", nodeId); // 루프 방지 태그
String json = objectMapper.writeValueAsString(payload);
System.out.println("publishToRoom = "+json);

stringRedisTemplate.convertAndSend(roomId, json);

stringRedisTemplate.convertAndSend(ChatChannels.roomChannel(roomId), json);
} catch (Exception ignore) {
}
}

public String nodeId() { return nodeId; }
public String nodeId() {
return nodeId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,11 @@ public void onMessage(Message message, byte[] pattern) {
@SuppressWarnings("unchecked")
Map<String, Object> payload = objectMapper.readValue(body, Map.class);

// ✅ 내 노드에서 보낸 메시지는 이미 전달했으므로 Redis를 통한 재전달 방지
// (ChatRedisPublisher에서 nodeId를 _src로 설정)
Object srcNodeId = payload.get("_src");
if (srcNodeId != null) {
// 현재 노드의 ID와 비교 (ChatRedisPublisher의 nodeId와 비교하려면 주입받아야 함)
// 여기서는 간단히 현재 JVM의 식별자로 비교
String currentNodeId = System.getProperty("node.id",
java.lang.management.ManagementFactory.getRuntimeMXBean().getName());
if (currentNodeId.equals(srcNodeId)) {
return; // 내가 보낸 메시지는 중복 전송 방지
}
}
// ❌ 내 노드에서 보낸 것도 브로커로 전달해야 함 (중복루프 없음)
// if (publisher.nodeId().equals(payload.get("_src"))) return;

String topic = ChatChannels.toTopic(channel);
// ✅ STOMP 브로커로 전송 (다른 노드에서 온 메시지만)
// ✅ STOMP 브로커로 전송
messagingTemplate.convertAndSend(topic, payload);

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package likelion.mlb.backendProject.domain.chat.controller;


import com.fasterxml.jackson.core.JsonProcessingException;
import java.security.Principal;
import java.util.Map;
import java.util.UUID;
Expand All @@ -16,7 +15,6 @@
import likelion.mlb.backendProject.domain.chat.entity.ChatMessage;
import likelion.mlb.backendProject.domain.chat.service.ChatMessageService;
import likelion.mlb.backendProject.global.security.dto.CustomUserDetails;
import org.springframework.transaction.annotation.Transactional;

@Controller
@RequiredArgsConstructor
Expand All @@ -28,10 +26,10 @@ public class ChatMessagingController {
private final ChatRedisPublisher chatRedisPublisher;


@Transactional
@MessageMapping("/chat/send")
public void send(ChatSendRequest req,
Principal principal) throws JsonProcessingException {
@MessageMapping("/chat/{roomId}/send")
public void send(@DestinationVariable UUID roomId,
ChatSendRequest req,
Principal principal) {

UUID userId = null;
if (principal instanceof Authentication auth
Expand All @@ -40,35 +38,24 @@ public void send(ChatSendRequest req,
}

// ✅ 방 멤버인지 권한 체크 (아니면 바로 거절)
if (userId == null || !membershipRepository.isMember(req.getRoomId(), userId)) {
if (userId == null || !membershipRepository.isMember(roomId, userId)) {
throw new org.springframework.messaging.MessagingException("Not a member of this chat room");
// 또는 그냥 return; // 조용히 무시하고 싶으면
}


System.out.println("------------/chat/{roomId}/send 시작 ");

// 안전장치: 메시지의 roomId는 URL의 roomId로 강제
ChatMessage saved = chatMessageService.saveUserMessage(req.getRoomId(), userId, req.getContent());
ChatMessage saved = chatMessageService.saveUserMessage(roomId, userId, req.getContent());

Map<String, Object> payload = Map.of(
"id", saved.getId().toString(),
"chatRoomId", req.getRoomId().toString(),
"chatRoomId", roomId.toString(),
"type", saved.getMessageType().name(),
"content", saved.getContent(),
"userId", userId != null ? userId.toString() : null,
"createdAt", saved.getCreatedAt().toString()
);


//System.out.println("------------받은메세지 payload"+payload.get(0).toString());

System.out.println("------------받은메세지"+req.getContent());

// ✅ 즉시 현재 노드의 클라이언트에게 전달
messagingTemplate.convertAndSend("/topic/chat/" + req.getRoomId(), payload);

// ✅ 다른 노드를 위해 Redis로도 전달
chatRedisPublisher.publishToRoom("chat." + req.getRoomId(), payload);
chatRedisPublisher.publishToRoom(roomId, new java.util.HashMap<>(payload));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public ResponseEntity<Map<String, Object>> roomAlert(
"createdAt", saved.getCreatedAt().toString()
);
messagingTemplate.convertAndSend("/topic/chat/" + roomId, payload);
//chatRedisPublisher.publishToRoom(roomId, new java.util.HashMap<>(payload));
chatRedisPublisher.publishToRoom(roomId, new java.util.HashMap<>(payload));

return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId().toString()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
@Entity
@Table(name = "chat_read_state",
uniqueConstraints = @UniqueConstraint(columnNames = {"chat_room_id", "user_id"}))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatReadState {

@Id
Expand All @@ -29,8 +33,12 @@ public class ChatReadState {

@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (lastReadAt == null) lastReadAt = Instant.EPOCH; // 처음엔 1970-01-01
if (id == null) {
id = UUID.randomUUID();
}
if (lastReadAt == null) {
lastReadAt = Instant.EPOCH; // 처음엔 1970-01-01
}
}

public void mark(UUID messageId, Instant when) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ protected void doDispatch(UUID playerId,
"createdAt", saved.getCreatedAt().toString()
);
messagingTemplate.convertAndSend("/topic/chat/" + roomId, payload);
// chatRedisPublisher.publishToRoom(roomId, new java.util.HashMap<>(payload));
chatRedisPublisher.publishToRoom(roomId, new java.util.HashMap<>(payload));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public class DraftTimingService {
/** UTC(저장/비교용) */
private static final ZoneId UTC = ZoneOffset.UTC;
/** 드래프트 오픈/락 시각(정책 상수) — 필요 시 application.properties로 분리 가능 */
private static final LocalTime OPEN_TIME = LocalTime.of(8, 0);
private static final LocalTime LOCK_TIME = LocalTime.of(17, 5);
private static final LocalTime OPEN_TIME = LocalTime.of(20, 50);
private static final LocalTime LOCK_TIME = LocalTime.of(21, 00);

/**
* "현재 시각 이후" 첫 라운드의 드래프트 윈도우를 반환한다.
Expand Down Expand Up @@ -85,6 +85,7 @@ private RoundInfo toWindow(Round r) {

// 정책: 경기일(KST) 기준 2일 전이 draftDay
LocalDate draftDay = startedAtKst.toLocalDate().minusDays(2);

LocalDateTime openAt = LocalDateTime.of(draftDay, OPEN_TIME);
LocalDateTime lockAt = LocalDateTime.of(draftDay, LOCK_TIME);

Expand All @@ -101,4 +102,4 @@ private RoundInfo toWindow(Round r) {
lockAt.toString()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package likelion.mlb.backendProject.domain.player.entity;

import jakarta.persistence.*;

import java.util.stream.Collectors;

import likelion.mlb.backendProject.domain.draft.dto.DraftRequest;
import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto;
import likelion.mlb.backendProject.domain.team.entity.Team;
Expand All @@ -15,7 +18,6 @@
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

@Entity
@Table(name = "player")
Expand Down Expand Up @@ -141,7 +143,6 @@ public void updatePlayer(FplElement element,
String picUri = "https://resources.premierleague.com/premierleague25/photos/players/110x140/"
+ element.getCode() + ".png";
this.pic = picUri;
this.code = element.getCode();
this.status = element.getStatus();
this.fplId = element.getFplId();
this.news = element.getNews();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,5 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(matchHandler, "/ws/match") // 1) 엔드포인트 등록
.setHandshakeHandler(new AuthHandshakeHandler(jwtTokenProvider)) // 2) 핸드셰이크 단계 JWT 검증
.setAllowedOrigins(frontendHttpUrl); // 3) CORS: 지정 Origin만 허용
// .setAllowedOriginPatterns("https://*.example.com"); // (선택) 와일드카드 패턴 허용 예시
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,24 @@ public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
Info info = new Info()
.title("MLB Project API")
.version("v1.0.0")
.description("API documentation for the MLB project.");
.title("MLB Project API")
.version("v1.0.0")
.description("API documentation for the MLB project.");

// Security Scheme for JWT
String jwtSchemeName = "jwtAuth";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP) // HTTP 방식
.scheme("bearer")
.bearerFormat("JWT"));
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP) // HTTP 방식
.scheme("bearer")
.bearerFormat("JWT"));

return new OpenAPI()
.info(info)
.addSecurityItem(securityRequirement)
.components(components);
.info(info)
.addSecurityItem(securityRequirement)
.components(components);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package likelion.mlb.backendProject.global.configuration;

import io.swagger.v3.core.converter.ModelConverters;
import io.swagger.v3.oas.models.OpenAPI;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import likelion.mlb.backendProject.domain.match.dto.AssignDto;
import likelion.mlb.backendProject.domain.match.ws.message.DraftStartMessage;
import likelion.mlb.backendProject.domain.match.dto.MatchStatusResponse;
import likelion.mlb.backendProject.domain.match.ws.message.StatusMessage;
import likelion.mlb.backendProject.domain.match.ws.message.UserIdMessage;
import likelion.mlb.backendProject.domain.match.dto.RoundInfo;

/**
* HTTP 엔드포인트에서 직접 사용하지 않는 WebSocket 전용 DTO를
* Swagger(OpenAPI) Schemas 섹션에 등록하기 위한 설정.
*
* 요구사항:
* - Spring Boot 3.x + springdoc-openapi-starter-webmvc-ui 2.x
* - (권장) spring-boot-starter-validation (jakarta.validation 반영)
*
* build.gradle 예시:
* implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0"
* implementation "org.springframework.boot:spring-boot-starter-validation"
*/
@Configuration
public class OpenApiWsSchemasConfig {

/**
* springdoc 스펙 생성 시점에 WS DTO들을 components.schemas에 주입합니다.
*/
@Bean
public OpenApiCustomizer wsSchemasCustomizer() {
return openApi -> {
registerSchemas(openApi,
AssignDto.class,
DraftStartMessage.class,
MatchStatusResponse.class,
StatusMessage.class,
UserIdMessage.class,
RoundInfo.class
);
};
}


/**
* 지정한 클래스(들)를 swagger-core ModelConverters로 변환하여
* openApi.components.schemas에 등록합니다.
*/
private void registerSchemas(OpenAPI openApi, Class<?>... classes) {
for (Class<?> clazz : classes) {
ModelConverters.getInstance().read(clazz)
.forEach((name, schema) -> openApi.getComponents().addSchemas(name, schema));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnecti
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("draft.*"));
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("chat/*"));
return container;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,7 @@ protected Principal determineUser(ServerHttpRequest request, WebSocketHandler ws
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");

// TaskScheduler 생성 및 초기화
// org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler scheduler =
// new org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler();
// scheduler.setPoolSize(1);
// scheduler.setThreadNamePrefix("websocket-heartbeat-");
// scheduler.initialize();

registry.enableSimpleBroker("/topic", "/queue");
// .setTaskScheduler(scheduler)
// .setHeartbeatValue(new long[]{10000, 10000}); // 하트비트 간격 조정
registry.setUserDestinationPrefix("/user");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ public void onMessage(Message message, byte[] pattern) {
String msgBody = new String(message.getBody());
DraftRequest draftRequest = objectMapper.readValue(msgBody, DraftRequest.class);

// simpMessagingTemplate.convertAndSend("/topic/draft." + draftRequest.getDraftId(), draftRequest);
simpMessagingTemplate.convertAndSend("/chat/" + draftRequest.getDraftId(), draftRequest);
simpMessagingTemplate.convertAndSend("/topic/draft." + draftRequest.getDraftId(), draftRequest);
} catch (Exception e) {

}
Expand Down