From 75a2653167ba5120d33d67a80eb785ea08e70dd9 Mon Sep 17 00:00:00 2001 From: wjkim9 Date: Fri, 8 Aug 2025 14:47:57 +0900 Subject: [PATCH 01/12] =?UTF-8?q?setting:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elasticsearch 의존성 제거 --- backendProject/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backendProject/build.gradle b/backendProject/build.gradle index 75a23c6..c697200 100644 --- a/backendProject/build.gradle +++ b/backendProject/build.gradle @@ -57,7 +57,7 @@ dependencies { /* ElasticSearch */ implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' - implementation 'co.elastic.clients:elasticsearch-java:8.13.0' + //implementation 'co.elastic.clients:elasticsearch-java:8.13.0' /* JSONObject */ implementation 'org.json:json:20240303' From 6988df8a4f4fc5a9b2b4c9b2f71e970277be8946 Mon Sep 17 00:00:00 2001 From: wjkim9 Date: Mon, 11 Aug 2025 11:21:10 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=93=9C=EB=9E=98=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시간이 지나면 자동으로 드래프트를 생성하고, 생성한 내용을 바탕으로 사용자들의 페이지를 이동 시킴 --- .../domain/draft/entity/Draft.java | 5 +- .../controller/AssignmentController.java | 31 ++++ .../domain/match/dto/AssignDto.java | 13 ++ .../domain/match/dto/DraftStartMessage.java | 16 +++ .../domain/match/entity/Participant.java | 2 +- .../repository/ParticipantRepository.java | 14 ++ .../match/service/AssignmentService.java | 37 +++++ .../match/service/DraftTimingService.java | 2 +- .../match/service/MatchTimeoutProcessor.java | 133 ++++++++++++++++++ .../global/exception/ErrorCode.java | 4 + .../security/config/SecurityConfig.java | 1 + 11 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java index 9f9ff73..d7329fc 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java @@ -19,8 +19,9 @@ public class Draft { @Column(name = "id", nullable = false) private UUID id; - @Column(name = "room_no", nullable = false) - private long roomNo; + @Column(name = "room_no", nullable = false, updatable = false, insertable = false) + @org.hibernate.annotations.Generated(org.hibernate.annotations.GenerationTime.INSERT) + private Long roomNo; @Column(name = "is_deleted", nullable = false) private boolean isDeleted; diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java new file mode 100644 index 0000000..a76cdab --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java @@ -0,0 +1,31 @@ +package likelion.mlb.backendProject.domain.match.controller; + +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.service.AssignmentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/match") +@RequiredArgsConstructor +public class AssignmentController { + + private final AssignmentService assignmentService; + + @GetMapping("/assignment") + public ResponseEntity getAssignment( + //@AuthenticationPrincipal CustomUser principal // FIXME: 실제 타입으로 + + ) { + //UUID userId = principal.getId(); // 로그인 유저 PK + UUID userId = UUID.fromString("969a6b7d-2a24-41ca-9f46-d1d2f8012844"); // FIXME 수정할 것 + AssignDto dto = assignmentService.getMyAssignmentOrThrow(userId); // 없으면 BaseException 던짐 + return ResponseEntity.ok(dto); + } +} + diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java new file mode 100644 index 0000000..87baa1e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java @@ -0,0 +1,13 @@ +package likelion.mlb.backendProject.domain.match.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.UUID; + +@Getter +@AllArgsConstructor +public class AssignDto { + private UUID draftId; + private short userNumber; +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java new file mode 100644 index 0000000..b7449d6 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java @@ -0,0 +1,16 @@ +package likelion.mlb.backendProject.domain.match.dto; + +import lombok.*; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DraftStartMessage { + private String type; // "DRAFT_START" + private UUID draftId; + private short userNumber; // 1~4 +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java index cdb1428..4986b86 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java @@ -22,7 +22,7 @@ public class Participant { private short userNumber; @Column(name = "isDummy", nullable = false) - private boolean isDummy; + private boolean dummy; @Column(name = "score", nullable = false) private short score = 0; diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java index 683b15b..a898edf 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java @@ -1,10 +1,24 @@ package likelion.mlb.backendProject.domain.match.repository; +import likelion.mlb.backendProject.domain.match.dto.AssignDto; import likelion.mlb.backendProject.domain.match.entity.Participant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.UUID; public interface ParticipantRepository extends JpaRepository { boolean existsByDraft_IdAndUser_Id(UUID draftId, UUID userId); + + @Query(""" + select new likelion.mlb.backendProject.domain.match.dto.AssignDto(p.draft.id, p.userNumber) + from Participant p + where p.dummy = false + and p.user.id = :userId + and p.draft.isDeleted = false + and p.draft.round.id = :roundId + """) + AssignDto findAssignment(@Param("userId") UUID userId, + @Param("roundId") UUID roundId); } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java new file mode 100644 index 0000000..99de804 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java @@ -0,0 +1,37 @@ +package likelion.mlb.backendProject.domain.match.service; + +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.repository.ParticipantRepository; +import likelion.mlb.backendProject.global.exception.BaseException; +import likelion.mlb.backendProject.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AssignmentService { + + private final ParticipantRepository participantRepository; + private final DraftTimingService draftTimingService; + + @Transactional(readOnly = true) + public AssignDto getMyAssignmentOrThrow(UUID userId) { + var roundInfo = draftTimingService.getNextDraftWindowOrThrow(); + var roundId = roundInfo.getId(); + try { + AssignDto dto = participantRepository.findAssignment(userId, roundId); + if (dto == null) { + throw new BaseException(ErrorCode.ASSIGNMENT_NOT_FOUND); // 404 등 + } + return dto; + } catch (IncorrectResultSizeDataAccessException e) { + // 데이터 이상(동시에 두 드래프트에 배정 등) + throw new BaseException(ErrorCode.ASSIGNMENT_CONFLICT); // 409 등 + } + } +} + diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java index d501439..4995d41 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java @@ -31,7 +31,7 @@ public RoundInfo getNextDraftWindowOrThrow() { LocalDateTime startedAtKST = r.getStartedAt().atZoneSameInstant(KST).toLocalDateTime(); LocalDate draftDay = startedAtKST.toLocalDate().minusDays(2); - LocalDateTime openAt = LocalDateTime.of(draftDay, LocalTime.of(10, 00)); + LocalDateTime openAt = LocalDateTime.of(draftDay, LocalTime.of(8, 00)); LocalDateTime lockAt = LocalDateTime.of(draftDay, LocalTime.of(18, 30)); return new RoundInfo( diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java new file mode 100644 index 0000000..aeeb098 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java @@ -0,0 +1,133 @@ +package likelion.mlb.backendProject.domain.match.service; + +import jakarta.transaction.Transactional; +import likelion.mlb.backendProject.domain.chat.service.ChatRoomService; +import likelion.mlb.backendProject.domain.draft.entity.Draft; +import likelion.mlb.backendProject.domain.draft.repository.DraftRepository; +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.dto.RoundInfo; +import likelion.mlb.backendProject.domain.match.entity.Participant; +import likelion.mlb.backendProject.domain.match.repository.ParticipantRepository; +import likelion.mlb.backendProject.domain.round.entity.Round; +import likelion.mlb.backendProject.domain.round.repository.RoundRepository; +import likelion.mlb.backendProject.domain.user.entity.User; +import likelion.mlb.backendProject.global.exception.BaseException; +import likelion.mlb.backendProject.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +@RequiredArgsConstructor +public class MatchTimeoutProcessor { + + private final RedisTemplate redisTemplate; + private final DraftRepository draftRepository; + private final ParticipantRepository participantRepository; + private final RoundRepository roundRepository; + + private final DraftTimingService draftTimingService; + private final ChatRoomService chatRoomService; + + private static final String SESSION_KEY = "match:session"; + + /** + * 타임아웃 시점: 대기중 유저를 4인 그룹으로 나눠 Draft/Participant 저장하고, + * 사용된 실제 유저는 Redis SET에서 제거. + * 반환: 실제 유저에게 전달할 (userId -> draftId, userNumber) 매핑 + */ + @Transactional + public Map processTimeoutAndInsert() { + // 1) 현재 대기 인원 스냅샷 + Set members = redisTemplate.opsForSet().members(SESSION_KEY); + if (members == null || members.isEmpty()) return Map.of(); + + // SET은 순서가 없으니 공정성을 위해 셔플 + List users = new ArrayList<>(members); + Collections.shuffle(users); + + // 2) 4인씩 그룹핑 + List> groups = splitBySize(users, 4); + + // 2-1) 마지막 그룹이 4 미만이면 더미로 채움 + if (!groups.isEmpty()) { + List last = groups.get(groups.size() - 1); + if (last.size() < 4) padWithDummies(last); + } + + //FIXME 구조 개멍청함 + RoundInfo roundInfo = draftTimingService.getNextDraftWindowOrThrow(); + UUID roundId = roundInfo.getId(); // RoundInfo에 id가 있다고 가정 + Round round = roundRepository.findById(roundId) + .orElseThrow(() -> new BaseException(ErrorCode.ROUND_NOT_FOUND)); + + Map result = new HashMap<>(); + List usedRealUserIds = new ArrayList<>(); + + // 3) 그룹별 Draft/Participant INSERT + // FIXME 이러면 누락? + for (List group : groups) { + if (group.size() != 4) continue; // 방은 항상 4명으로만 생성 + + // Draft 생성 + Draft draft = new Draft(); + draft.setId(UUID.randomUUID()); + draft.setRound(round); + draftRepository.save(draft); + chatRoomService.createRoom(draft.getId()); + + short userNumber = 1; + List participants = new ArrayList<>(4); + + for (String uid : group) { + Participant p = new Participant(); + p.setId(UUID.randomUUID()); + p.setDraft(draft); + p.setUserNumber(userNumber++); + + if (uid.startsWith("DUMMY:")) { + p.setDummy(true); + p.setUser(null); + } else { + p.setDummy(false); + // 프록시 참조만 잡아도 OK (엔티티 설계에 따라 변경) + User userRef = new User(); + userRef.setId(UUID.fromString(uid)); + p.setUser(userRef); + + // 결과 매핑 (나중에 페이지 이동에 씀) + result.put(uid, new AssignDto(draft.getId(), (short)(p.getUserNumber()))); + usedRealUserIds.add(uid); + } + participants.add(p); + } + participantRepository.saveAll(participants); + } + + // 4) 사용된 실제 유저만 Redis에서 제거 (전체 삭제 X) + if (!usedRealUserIds.isEmpty()) { + redisTemplate.opsForSet().remove(SESSION_KEY, usedRealUserIds.toArray()); + } + + return result; + } + + /** n개씩 분할 */ + private static List> splitBySize(List list, int n) { + List> res = new ArrayList<>(); + for (int i = 0; i < list.size(); i += n) { + res.add(new ArrayList<>(list.subList(i, Math.min(i + n, list.size())))); + } + return res; + } + + /** 4명이 될 때까지 더미 추가 */ + private static void padWithDummies(List group) { + int need = 4 - group.size(); + for (int i = 0; i < need; i++) { + group.add("DUMMY:" + UUID.randomUUID()); + } + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java index 4cb484c..3778852 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java @@ -26,6 +26,10 @@ public enum ErrorCode { /* 404 - Not Found */ USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다."), ROUND_NOT_FOUND(HttpStatus.NOT_FOUND, "ROUND_NOT_FOUND", "시작 예정 라운드를 찾을 수 없습니다."), + ASSIGNMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "ASSIGNMENT_NOT_FOUND", "배정된 드래프트가 없습니다."), + + /* 409 - CONFLICT */ + ASSIGNMENT_CONFLICT(HttpStatus.CONFLICT, "ASSIGNMENT_CONFLICT", "중복 배정이 감지되었습니다."), /* 500 - Internal Server Error */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버에 오류가 발생했습니다."); diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java index 5653526..0524cac 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java @@ -36,6 +36,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers("/login.html", "/login-success.html").permitAll() .requestMatchers("/ws/**").permitAll() // ✅ WebSocket 경로 허용 + .requestMatchers("/**").permitAll() .anyRequest().permitAll() ) .oauth2Login(oauth2 -> oauth2 From bacb7a2f2673ea61602474a788b06568048608b4 Mon Sep 17 00:00:00 2001 From: wjkim9 Date: Mon, 11 Aug 2025 14:25:24 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=ED=9B=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통합 후 수정 작업 진행중 --- .../domain/match/handler/MatchHandler.java | 85 +++++++++++-------- .../configuration/AuthHandshakeHandler.java | 48 +++++++++++ .../configuration/MatchWebSocketConfig.java | 11 ++- 3 files changed, 106 insertions(+), 38 deletions(-) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java index 2407689..a4675ff 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java @@ -3,17 +3,19 @@ import likelion.mlb.backendProject.domain.match.dto.MatchStatusResponse; import likelion.mlb.backendProject.domain.match.dto.RoundInfo; import likelion.mlb.backendProject.domain.match.service.MatchService; +import likelion.mlb.backendProject.global.security.dto.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.json.JSONObject; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; +import java.security.Principal; import java.util.Set; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Slf4j @@ -22,51 +24,66 @@ public class MatchHandler extends TextWebSocketHandler { private final MatchService matchService; - private final Set sessions = ConcurrentHashMap.newKeySet(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - //FIXME 로그인 구현 후 실제 유저 PK를 받아올 것. - String userId = UUID.randomUUID().toString(); - session.getAttributes().put("userId", userId); - sessions.add(session); + var principal = session.getPrincipal(); + if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { + log.warn("인증 정보가 없어 WebSocket 연결 종료"); + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } - log.info("🟢 WebSocket 연결됨: {}", session.getId()); + // CustomUserDetails → userId + Object p = auth.getPrincipal(); + String userId; + if (p instanceof CustomUserDetails cud && cud.getUser() != null) { + userId = cud.getUser().getId().toString(); + } else if (p instanceof String s) { + userId = s; // 토큰의 subject를 그대로 쓰는 경우 + } else { + log.warn("지원하지 않는 principal 타입: {}", p.getClass()); + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } - if (session.isOpen()) { - try { - // ✅ 1. USER_ID 전송 - session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); + session.getAttributes().put("userId", userId); + sessions.add(session); + log.info("🟢 WebSocket 연결됨: {} (userId={})", session.getId(), userId); - // ✅ 2. STATUS 즉시 전송 - MatchStatusResponse status = matchService.getCurrentStatus(); - JSONObject response = new JSONObject(); - response.put("type", "STATUS"); - response.put("count", status.getCount()); - response.put("remainingTime", status.getRemainingTime()); - response.put("state", status.getState()); + // 1) USER_ID 전송 + session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); - JSONObject round = new JSONObject(); - round.put("no", status.getRound().getNo()); - round.put("id", status.getRound().getId()); - round.put("openAt", status.getRound().getOpenAt()); - round.put("lockAt", status.getRound().getLockAt()); + // 2) STATUS 즉시 전송 + MatchStatusResponse status = matchService.getCurrentStatus(); + JSONObject response = new JSONObject(); + response.put("type", "STATUS"); + response.put("count", status.getCount()); + response.put("remainingTime", status.getRemainingTime()); + response.put("state", status.getState()); - response.put("round", round); + JSONObject round = new JSONObject(); + round.put("no", status.getRound().getNo()); + round.put("id", status.getRound().getId()); + round.put("openAt", status.getRound().getOpenAt()); + round.put("lockAt", status.getRound().getLockAt()); + response.put("round", round); - session.sendMessage(new TextMessage(response.toString())); + session.sendMessage(new TextMessage(response.toString())); + } - } catch (IOException e) { - log.warn("초기 메시지 전송 실패 → 세션 제거: {}", e.getMessage()); - sessions.remove(session); + private String extractUserId(WebSocketSession session) { + Principal principal = session.getPrincipal(); + if (principal instanceof org.springframework.security.core.Authentication auth) { + Object p = auth.getPrincipal(); + if (p instanceof CustomUserDetails cud) { + return cud.getUser().getId().toString(); // UUID } - } else { - log.warn("❌ 연결은 됐지만 session이 열려있지 않음"); } + return null; } - @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException { JSONObject json = new JSONObject(message.getPayload()); @@ -104,7 +121,6 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status) @Scheduled(fixedRate = 1000) public void broadcastStatus() { MatchStatusResponse status = matchService.getCurrentStatus(); - JSONObject response = new JSONObject(); response.put("type", "STATUS"); response.put("count", status.getCount()); @@ -123,14 +139,11 @@ public void broadcastStatus() { for (WebSocketSession s : sessions) { try { - if (s.isOpen()) { - s.sendMessage(new TextMessage(response.toString())); - } + if (s.isOpen()) s.sendMessage(new TextMessage(response.toString())); } catch (IOException e) { log.warn("WebSocket 전송 실패: {}", e.getMessage()); sessions.remove(s); } } } - } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java new file mode 100644 index 0000000..b993bdd --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java @@ -0,0 +1,48 @@ +package likelion.mlb.backendProject.global.configuration; + +import likelion.mlb.backendProject.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +@RequiredArgsConstructor +public class AuthHandshakeHandler extends DefaultHandshakeHandler { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected Principal determineUser(ServerHttpRequest request, + WebSocketHandler wsHandler, + Map attributes) { + + // 1) 세션/쿠키 기반: SecurityContext에 이미 인증이 있으면 그걸 그대로 사용 + Authentication ctxAuth = SecurityContextHolder.getContext().getAuthentication(); + if (ctxAuth != null && ctxAuth.isAuthenticated() + && !(ctxAuth instanceof AnonymousAuthenticationToken)) { + return ctxAuth; // Authentication 자체가 Principal + } + + // 2) JWT 기반: 쿼리 파라미터 token 로 들어온 경우 검증 후 Authentication 생성 + if (request instanceof ServletServerHttpRequest servlet) { + String token = servlet.getServletRequest().getParameter("token"); + if (token != null && !token.isBlank()) { + if (token.startsWith("Bearer ")) token = token.substring(7); + if (jwtTokenProvider.validateToken(token)) { + Authentication auth = jwtTokenProvider.getAuthentication(token); // 너희 구현 그대로 사용 + return auth; // Authentication 반환 → session.getPrincipal()로 접근 가능 + } + } + } + + // 인증 정보가 없으면 null → 이후 핸들러에서 거절 + return null; + } +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java index ec796de..5dcdb0b 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java @@ -1,7 +1,9 @@ package likelion.mlb.backendProject.global.configuration; import likelion.mlb.backendProject.domain.match.handler.MatchHandler; +import likelion.mlb.backendProject.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; @@ -13,10 +15,15 @@ public class MatchWebSocketConfig implements WebSocketConfigurer { private final MatchHandler matchHandler; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${frontend.http.url:${FRONTEND_HTTP_URL:http://localhost:5173}}") + private String frontendHttpUrl; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(matchHandler, "/ws/match") // ✅ 분리된 경로 - .setAllowedOrigins("*"); + registry.addHandler(matchHandler, "/ws/match") + .setHandshakeHandler(new AuthHandshakeHandler(jwtTokenProvider)) // ✅ 여기 한 줄 + .setAllowedOrigins(frontendHttpUrl); } } From ccc50859b176ce4c38f5cf0a73e372f3445e735d Mon Sep 17 00:00:00 2001 From: hwichoi0317 Date: Mon, 11 Aug 2025 17:27:06 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=EC=84=A0=EC=88=98=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20REDIS=EC=99=80=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20e?= =?UTF-8?q?lasticsearch=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlayerCacheController.java | 36 ++++ .../player/cache/dto/ElementTypeDto.java | 22 +++ .../domain/player/cache/dto/PlayerDto.java | 85 +++++++++ .../domain/player/cache/dto/TeamDto.java | 26 +++ .../cache/service/PlayerCacheService.java | 102 +++++++++++ .../controller/PlayerEsController.java | 51 ++++++ .../elasticsearch/dto/PlayerEsDocument.java | 73 ++++++++ .../repository/PlayerEsRepository.java | 14 ++ .../service/PlayerEsService.java | 168 ++++++++++++++++++ 9 files changed, 577 insertions(+) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java new file mode 100644 index 0000000..e92beb7 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java @@ -0,0 +1,36 @@ +package likelion.mlb.backendProject.domain.player.cache.controller; + +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; +import likelion.mlb.backendProject.domain.player.cache.service.PlayerCacheService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; + +@RestController +@RequestMapping("/api/players") +@RequiredArgsConstructor +public class PlayerCacheController { + + private final PlayerCacheService playerCacheService; + + // DB → Redis 저장 (캐시 초기화) + @PostMapping("/cache") + public ResponseEntity cachePlayers() { + playerCacheService.loadPlayersToRedis(); + return ResponseEntity.ok("Players cached to Redis with TTL."); + } + + // Redis → 조회 (캐시 미스 시 자동 로드) + @GetMapping + public ResponseEntity> getPlayers() { + List players = playerCacheService.getPlayersFromRedis(); + return ResponseEntity.ok(players); + } +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java new file mode 100644 index 0000000..f7fba44 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java @@ -0,0 +1,22 @@ +package likelion.mlb.backendProject.domain.player.cache.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ElementTypeDto { + private UUID id; + private Integer fplId; + private String pluralName; + private String krName; + private Integer squadMinPlay; + private Integer squadMaxPlay; + private Integer elementCount; +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java new file mode 100644 index 0000000..83143b6 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java @@ -0,0 +1,85 @@ +package likelion.mlb.backendProject.domain.player.cache.dto; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.entity.Player; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.*; +import java.util.stream.Collectors; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PlayerDto { + private UUID id; + private Integer code; + private String webName; + private String krName; + private String pic; + private boolean chanceOfPlayingNextRound; + private boolean chanceOfPlayingThisRound; + private short cost; + private String status; + private String news; + private Integer teamCode; + private Integer etId; + + private TeamDto team; + private ElementTypeDto elementType; + + /** + * PlayerDto 리스트 → PlayerEsDocument 리스트 변환 + */ + public static List toDocumentList(List playerDtoList) { + return playerDtoList.stream().map(p -> PlayerEsDocument.builder() + .id(p.getId()) +// .code(p.getCode()) + .webName(p.getWebName()) + .krName(p.getKrName()) + .pic(p.getPic()) + /* + .chanceOfPlayingNextRound(p.isChanceOfPlayingNextRound()) + .chanceOfPlayingThisRound(p.isChanceOfPlayingThisRound()) + .cost(p.getCost()) + .status(p.getStatus()) + .news(p.getNews()) + .teamCode(p.getTeamCode()) + .etId(p.getEtId()) + */ + + // team관련 설정 + /* + .teamId(p.getTeam().getId()) +// .code(p.getTeam().getCode()) + .teamFplId(p.getTeam().getFplId()) + */ + .teamName(p.getTeam().getName()) + .teamKrName(p.getTeam().getKrName()) + /* + .teamWin(p.getTeam().getWin()) + .teamDraw(p.getTeam().getDraw()) + .teamLose(p.getTeam().getLose()) + .teamPoints(p.getTeam().getPoints()) + .teamPlayed(p.getTeam().getPlayed()) + .teamPosition(p.getTeam().getPosition()) + */ + // 포지션(elementType) 관련 설정 + /* + .elementTypeId(p.getElementType().getId()) + .elementTypeFplId(p.getElementType().getFplId())*/ + .elementTypePluralName(p.getElementType().getPluralName()) + .elementTypeKrName(p.getElementType().getKrName()) + /* + .elementTypeSquadMinPlay(p.getElementType().getSquadMinPlay()) + .elementTypeSquadMaxPlay(p.getElementType().getSquadMaxPlay()) + .elementTypeElementCount(p.getElementType().getElementCount()) + */ + .build() + ).collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java new file mode 100644 index 0000000..70a069a --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java @@ -0,0 +1,26 @@ +package likelion.mlb.backendProject.domain.player.cache.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TeamDto { + private UUID id; + private Integer code; + private Integer fplId; + private String name; + private String krName; + private short win; + private short draw; + private short lose; + private short points; + private short played; + private short position; +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java new file mode 100644 index 0000000..239413d --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java @@ -0,0 +1,102 @@ +package likelion.mlb.backendProject.domain.player.cache.service; + +import likelion.mlb.backendProject.domain.player.cache.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; +import likelion.mlb.backendProject.domain.player.cache.dto.TeamDto; +import likelion.mlb.backendProject.domain.player.elasticsearch.service.PlayerEsService; +import likelion.mlb.backendProject.domain.player.entity.Player; +import likelion.mlb.backendProject.domain.player.repository.PlayerRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PlayerCacheService { + + private final PlayerRepository playerRepository; + private final PlayerEsService playerEsService; + private final RedisTemplate redisTemplate; + private static final String PLAYER_CACHE_KEY = "players:all"; + private static final long CACHE_TTL_SECONDS = 3600; // 1시간 + + /** + * Player 엔티티 리스트 → PlayerDto 리스트 변환 + */ + private List toDtoList(List players) { + return players.stream().map(p -> PlayerDto.builder() + .id(p.getId()) + .code(p.getCode()) + .webName(p.getWebName()) + .krName(p.getKrName()) + .pic(p.getPic()) + .chanceOfPlayingNextRound(p.isChanceOfPlayingNextRound()) + .chanceOfPlayingThisRound(p.isChanceOfPlayingThisRound()) + .cost(p.getCost()) + .status(p.getStatus()) + .news(p.getNews()) + .teamCode(p.getTeamCode()) + .etId(p.getEtId()) + .team(TeamDto.builder() + .id(p.getTeam().getId()) + .code(p.getTeam().getCode()) + .fplId(p.getTeam().getFplId()) + .name(p.getTeam().getName()) + .krName(p.getTeam().getKrName()) + .win(p.getTeam().getWin()) + .draw(p.getTeam().getDraw()) + .lose(p.getTeam().getLose()) + .points(p.getTeam().getPoints()) + .played(p.getTeam().getPlayed()) + .position(p.getTeam().getPosition()) + .build()) + .elementType(ElementTypeDto.builder() + .id(p.getElementType().getId()) + .fplId(p.getElementType().getFplId()) + .pluralName(p.getElementType().getPluralName()) + .krName(p.getElementType().getKrName()) + .squadMinPlay(p.getElementType().getSquadMinPlay()) + .squadMaxPlay(p.getElementType().getSquadMaxPlay()) + .elementCount(p.getElementType().getElementCount()) + .build()) + .build() + ).collect(Collectors.toList()); + } + + /** + * DB에서 선수 리스트를 조회하여 Redis에 DTO 형태로 저장 (TTL 적용) + */ + public void loadPlayersToRedis() { + List players = playerRepository.findAllWithTeamAndElementType(); + List playerDtoList = toDtoList(players); + redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); + + // Elasticsearch에도 저장 + playerEsService.saveAll(PlayerDto.toDocumentList(playerDtoList)); + } + + /** + * Redis에서 DTO 리스트 조회 (캐시 미스 시 DB 조회 후 저장) + */ + public List getPlayersFromRedis() { + Object cached = redisTemplate.opsForValue().get(PLAYER_CACHE_KEY); + + // 1. redis에 저장 된 데이터 가져오기 + if (cached instanceof List) { + @SuppressWarnings("unchecked") + List playerDtoCacheList = (List) cached; + return playerDtoCacheList; + } + // 2. 캐시 미스 → DB 조회 + Redis 저장 + List players = playerRepository.findAllWithTeamAndElementType(); + List playerDtoList = toDtoList(players); + redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); + return playerDtoList; + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java new file mode 100644 index 0000000..a9f7e42 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java @@ -0,0 +1,51 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.controller; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.elasticsearch.service.PlayerEsService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/playerEs") +public class PlayerEsController { + + private final PlayerEsService playerEsService; + +// private final KafkaTemplate kafkaTemplate; + + + // elasticsearch 검색 결과를 json List로 반환 + @GetMapping("/elasticsearch") + public ResponseEntity> elasticSearch( + @RequestParam(value = "keyword", defaultValue = "") String keyword) { + + // 검색어 정보 카프카 전송 + String userId = "1"; + String searchedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + +// SearchLogMessage message = new SearchLogMessage(keyword, userId, searchedAt); +// kafkaTemplate.send("search-log", message); // search-log 토픽으로 메시지 전달 + + return ResponseEntity.ok(playerEsService.search(keyword)); + } + + // 검색어 top 10개 가져오기 + /* + @GetMapping("/top-keywords") + public ResponseEntity> getTopKeyWord() { + List keywords = boardEsService.getTopSearchKeyword(); + return ResponseEntity.ok(keywords); + } + */ +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java new file mode 100644 index 0000000..7253ccc --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java @@ -0,0 +1,73 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.elasticsearch.annotations.Document; + +import java.util.UUID; + +/** +* 엘라스틱서치에 적용 될 문서를 자바 객체로 정의한 클래스 + * 엘라스틱 전용 DTO +* */ + +// 이게 없으면 클래스명도 같이 Elasticsearch에 저장이 됨. +// 그렇게 되면 PlayerEsDocument와 매칭이 안 되어 search에 오류가 생김. +@JsonIgnoreProperties(ignoreUnknown = true) +@Document(indexName = "player-index") // 인덱스명 +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PlayerEsDocument { + + @Id + private UUID id; + +// private Integer code; + private String webName; + private String krName; + private String pic; + /* + private boolean chanceOfPlayingNextRound; + private boolean chanceOfPlayingThisRound; + private short cost; + private String status; + private String news; + private Integer teamCode; + private Integer etId; + */ + // 소속팀 관련 정보 +// private UUID teamId; +// private Integer teamCode; // player엔티티의 teamCode와 겹쳐 우선 주석처리 +// private Integer teamFplId; + + private String teamName; + private String teamKrName; + + /* + private short teamWin; + private short teamDraw; + private short teamLose; + private short teamPoints; + private short teamPlayed; + private short teamPosition; + */ + + // 포지션 관련 정보 + /* + private UUID elementTypeId; + private Integer elementTypeFplId; + */ + private String elementTypePluralName; + private String elementTypeKrName; + /* + private Integer elementTypeSquadMinPlay; + private Integer elementTypeSquadMaxPlay; + private Integer elementTypeElementCount; + */ +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java new file mode 100644 index 0000000..2050feb --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java @@ -0,0 +1,14 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.repository; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlayerEsRepository extends ElasticsearchRepository { + + // 문서 ID로 데이터 삭제하는 쿼리 메서드 + void deleteById(String id); + +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java new file mode 100644 index 0000000..1cbb26a --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java @@ -0,0 +1,168 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.*; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.elasticsearch.repository.PlayerEsRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PlayerEsService { + + // 엘라스틱서치에 명령을 전달하는 자바 API + private final ElasticsearchClient elasticsearchClient; + + private final PlayerEsRepository playerEsRepository; + + // 단건 데이터 저장 메서드 + public void save(PlayerEsDocument document) { + playerEsRepository.save(document); + } + + // 복수 데이터 저장 메서드 + public void saveAll(List documentList) { + playerEsRepository.saveAll(documentList); + } + + // 데이터 삭제 메서드 + public void deleteById(String id) { + playerEsRepository.deleteById(id); + } + + // 검색 키워드와 페이지 번호와 페이지 크기를 받아서 엘라스틱서치에서 검색하는 메서드 + // 검색 된 정보와 페이징 정보도 함께 반환하도록 하기 위해 page 객체를 사용하여 반환 + public List search(String keyword) { + + try { + // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 + Query query; + + // 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 + if (keyword == null || keyword.isBlank()) { + query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 + // MatchAllQuery는 엘라스틱서치에서 조건 없이 모든 문서를 검색할 때 사용하는 쿼리 + } + // 검색어가 있을 때 + else { + // BoolQuery는 복수 조건을 조합할 때 사용하는 쿼리 + // 이 쿼리 안에서 여러 개의 조건을 나열 + // 예를 들어서 "백엔드"라는 키워드가 들어왔을 때 이 "백엔드"라는 키워드를 어떻게 분석해서 데이터를 보여줄 것인가를 작성 + query = BoolQuery.of(b ->{ + + /* + * must: 모두 일치해야 함 (AND) + * should: 하나라도 일치하면 됨 (OR) + * must_not: 해당 조건을 만족하면 제외 + * filter : must와 같지만 점수 계산 안함 (속도가 빠름) + */ + + // PrefixQuery는 해당 필드가 특정 단어로 시작하는 지 검사하는 쿼리(초성 검색) +// b.should(PrefixQuery.of(p->p.field("webName.chosung").value(keyword))._toQuery()); +// b.should(PrefixQuery.of(p->p.field("krName.chosung").value(keyword))._toQuery()); + + // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 + b.should(MatchQuery.of(m->m.field("webName.ngram").query(keyword))._toQuery()); + b.should(MatchQuery.of(m->m.field("krName.ngram").query(keyword))._toQuery()); + + // fuzziness: "AUTO"는 오타 허용 검색 기능을 자동으로 켜주는 설정 -> 유사도 계산을 매번 수행하기 때문에 느림 + // 짧은 키워드에는 사용 xxx + // 오타 허용 (오타허용은 match만 가능) + /* + if (keyword.length()>=3){ // 검색어가 3글자 이상일 시 + b.should(MatchQuery.of(m ->m.field("webName").query(keyword).fuzziness("AUTO"))._toQuery()); + b.should(MatchQuery.of(m ->m.field("krName").query(keyword).fuzziness("AUTO"))._toQuery()); + } + */ + + return b; + })._toQuery(); + } + // searchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체 + // 인덱스명, 페이징 정보, 쿼리를 포함한 검색 요청 + SearchRequest request = SearchRequest.of(s->s + .index("player-index") + .query(query) + + // 정렬 + +// .sort(sort -> sort +// .field(f -> f.field("id.keyword").order(SortOrder.Asc)) +// ) + +// .sort(sort->sort +// .field(f->f.field("id") // 정렬 대상 필드명 +// .order(SortOrder.Asc) +// ) +// ) // id 오름차순 + ); + + // SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체 + SearchResponse response = + // 엘라스틱서치에 명령을 전달하는 자바 API검색 요청을 담아서 응답 객체로 반환 + elasticsearchClient.search(request, PlayerEsDocument.class); + + // 위 응답객체에서 받은 검색 결과 중 문서만 추출해서 리스트로 만듬 + // hit는 엘라스틱서치에서 검색 된 문서 1개를 감싸고 있는 객체 + List content = response.hits() // 엘라스틱 서치 응답에서 hits(문서 검색결과) 전체를 꺼냄 + .hits() // 검색 결과 안에 개별 리스트를 가져옴 + .stream() // 자바 stream api를 사용 + .map(Hit::source) // 각 Hit 객체에서 실제 문서를 꺼내는 작업 + .collect(Collectors.toList()); // 위에서 꺼낸 객체를 자바 List에 넣음 + + return content; + } catch (IOException ioException) { + log.error("검색 오류 ", ioException.getMessage()); + throw new RuntimeException("검색 오류 중 발생 ", ioException); + } + + } + + /* + public List getTopSearchKeyword() { + + // TermsAggregation 엘라스틱서치의 집계 메서드 + TermsAggregation termsAggregation = TermsAggregation.of(t->t + .field("keyword.keyword") // 집계 기준 필드 + .size(10)); // 상위 10개만 불러오기 + + // 집계 요청 + SearchRequest request = SearchRequest.of(s->s + .index("search-log-index") // 집계를 가져올 인덱스 이름 + .size(0) // 집계만 가져오고 검색 결과는 가져오지 않음 + .aggregations("top_keywords", a->a.terms(termsAggregation)) // 인기 검색어 집계 + ); + try { + // 집계 응답 + SearchResponse response = client.search(request, Void.class); + return response.aggregations() // 응답 결과에서 집계 결과만 꺼냄 + .get("top_keywords") // 위에서 내가 집계요청한 이름 + .sterms() // String terms로 변환 + .buckets() // 집계 결과 버킷리스트 + .array() // 버킷 리스트를 배열로 변환 + .stream() // 배열을 스트림으로 변환 + .map(buket ->buket.key().stringValue()) // 버킷의 key값을 문자열로 꺼냄 + .map(Object::toString) // String으로 변환 + .collect(Collectors.toList()); // 스트림 결과를 리스트로 모아서 반환 + } catch (IOException ioException) { + throw new RuntimeException("검색어 통계 조회 중 오류 발생 ", ioException); + } + + } + */ + +} From 9c679e9384518b41cbda905da5e4ad57460580a8 Mon Sep 17 00:00:00 2001 From: hwichoi0317 Date: Mon, 11 Aug 2025 17:40:26 +0900 Subject: [PATCH 05/12] =?UTF-8?q?Redis=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD(=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EC=84=A4=EC=A0=95=20(key,=20value=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90)=20->=20value=20(=EC=97=AD)=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EC=84=A4=EC=A0=95=EC=9D=80=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=EC=9D=B4=20=EC=95=84=EB=8B=8C=20Object=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/configuration/RedisConfig.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java index 74e7f03..40e1975 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java @@ -13,6 +13,7 @@ import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -41,11 +42,17 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - // 문자열 직렬화 설정 (key, value 모두) + /* + * 문자열 직렬화 설정 (key, value 모두) -> value (역)직렬화 설정은 문자열이 아닌 Object로 변경 + * new StringRedisSerializer() -> new GenericJackson2JsonRedisSerializer() + * */ template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new StringRedisSerializer()); +// template.setValueSerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new StringRedisSerializer()); +// template.setHashValueSerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; } From 03f45446aebe3ab93eafbfe17e6c1f97c62cccac Mon Sep 17 00:00:00 2001 From: hwichoi0317 Date: Tue, 12 Aug 2025 12:30:24 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=EC=84=A0=EC=88=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlayerCacheController.java | 12 ++-- .../player/cache/dto/ElementTypeDto.java | 22 ------ .../domain/player/cache/dto/PlayerDto.java | 68 ++++++------------- .../domain/player/cache/dto/TeamDto.java | 26 ------- .../cache/service/PlayerCacheService.java | 54 ++------------- .../controller/PlayerEsController.java | 18 ----- .../elasticsearch/dto/PlayerEsDocument.java | 53 +++------------ .../repository/PlayerEsRepository.java | 3 - .../service/PlayerEsService.java | 58 +--------------- .../domain/player/entity/Player.java | 24 +++++++ 10 files changed, 67 insertions(+), 271 deletions(-) delete mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java delete mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java index e92beb7..e141869 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java @@ -14,7 +14,7 @@ import java.util.*; @RestController -@RequestMapping("/api/players") +@RequestMapping("/api/playerCache") @RequiredArgsConstructor public class PlayerCacheController { @@ -22,15 +22,15 @@ public class PlayerCacheController { // DB → Redis 저장 (캐시 초기화) @PostMapping("/cache") - public ResponseEntity cachePlayers() { + public ResponseEntity cachePlayerList() { playerCacheService.loadPlayersToRedis(); - return ResponseEntity.ok("Players cached to Redis with TTL."); + return ResponseEntity.ok("Player list cached to Redis."); } // Redis → 조회 (캐시 미스 시 자동 로드) @GetMapping - public ResponseEntity> getPlayers() { - List players = playerCacheService.getPlayersFromRedis(); - return ResponseEntity.ok(players); + public ResponseEntity> getPlayerList() { + List playerList = playerCacheService.getPlayersFromRedis(); + return ResponseEntity.ok(playerList); } } \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java deleted file mode 100644 index f7fba44..0000000 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/ElementTypeDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package likelion.mlb.backendProject.domain.player.cache.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class ElementTypeDto { - private UUID id; - private Integer fplId; - private String pluralName; - private String krName; - private Integer squadMinPlay; - private Integer squadMaxPlay; - private Integer elementCount; -} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java index 83143b6..b871339 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java @@ -15,21 +15,21 @@ @AllArgsConstructor @Builder public class PlayerDto { - private UUID id; - private Integer code; - private String webName; - private String krName; - private String pic; - private boolean chanceOfPlayingNextRound; - private boolean chanceOfPlayingThisRound; - private short cost; - private String status; - private String news; - private Integer teamCode; - private Integer etId; + private UUID id; // player pk값 - private TeamDto team; - private ElementTypeDto elementType; + private String webName; // player 영어 이름 + + private String krName; // player 한글 이름 + + private String pic; // player 사진 + + private String teamName; // 소속팀 영어명 + + private String teamKrName; // 소속팀 한글명 + + private String elementTypePluralName; // 포지션 영어명 + + private String elementTypeKrName; // 포지션 한글명 /** * PlayerDto 리스트 → PlayerEsDocument 리스트 변환 @@ -37,47 +37,17 @@ public class PlayerDto { public static List toDocumentList(List playerDtoList) { return playerDtoList.stream().map(p -> PlayerEsDocument.builder() .id(p.getId()) -// .code(p.getCode()) .webName(p.getWebName()) .krName(p.getKrName()) .pic(p.getPic()) - /* - .chanceOfPlayingNextRound(p.isChanceOfPlayingNextRound()) - .chanceOfPlayingThisRound(p.isChanceOfPlayingThisRound()) - .cost(p.getCost()) - .status(p.getStatus()) - .news(p.getNews()) - .teamCode(p.getTeamCode()) - .etId(p.getEtId()) - */ // team관련 설정 - /* - .teamId(p.getTeam().getId()) -// .code(p.getTeam().getCode()) - .teamFplId(p.getTeam().getFplId()) - */ - .teamName(p.getTeam().getName()) - .teamKrName(p.getTeam().getKrName()) - /* - .teamWin(p.getTeam().getWin()) - .teamDraw(p.getTeam().getDraw()) - .teamLose(p.getTeam().getLose()) - .teamPoints(p.getTeam().getPoints()) - .teamPlayed(p.getTeam().getPlayed()) - .teamPosition(p.getTeam().getPosition()) - */ + .teamName(p.getTeamName()) + .teamKrName(p.getTeamKrName()) + // 포지션(elementType) 관련 설정 - /* - .elementTypeId(p.getElementType().getId()) - .elementTypeFplId(p.getElementType().getFplId())*/ - .elementTypePluralName(p.getElementType().getPluralName()) - .elementTypeKrName(p.getElementType().getKrName()) - /* - .elementTypeSquadMinPlay(p.getElementType().getSquadMinPlay()) - .elementTypeSquadMaxPlay(p.getElementType().getSquadMaxPlay()) - .elementTypeElementCount(p.getElementType().getElementCount()) - */ + .elementTypePluralName(p.getElementTypePluralName()) + .elementTypeKrName(p.getElementTypeKrName()) .build() ).collect(Collectors.toList()); } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java deleted file mode 100644 index 70a069a..0000000 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/TeamDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package likelion.mlb.backendProject.domain.player.cache.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class TeamDto { - private UUID id; - private Integer code; - private Integer fplId; - private String name; - private String krName; - private short win; - private short draw; - private short lose; - private short points; - private short played; - private short position; -} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java index 239413d..f48385d 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java @@ -1,8 +1,6 @@ package likelion.mlb.backendProject.domain.player.cache.service; -import likelion.mlb.backendProject.domain.player.cache.dto.ElementTypeDto; import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; -import likelion.mlb.backendProject.domain.player.cache.dto.TeamDto; import likelion.mlb.backendProject.domain.player.elasticsearch.service.PlayerEsService; import likelion.mlb.backendProject.domain.player.entity.Player; import likelion.mlb.backendProject.domain.player.repository.PlayerRepository; @@ -14,7 +12,6 @@ import java.util.*; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -24,57 +21,14 @@ public class PlayerCacheService { private final PlayerEsService playerEsService; private final RedisTemplate redisTemplate; private static final String PLAYER_CACHE_KEY = "players:all"; - private static final long CACHE_TTL_SECONDS = 3600; // 1시간 - - /** - * Player 엔티티 리스트 → PlayerDto 리스트 변환 - */ - private List toDtoList(List players) { - return players.stream().map(p -> PlayerDto.builder() - .id(p.getId()) - .code(p.getCode()) - .webName(p.getWebName()) - .krName(p.getKrName()) - .pic(p.getPic()) - .chanceOfPlayingNextRound(p.isChanceOfPlayingNextRound()) - .chanceOfPlayingThisRound(p.isChanceOfPlayingThisRound()) - .cost(p.getCost()) - .status(p.getStatus()) - .news(p.getNews()) - .teamCode(p.getTeamCode()) - .etId(p.getEtId()) - .team(TeamDto.builder() - .id(p.getTeam().getId()) - .code(p.getTeam().getCode()) - .fplId(p.getTeam().getFplId()) - .name(p.getTeam().getName()) - .krName(p.getTeam().getKrName()) - .win(p.getTeam().getWin()) - .draw(p.getTeam().getDraw()) - .lose(p.getTeam().getLose()) - .points(p.getTeam().getPoints()) - .played(p.getTeam().getPlayed()) - .position(p.getTeam().getPosition()) - .build()) - .elementType(ElementTypeDto.builder() - .id(p.getElementType().getId()) - .fplId(p.getElementType().getFplId()) - .pluralName(p.getElementType().getPluralName()) - .krName(p.getElementType().getKrName()) - .squadMinPlay(p.getElementType().getSquadMinPlay()) - .squadMaxPlay(p.getElementType().getSquadMaxPlay()) - .elementCount(p.getElementType().getElementCount()) - .build()) - .build() - ).collect(Collectors.toList()); - } + private static final long CACHE_TTL_SECONDS = 7200; // 2시간 /** * DB에서 선수 리스트를 조회하여 Redis에 DTO 형태로 저장 (TTL 적용) */ public void loadPlayersToRedis() { List players = playerRepository.findAllWithTeamAndElementType(); - List playerDtoList = toDtoList(players); + List playerDtoList = Player.toDtoList(players); redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); // Elasticsearch에도 저장 @@ -94,8 +48,8 @@ public List getPlayersFromRedis() { return playerDtoCacheList; } // 2. 캐시 미스 → DB 조회 + Redis 저장 - List players = playerRepository.findAllWithTeamAndElementType(); - List playerDtoList = toDtoList(players); + List playerList = playerRepository.findAllWithTeamAndElementType(); + List playerDtoList = Player.toDtoList(playerList); redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); return playerDtoList; } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java index a9f7e42..a739065 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java @@ -22,30 +22,12 @@ public class PlayerEsController { private final PlayerEsService playerEsService; -// private final KafkaTemplate kafkaTemplate; - // elasticsearch 검색 결과를 json List로 반환 @GetMapping("/elasticsearch") public ResponseEntity> elasticSearch( @RequestParam(value = "keyword", defaultValue = "") String keyword) { - // 검색어 정보 카프카 전송 - String userId = "1"; - String searchedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); - -// SearchLogMessage message = new SearchLogMessage(keyword, userId, searchedAt); -// kafkaTemplate.send("search-log", message); // search-log 토픽으로 메시지 전달 - return ResponseEntity.ok(playerEsService.search(keyword)); } - - // 검색어 top 10개 가져오기 - /* - @GetMapping("/top-keywords") - public ResponseEntity> getTopKeyWord() { - List keywords = boardEsService.getTopSearchKeyword(); - return ResponseEntity.ok(keywords); - } - */ } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java index 7253ccc..1ee9b2e 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java @@ -15,8 +15,6 @@ * 엘라스틱 전용 DTO * */ -// 이게 없으면 클래스명도 같이 Elasticsearch에 저장이 됨. -// 그렇게 되면 PlayerEsDocument와 매칭이 안 되어 search에 오류가 생김. @JsonIgnoreProperties(ignoreUnknown = true) @Document(indexName = "player-index") // 인덱스명 @Data @@ -26,48 +24,19 @@ public class PlayerEsDocument { @Id - private UUID id; + private UUID id; // player pk값 -// private Integer code; - private String webName; - private String krName; - private String pic; - /* - private boolean chanceOfPlayingNextRound; - private boolean chanceOfPlayingThisRound; - private short cost; - private String status; - private String news; - private Integer teamCode; - private Integer etId; - */ - // 소속팀 관련 정보 -// private UUID teamId; -// private Integer teamCode; // player엔티티의 teamCode와 겹쳐 우선 주석처리 -// private Integer teamFplId; + private String webName; // player 영어 이름 - private String teamName; - private String teamKrName; + private String krName; // player 한글 이름 - /* - private short teamWin; - private short teamDraw; - private short teamLose; - private short teamPoints; - private short teamPlayed; - private short teamPosition; - */ + private String pic; // player 사진 - // 포지션 관련 정보 - /* - private UUID elementTypeId; - private Integer elementTypeFplId; - */ - private String elementTypePluralName; - private String elementTypeKrName; - /* - private Integer elementTypeSquadMinPlay; - private Integer elementTypeSquadMaxPlay; - private Integer elementTypeElementCount; - */ + private String teamName; // 소속팀 영어명 + + private String teamKrName; // 소속팀 한글명 + + private String elementTypePluralName; // 포지션 영어명 + + private String elementTypeKrName; // 포지션 한글명 } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java index 2050feb..a4e6667 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java @@ -8,7 +8,4 @@ @Repository public interface PlayerEsRepository extends ElasticsearchRepository { - // 문서 ID로 데이터 삭제하는 쿼리 메서드 - void deleteById(String id); - } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java index 1cbb26a..7fba5f3 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java @@ -44,8 +44,7 @@ public void deleteById(String id) { playerEsRepository.deleteById(id); } - // 검색 키워드와 페이지 번호와 페이지 크기를 받아서 엘라스틱서치에서 검색하는 메서드 - // 검색 된 정보와 페이징 정보도 함께 반환하도록 하기 위해 page 객체를 사용하여 반환 + // 검색 키워드를 받아서 엘라스틱서치에서 검색하는 메서드 public List search(String keyword) { try { @@ -55,13 +54,11 @@ public List search(String keyword) { // 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 if (keyword == null || keyword.isBlank()) { query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 - // MatchAllQuery는 엘라스틱서치에서 조건 없이 모든 문서를 검색할 때 사용하는 쿼리 } // 검색어가 있을 때 else { // BoolQuery는 복수 조건을 조합할 때 사용하는 쿼리 // 이 쿼리 안에서 여러 개의 조건을 나열 - // 예를 들어서 "백엔드"라는 키워드가 들어왔을 때 이 "백엔드"라는 키워드를 어떻게 분석해서 데이터를 보여줄 것인가를 작성 query = BoolQuery.of(b ->{ /* @@ -71,16 +68,12 @@ public List search(String keyword) { * filter : must와 같지만 점수 계산 안함 (속도가 빠름) */ - // PrefixQuery는 해당 필드가 특정 단어로 시작하는 지 검사하는 쿼리(초성 검색) -// b.should(PrefixQuery.of(p->p.field("webName.chosung").value(keyword))._toQuery()); -// b.should(PrefixQuery.of(p->p.field("krName.chosung").value(keyword))._toQuery()); - // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 b.should(MatchQuery.of(m->m.field("webName.ngram").query(keyword))._toQuery()); b.should(MatchQuery.of(m->m.field("krName.ngram").query(keyword))._toQuery()); // fuzziness: "AUTO"는 오타 허용 검색 기능을 자동으로 켜주는 설정 -> 유사도 계산을 매번 수행하기 때문에 느림 - // 짧은 키워드에는 사용 xxx + // 짧은 키워드에는 사용 X // 오타 허용 (오타허용은 match만 가능) /* if (keyword.length()>=3){ // 검색어가 3글자 이상일 시 @@ -93,22 +86,10 @@ public List search(String keyword) { })._toQuery(); } // searchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체 - // 인덱스명, 페이징 정보, 쿼리를 포함한 검색 요청 + // 인덱스명, 쿼리를 포함한 검색 요청 SearchRequest request = SearchRequest.of(s->s .index("player-index") .query(query) - - // 정렬 - -// .sort(sort -> sort -// .field(f -> f.field("id.keyword").order(SortOrder.Asc)) -// ) - -// .sort(sort->sort -// .field(f->f.field("id") // 정렬 대상 필드명 -// .order(SortOrder.Asc) -// ) -// ) // id 오름차순 ); // SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체 @@ -131,38 +112,5 @@ public List search(String keyword) { } } - - /* - public List getTopSearchKeyword() { - - // TermsAggregation 엘라스틱서치의 집계 메서드 - TermsAggregation termsAggregation = TermsAggregation.of(t->t - .field("keyword.keyword") // 집계 기준 필드 - .size(10)); // 상위 10개만 불러오기 - - // 집계 요청 - SearchRequest request = SearchRequest.of(s->s - .index("search-log-index") // 집계를 가져올 인덱스 이름 - .size(0) // 집계만 가져오고 검색 결과는 가져오지 않음 - .aggregations("top_keywords", a->a.terms(termsAggregation)) // 인기 검색어 집계 - ); - try { - // 집계 응답 - SearchResponse response = client.search(request, Void.class); - return response.aggregations() // 응답 결과에서 집계 결과만 꺼냄 - .get("top_keywords") // 위에서 내가 집계요청한 이름 - .sterms() // String terms로 변환 - .buckets() // 집계 결과 버킷리스트 - .array() // 버킷 리스트를 배열로 변환 - .stream() // 배열을 스트림으로 변환 - .map(buket ->buket.key().stringValue()) // 버킷의 key값을 문자열로 꺼냄 - .map(Object::toString) // String으로 변환 - .collect(Collectors.toList()); // 스트림 결과를 리스트로 모아서 반환 - } catch (IOException ioException) { - throw new RuntimeException("검색어 통계 조회 중 오류 발생 ", ioException); - } - - } - */ } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java index 8bfff45..433d9f5 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java @@ -1,6 +1,7 @@ package likelion.mlb.backendProject.domain.player.entity; import jakarta.persistence.*; +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; import likelion.mlb.backendProject.domain.team.entity.Team; import likelion.mlb.backendProject.global.jpa.entity.BaseTime; import likelion.mlb.backendProject.global.staticdata.dto.bootstrap.FplElement; @@ -13,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; @Entity @Table(name = "player") @@ -144,5 +146,27 @@ public void updatePlayer(FplElement element, this.team = teamMap.get(element.getTeam()); this.elementType = typeMap.get(element.getElementType()); } + + /** + * Player 엔티티 리스트 → PlayerDto 리스트 변환 + */ + public static List toDtoList(List players) { + return players.stream().map(p -> PlayerDto.builder() + .id(p.getId()) + .webName(p.getWebName()) + .krName(p.getKrName()) + .pic(p.getPic()) + + // team관련 설정 + .teamName(p.getTeam().getName()) + .teamKrName(p.getTeam().getKrName()) + + // 포지션(elementType) 관련 설정 + .elementTypePluralName(p.getElementType().getPluralName()) + .elementTypeKrName(p.getElementType().getKrName()) + .build() + ).collect(Collectors.toList()); + } + } From 3619842fcad3b46d34bb2bd5de7268e7c21d0db7 Mon Sep 17 00:00:00 2001 From: wjkim9 Date: Wed, 13 Aug 2025 14:52:54 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EB=93=9C=EB=9E=98=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 드래프트 생성 참가자 배정 채팅방 생성 --- .../domain/match/handler/MatchHandler.java | 185 +++++++++++++----- .../match/scheduler/MatchScheduler.java | 31 +++ .../match/service/DraftTimingService.java | 2 +- .../match/service/MatchTimeoutProcessor.java | 29 ++- .../global/configuration/KafkaConfig.java | 42 ++-- .../security/config/SecurityConfig.java | 12 +- .../global/security/jwt/JwtTokenProvider.java | 12 ++ .../oauth/CustomUserDetailsService.java | 22 +++ .../application-dev.example.properties | 40 ---- 9 files changed, 238 insertions(+), 137 deletions(-) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java delete mode 100644 backendProject/src/main/resources/application-dev.example.properties diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java index a4675ff..814b1a6 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java @@ -1,8 +1,8 @@ package likelion.mlb.backendProject.domain.match.handler; -import likelion.mlb.backendProject.domain.match.dto.MatchStatusResponse; -import likelion.mlb.backendProject.domain.match.dto.RoundInfo; +import likelion.mlb.backendProject.domain.match.dto.AssignDto; import likelion.mlb.backendProject.domain.match.service.MatchService; +import likelion.mlb.backendProject.domain.match.service.MatchTimeoutProcessor; import likelion.mlb.backendProject.global.security.dto.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,8 +15,11 @@ import java.io.IOException; import java.security.Principal; +import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; @Slf4j @Component @@ -24,53 +27,58 @@ public class MatchHandler extends TextWebSocketHandler { private final MatchService matchService; + private final MatchTimeoutProcessor matchTimeoutProcessor; + private final Set sessions = ConcurrentHashMap.newKeySet(); + private final Map latestByUser = new ConcurrentHashMap<>(); + + // 라운드별 타임아웃 배치 중복 실행 방지용 + private final AtomicReference lastProcessedRoundId = new AtomicReference<>(null); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { var principal = session.getPrincipal(); if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { - log.warn("인증 정보가 없어 WebSocket 연결 종료"); session.close(CloseStatus.NOT_ACCEPTABLE); return; } + var cud = (CustomUserDetails) auth.getPrincipal(); + String userId = cud.getUser().getId().toString(); - // CustomUserDetails → userId - Object p = auth.getPrincipal(); - String userId; - if (p instanceof CustomUserDetails cud && cud.getUser() != null) { - userId = cud.getUser().getId().toString(); - } else if (p instanceof String s) { - userId = s; // 토큰의 subject를 그대로 쓰는 경우 - } else { - log.warn("지원하지 않는 principal 타입: {}", p.getClass()); - session.close(CloseStatus.NOT_ACCEPTABLE); - return; + // 기존 세션 있으면 닫기 (유저당 1세션 유지) + WebSocketSession prev = latestByUser.put(userId, session); + if (prev != null && prev.isOpen() && prev != session) { + try { prev.close(CloseStatus.NORMAL); } catch (Exception ignored) {} } session.getAttributes().put("userId", userId); sessions.add(session); log.info("🟢 WebSocket 연결됨: {} (userId={})", session.getId(), userId); - // 1) USER_ID 전송 - session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); - - // 2) STATUS 즉시 전송 - MatchStatusResponse status = matchService.getCurrentStatus(); - JSONObject response = new JSONObject(); - response.put("type", "STATUS"); - response.put("count", status.getCount()); - response.put("remainingTime", status.getRemainingTime()); - response.put("state", status.getState()); - - JSONObject round = new JSONObject(); - round.put("no", status.getRound().getNo()); - round.put("id", status.getRound().getId()); - round.put("openAt", status.getRound().getOpenAt()); - round.put("lockAt", status.getRound().getLockAt()); - response.put("round", round); - - session.sendMessage(new TextMessage(response.toString())); + try { + // 1) USER_ID + session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); + + // 2) STATUS 즉시 전송 + var status = matchService.getCurrentStatus(); + var resp = new JSONObject(); + resp.put("type", "STATUS"); + resp.put("count", status.getCount()); + resp.put("remainingTime", status.getRemainingTime()); + resp.put("state", status.getState()); + var round = status.getRound(); + if (round != null) { + var r = new JSONObject(); + r.put("id", round.getId().toString()); + r.put("no", round.getNo()); + r.put("openAt", round.getOpenAt()); + r.put("lockAt", round.getLockAt()); + resp.put("round", r); + } + session.sendMessage(new TextMessage(resp.toString())); + } catch (IOException ex) { + log.warn("초기 메시지 전송 중 끊김: {}", ex.getMessage()); + } } private String extractUserId(WebSocketSession session) { @@ -113,37 +121,110 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); String userId = (String) session.getAttributes().get("userId"); - if (userId != null && matchService.isInMatch(userId)) { - matchService.cancelMatch(userId); + if (userId != null) { + // latestByUser 최신세션만 정리 + latestByUser.compute(userId, (k, v) -> (v == session) ? null : v); + + // ❌ 자동 취소 제거: 재연결/탭 이동 중에도 대기열 유지 + // if (matchService.isInMatch(userId)) { + // matchService.cancelMatch(userId); + // } } } + @Scheduled(fixedRate = 1000) public void broadcastStatus() { - MatchStatusResponse status = matchService.getCurrentStatus(); - JSONObject response = new JSONObject(); - response.put("type", "STATUS"); - response.put("count", status.getCount()); - response.put("remainingTime", status.getRemainingTime()); - response.put("state", status.getState()); - - RoundInfo round = status.getRound(); - if (round != null) { - JSONObject roundJson = new JSONObject(); - roundJson.put("id", round.getId().toString()); - roundJson.put("no", round.getNo()); - roundJson.put("openAt", round.getOpenAt()); - roundJson.put("lockAt", round.getLockAt()); - response.put("round", roundJson); - } + var status = matchService.getCurrentStatus(); + // 1) 상태 브로드캐스트 (세션별 LOCKED_HOLD 처리) for (WebSocketSession s : sessions) { try { - if (s.isOpen()) s.sendMessage(new TextMessage(response.toString())); + if (!s.isOpen()) continue; + String uid = (String) s.getAttributes().get("userId"); + + var msg = new JSONObject(); + msg.put("type", "STATUS"); + msg.put("count", status.getCount()); + msg.put("remainingTime", status.getRemainingTime()); + + String state = status.getState(); + if ("LOCKED".equals(state) && uid != null && matchService.isInMatch(uid)) { + msg.put("state", "LOCKED_HOLD"); + } else { + msg.put("state", state); + } + + var round = status.getRound(); + if (round != null) { + var r = new JSONObject(); + r.put("id", round.getId().toString()); + r.put("no", round.getNo()); + r.put("openAt", round.getOpenAt()); + r.put("lockAt", round.getLockAt()); + msg.put("round", r); + } + + s.sendMessage(new TextMessage(msg.toString())); } catch (IOException e) { log.warn("WebSocket 전송 실패: {}", e.getMessage()); sessions.remove(s); } } + + // 2) LOCKED로 넘어간 첫 틱에만 타임아웃 배치 실행 + DRAFT_START 푸시 + var round = status.getRound(); + if ("LOCKED".equals(status.getState()) && round != null) { + String rid = round.getId().toString(); + String prev = lastProcessedRoundId.get(); + if (prev == null || !prev.equals(rid)) { + // 동시성 가드 (여러 스레드가 들어오더라도 최초 1회만) + if (lastProcessedRoundId.compareAndSet(prev, rid)) { + try { + log.info("⏰ LOCKED 진입: roundId={} → 타임아웃 배치 실행", rid); + + // DB 반영 (Draft/Participant 생성, Redis 정리) + Map assignments = matchTimeoutProcessor.processTimeoutAndInsert(); + + log.info("🧩 배정 완료: {} 명", assignments.size()); + + // 개인 푸시 + assignments.forEach((uid, a) -> { + WebSocketSession target = latestByUser.get(uid); + if (target != null && target.isOpen()) { + var msg = new JSONObject(); + msg.put("type", "DRAFT_START"); + msg.put("draftId", a.getDraftId().toString()); + msg.put("userNumber", a.getUserNumber()); + try { + target.sendMessage(new TextMessage(msg.toString())); + } catch (Exception e) { + log.warn("DRAFT_START 전송 실패 userId={}: {}", uid, e.getMessage()); + } + } + }); + } catch (Exception e) { + log.error("타임아웃 배치 실행 실패: {}", e.getMessage(), e); + // 실패 시 다시 시도하게 하려면 가드 해제 + lastProcessedRoundId.set(prev); + } + } + } + } + } + + public void notifyDraftStart(String userId, UUID draftId, short userNumber) { + WebSocketSession s = latestByUser.get(userId); + if (s != null && s.isOpen()) { + var msg = new JSONObject(); + msg.put("type", "DRAFT_START"); + msg.put("draftId", draftId.toString()); + msg.put("userNumber", userNumber); + try { + s.sendMessage(new TextMessage(msg.toString())); + } catch (IOException e) { + log.warn("DRAFT_START 전송 실패: {}", e.getMessage()); + } + } } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java new file mode 100644 index 0000000..d5fb3f7 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java @@ -0,0 +1,31 @@ +package likelion.mlb.backendProject.domain.match.scheduler; + +import likelion.mlb.backendProject.domain.match.service.DraftTimingService; +import likelion.mlb.backendProject.domain.match.service.MatchTimeoutProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class MatchScheduler { + private final DraftTimingService draftTimingService; + private final MatchTimeoutProcessor processor; + + private volatile UUID processedRoundId; + + @Scheduled(cron = "*/5 * * * * *") // 5초마다 체크 + public void tick() { + var round = draftTimingService.getNextDraftWindowOrThrow(); + var lockAt = LocalDateTime.parse(round.getLockAt()); + if (LocalDateTime.now(ZoneId.of("Asia/Seoul")).isAfter(lockAt) + && !round.getId().equals(processedRoundId)) { + var map = processor.processTimeoutAndInsert(); + processedRoundId = round.getId(); + } + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java index 4995d41..5a2ebf9 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java @@ -32,7 +32,7 @@ public RoundInfo getNextDraftWindowOrThrow() { LocalDate draftDay = startedAtKST.toLocalDate().minusDays(2); LocalDateTime openAt = LocalDateTime.of(draftDay, LocalTime.of(8, 00)); - LocalDateTime lockAt = LocalDateTime.of(draftDay, LocalTime.of(18, 30)); + LocalDateTime lockAt = LocalDateTime.of(draftDay, LocalTime.of(12,20)); return new RoundInfo( r.getId(), diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java index aeeb098..504068f 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java @@ -27,7 +27,6 @@ public class MatchTimeoutProcessor { private final DraftRepository draftRepository; private final ParticipantRepository participantRepository; private final RoundRepository roundRepository; - private final DraftTimingService draftTimingService; private final ChatRoomService chatRoomService; @@ -44,38 +43,37 @@ public Map processTimeoutAndInsert() { Set members = redisTemplate.opsForSet().members(SESSION_KEY); if (members == null || members.isEmpty()) return Map.of(); - // SET은 순서가 없으니 공정성을 위해 셔플 + // 공정성 위해 셔플 List users = new ArrayList<>(members); Collections.shuffle(users); - // 2) 4인씩 그룹핑 + // 2) 4인씩 그룹핑 + 마지막 그룹 패딩 List> groups = splitBySize(users, 4); - - // 2-1) 마지막 그룹이 4 미만이면 더미로 채움 if (!groups.isEmpty()) { List last = groups.get(groups.size() - 1); if (last.size() < 4) padWithDummies(last); } - //FIXME 구조 개멍청함 + // 3) 라운드 조회 RoundInfo roundInfo = draftTimingService.getNextDraftWindowOrThrow(); - UUID roundId = roundInfo.getId(); // RoundInfo에 id가 있다고 가정 + UUID roundId = roundInfo.getId(); Round round = roundRepository.findById(roundId) .orElseThrow(() -> new BaseException(ErrorCode.ROUND_NOT_FOUND)); Map result = new HashMap<>(); List usedRealUserIds = new ArrayList<>(); - // 3) 그룹별 Draft/Participant INSERT - // FIXME 이러면 누락? + // 4) 그룹별 Draft/Participant INSERT for (List group : groups) { - if (group.size() != 4) continue; // 방은 항상 4명으로만 생성 + if (group.size() != 4) continue; // 방은 항상 4명 - // Draft 생성 Draft draft = new Draft(); draft.setId(UUID.randomUUID()); draft.setRound(round); + // draft.setDeleted(false); // ← is_deleted NOT NULL이고 default 없다면 주석 해제 draftRepository.save(draft); + + // 필요 시 채팅방 생성 chatRoomService.createRoom(draft.getId()); short userNumber = 1; @@ -92,13 +90,12 @@ public Map processTimeoutAndInsert() { p.setUser(null); } else { p.setDummy(false); - // 프록시 참조만 잡아도 OK (엔티티 설계에 따라 변경) - User userRef = new User(); + User userRef = new User(); // 프록시 참조만 세팅 userRef.setId(UUID.fromString(uid)); p.setUser(userRef); - // 결과 매핑 (나중에 페이지 이동에 씀) - result.put(uid, new AssignDto(draft.getId(), (short)(p.getUserNumber()))); + // 결과 매핑 + result.put(uid, new AssignDto(draft.getId(), (short) (p.getUserNumber()))); usedRealUserIds.add(uid); } participants.add(p); @@ -106,7 +103,7 @@ public Map processTimeoutAndInsert() { participantRepository.saveAll(participants); } - // 4) 사용된 실제 유저만 Redis에서 제거 (전체 삭제 X) + // 5) 사용된 실제 유저만 Redis에서 제거 if (!usedRealUserIds.isEmpty()) { redisTemplate.opsForSet().remove(SESSION_KEY, usedRealUserIds.toArray()); } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java index ec528ad..d9dc768 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java @@ -15,25 +15,25 @@ @Configuration public class KafkaConfig { - @Bean - public ProducerFactory producerFactory() { - Map props = new HashMap<>(); - props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, - "${spring.kafka.bootstrap-servers}"); - props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, - StringSerializer.class); - // JsonSerializer 설정: ChatMessageEvent를 자동 직렬화 - props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, - JsonSerializer.class); - // 타입 헤더 없이 순수 JSON만 보내고 싶으면 아래 옵션 추가 - props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); - - return new DefaultKafkaProducerFactory<>(props); - } - - @Bean - public KafkaTemplate kafkaTemplate( - ProducerFactory pf) { - return new KafkaTemplate<>(pf); - } +// @Bean +// public ProducerFactory producerFactory() { +// Map props = new HashMap<>(); +// props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, +// "${spring.kafka.bootstrap-servers}"); +// props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, +// StringSerializer.class); +// // JsonSerializer 설정: ChatMessageEvent를 자동 직렬화 +// props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, +// JsonSerializer.class); +// // 타입 헤더 없이 순수 JSON만 보내고 싶으면 아래 옵션 추가 +// props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); +// +// return new DefaultKafkaProducerFactory<>(props); +// } +// +// @Bean +// public KafkaTemplate kafkaTemplate( +// ProducerFactory pf) { +// return new KafkaTemplate<>(pf); +// } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java index 0b35601..63d4fbf 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java @@ -36,17 +36,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**", "/oauth2/**", "/login.html").permitAll() - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() - .requestMatchers("/login.html", "/login-success.html").permitAll() - .requestMatchers("/ws/**").permitAll() // ✅ WebSocket 경로 허용 - .requestMatchers("/**").permitAll() - .requestMatchers("/ws/**").permitAll() + .requestMatchers("/**").permitAll() + .requestMatchers("/api/auth/**", "/oauth2/**", "/login.html").permitAll() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers("/login.html", "/login-success.html").permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() - .requestMatchers("/api/**").authenticated() + //.requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) .oauth2Login(oauth2 -> oauth2 diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java index 86e8c03..4bb75d3 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java @@ -11,6 +11,8 @@ import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; @Component @@ -22,6 +24,8 @@ public class JwtTokenProvider { private Key key; + private final UserDetailsService userDetailsService; + private static final long ACCESS_TOKEN_VALID_TIME = 60 * 60 * 1000L; // 1시간 private static final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 14 * 1000L; // 14일 @@ -64,4 +68,12 @@ public String getEmail(String token) { .getSubject(); } + // ✅ 추가: 토큰 → Authentication + public Authentication getAuthentication(String token) { + String email = getEmail(token); + var userDetails = userDetailsService.loadUserByUsername(email); // CustomUserDetails 기대 + return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java new file mode 100644 index 0000000..7d73d3e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java @@ -0,0 +1,22 @@ +package likelion.mlb.backendProject.global.security.oauth; + +import likelion.mlb.backendProject.domain.user.repository.UserRepository; +import likelion.mlb.backendProject.global.security.dto.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + var user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + return new CustomUserDetails(user); // 네 프로젝트의 CustomUserDetails + } +} \ No newline at end of file diff --git a/backendProject/src/main/resources/application-dev.example.properties b/backendProject/src/main/resources/application-dev.example.properties deleted file mode 100644 index 06c110c..0000000 --- a/backendProject/src/main/resources/application-dev.example.properties +++ /dev/null @@ -1,40 +0,0 @@ -# Redis enviroments -redis.host= -redis.port= - -# Elasticsearch -es.uri= - -# Database ?? -db.server= -db.port= -db.name= -db.username= -db.password= - -# PostgreSQL ?? -spring.datasource.url= -spring.datasource.driverClassName= -spring.datasource.username= -spring.datasource.password= -spring.jpa.database-platform= -spring.jpa.properties.hibernate.globally_quoted_identifiers= - -# Redis -spring.data.redis.host= -spring.data.redis.port= - -# WebSocket -# ?: WebSocket ?????, ?? ?? ? ? - -# Swagger ?? ?? ?? ???? -# springdoc.api-docs.enabled= -# springdoc.swagger-ui.enabled= - -# Elasticsearch -spring.elasticsearch.uris= - -jwt.secret = jwt_token - -FRONTEND_HTTP_URL= -FRONTEND_HTTPS_URL= \ No newline at end of file From 4f95a5e43ed6bffb81539127c641dfbaa8fff0c6 Mon Sep 17 00:00:00 2001 From: hwichoi0317 Date: Wed, 13 Aug 2025 15:36:52 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=EC=84=A0=EC=88=98=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95(=EC=84=A0?= =?UTF-8?q?=EC=88=98=EC=83=81=ED=83=9C(status)=EC=99=80=20elementTypeId(?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=EC=85=98pk=EA=B0=92)=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/player/cache/dto/PlayerDto.java | 8 +++ .../controller/PlayerEsController.java | 14 ++++- .../elasticsearch/dto/PlayerEsDocument.java | 6 ++ .../service/PlayerEsService.java | 61 ++++++++++++++++++- .../domain/player/entity/Player.java | 2 + 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java index b871339..030be55 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java @@ -23,10 +23,16 @@ public class PlayerDto { private String pic; // player 사진 + //a: 뛸 수 있음, d: 출전 불투명, i: 부상, s: 징계, u: 사용불(임대 등), n: 자격 없음(미등록 선수) + //x로 업데이트 된 건 fpl api로부터 받아온 데이터에서 삭제된 선수 + private String status; + private String teamName; // 소속팀 영어명 private String teamKrName; // 소속팀 한글명 + private UUID elementTypeId; // 포지션 pk값 + private String elementTypePluralName; // 포지션 영어명 private String elementTypeKrName; // 포지션 한글명 @@ -40,12 +46,14 @@ public static List toDocumentList(List playerDtoLis .webName(p.getWebName()) .krName(p.getKrName()) .pic(p.getPic()) + .status(p.getStatus()) // team관련 설정 .teamName(p.getTeamName()) .teamKrName(p.getTeamKrName()) // 포지션(elementType) 관련 설정 + .elementTypeId(p.getElementTypeId()) .elementTypePluralName(p.getElementTypePluralName()) .elementTypeKrName(p.getElementTypeKrName()) .build() diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java index a739065..31457d8 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java @@ -24,10 +24,18 @@ public class PlayerEsController { // elasticsearch 검색 결과를 json List로 반환 - @GetMapping("/elasticsearch") - public ResponseEntity> elasticSearch( + @GetMapping("/searchWithKeyword") + public ResponseEntity> searchWithKeyword( @RequestParam(value = "keyword", defaultValue = "") String keyword) { - return ResponseEntity.ok(playerEsService.search(keyword)); + return ResponseEntity.ok(playerEsService.searchWithKeyword(keyword)); + } + + // elasticsearch 검색 결과를 json List로 반환 + @GetMapping("/searchWithElementTypeId") + public ResponseEntity> searchWithElementTypeId( + @RequestParam(value = "elementTypeId", defaultValue = "") String elementTypeId) { + + return ResponseEntity.ok(playerEsService.searchWithElementTypeId(elementTypeId)); } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java index 1ee9b2e..f42eb02 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java @@ -32,10 +32,16 @@ public class PlayerEsDocument { private String pic; // player 사진 + //a: 뛸 수 있음, d: 출전 불투명, i: 부상, s: 징계, u: 사용불(임대 등), n: 자격 없음(미등록 선수) + //x로 업데이트 된 건 fpl api로부터 받아온 데이터에서 삭제된 선수 + private String status; + private String teamName; // 소속팀 영어명 private String teamKrName; // 소속팀 한글명 + private UUID elementTypeId; // 포지션 pk값 + private String elementTypePluralName; // 포지션 영어명 private String elementTypeKrName; // 포지션 한글명 diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java index 7fba5f3..b3a9597 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; @Slf4j @@ -45,7 +46,7 @@ public void deleteById(String id) { } // 검색 키워드를 받아서 엘라스틱서치에서 검색하는 메서드 - public List search(String keyword) { + public List searchWithKeyword(String keyword) { try { // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 @@ -112,5 +113,63 @@ public List search(String keyword) { } } + + // 선수 포지션 pk값 받아서 엘라스틱서치에서 검색하는 메서드 + public List searchWithElementTypeId(String elementTypeId) { + + try { + // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 + Query query; + + // 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 + if (elementTypeId == null || elementTypeId.isBlank()) { + query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 + } + // 검색어가 있을 때 + else { + // BoolQuery는 복수 조건을 조합할 때 사용하는 쿼리 + // 이 쿼리 안에서 여러 개의 조건을 나열 + query = BoolQuery.of(b ->{ + + /* + * must: 모두 일치해야 함 (AND) + * should: 하나라도 일치하면 됨 (OR) + * must_not: 해당 조건을 만족하면 제외 + * filter : must와 같지만 점수 계산 안함 (속도가 빠름) + */ + + // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 + b.should(MatchQuery.of(m->m.field("elementTypeId.ngram").query(elementTypeId))._toQuery()); + + return b; + })._toQuery(); + } + // searchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체 + // 인덱스명, 쿼리를 포함한 검색 요청 + SearchRequest request = SearchRequest.of(s->s + .index("player-index") + .query(query) + ); + + // SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체 + SearchResponse response = + // 엘라스틱서치에 명령을 전달하는 자바 API검색 요청을 담아서 응답 객체로 반환 + elasticsearchClient.search(request, PlayerEsDocument.class); + + // 위 응답객체에서 받은 검색 결과 중 문서만 추출해서 리스트로 만듬 + // hit는 엘라스틱서치에서 검색 된 문서 1개를 감싸고 있는 객체 + List content = response.hits() // 엘라스틱 서치 응답에서 hits(문서 검색결과) 전체를 꺼냄 + .hits() // 검색 결과 안에 개별 리스트를 가져옴 + .stream() // 자바 stream api를 사용 + .map(Hit::source) // 각 Hit 객체에서 실제 문서를 꺼내는 작업 + .collect(Collectors.toList()); // 위에서 꺼낸 객체를 자바 List에 넣음 + + return content; + } catch (IOException ioException) { + log.error("검색 오류 ", ioException.getMessage()); + throw new RuntimeException("검색 오류 중 발생 ", ioException); + } + + } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java index aac134f..4a99d9e 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java @@ -160,12 +160,14 @@ public static List toDtoList(List players) { .webName(p.getWebName()) .krName(p.getKrName()) .pic(p.getPic()) + .status(p.getStatus()) // team관련 설정 .teamName(p.getTeam().getName()) .teamKrName(p.getTeam().getKrName()) // 포지션(elementType) 관련 설정 + .elementTypeId(p.getElementType().getId()) .elementTypePluralName(p.getElementType().getPluralName()) .elementTypeKrName(p.getElementType().getKrName()) .build() From 92d90e6c9ba712e0f543bfb5386a95a54b45ffe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=AF=BC?= Date: Thu, 14 Aug 2025 12:25:05 +0900 Subject: [PATCH 09/12] Create deploy.yml --- .github/workflows/deploy.yml | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..145bb8d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,100 @@ +name: Deploy to Amazon Linux EC2 (compose-prod) + +on: + push: + branches: [ main ] # main 브랜치 푸시 시 배포 + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 1) 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + + # 2) JDK 설치 (Spring 빌드) + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # 3) Gradle 빌드 (테스트 생략 예시) + - name: Build Spring Boot + run: ./gradlew clean build -x test + + # 4) EC2로 파일 전송 + # - compose 파일: docker-compose-prod.yml + # - backendProject 폴더 안의 Dockerfile + # - CI에서 생성한 JAR (backendProject/build/libs/*.jar) + # - (있다면) .env + - name: Upload artifacts to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: | + docker-compose-prod.yml + backendProject/Dockerfile + backendProject/build/libs/*.jar + .env + target: ${{ secrets.EC2_APP_DIR }} + + # 5) EC2에서 배포 (Amazon Linux) + - name: Deploy on EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + APP_DIR="${{ secrets.EC2_APP_DIR }}" + cd "$APP_DIR" + + # Docker/compose 설치(없을 때) + if ! command -v docker >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then + sudo dnf -y update + sudo dnf -y install docker + else + sudo yum -y update + sudo yum -y install docker + fi + sudo systemctl enable docker + sudo systemctl start docker + sudo usermod -aG docker $USER || true + fi + if ! docker compose version >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then + sudo dnf -y install docker-compose-plugin + else + sudo yum -y install docker-compose-plugin || true + fi + fi + + # 기존 컨테이너 중지 후 재빌드/재기동 + sudo docker compose -f docker-compose-prod.yml down || true + sudo docker compose -f docker-compose-prod.yml build --no-cache + sudo docker compose -f docker-compose-prod.yml up -d + + # (옵션) 안 쓰는 이미지 정리 + sudo docker image prune -f + + # 6) (선택) CloudFront 캐시 무효화 + - name: Configure AWS credentials + if: ${{ secrets.CF_DISTRIBUTION_ID != '' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Invalidate CloudFront + if: ${{ secrets.CF_DISTRIBUTION_ID != '' }} + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ secrets.CF_DISTRIBUTION_ID }}" \ + --paths "/*" From 58866df6ab991fa071eb92aa93c523359a08cd5a Mon Sep 17 00:00:00 2001 From: hwichoi0317 Date: Thu, 14 Aug 2025 12:25:58 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=EC=84=A0=EC=88=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=EC=85=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ElementTypeController.java | 29 ++++++++++++ .../domain/player/dto/ElementTypeDto.java | 23 ++++++++++ .../controller/PlayerEsController.java | 12 ++++- .../service/PlayerEsService.java | 46 ++++++++----------- .../domain/player/entity/ElementType.java | 15 ++++++ .../player/service/ElementTypeService.java | 23 ++++++++++ 6 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java new file mode 100644 index 0000000..c54a14e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java @@ -0,0 +1,29 @@ +package likelion.mlb.backendProject.domain.player.controller; + + +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.dto.PreviousBestPlayerDto; +import likelion.mlb.backendProject.domain.player.service.ElementTypeService; +import likelion.mlb.backendProject.domain.player.service.PlayerService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/elementType") +public class ElementTypeController { + + private final ElementTypeService elementTypeService; + + @GetMapping("/all") + public ResponseEntity> getAllElementType() { + List elementTypeDtos = elementTypeService.getAllElementType(); + return new ResponseEntity<>(elementTypeDtos, HttpStatus.OK); + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java new file mode 100644 index 0000000..32ef016 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java @@ -0,0 +1,23 @@ +package likelion.mlb.backendProject.domain.player.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class ElementTypeDto { + + private UUID id; + + //fpl의 각 포지션별 id + private Integer fplId; + + //ex) Goalkeepers, Defenders, fpl에서 받아오는 영어 이름 + private String pluralName; + + // 선수 포지션 한글명 + private String krName = ""; + +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java index 31457d8..7a851d8 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java @@ -22,8 +22,17 @@ public class PlayerEsController { private final PlayerEsService playerEsService; - // elasticsearch 검색 결과를 json List로 반환 + @GetMapping("/search") + public ResponseEntity> searchWithKeyword( + @RequestParam(value = "keyword", defaultValue = "") String keyword + , @RequestParam(value = "elementTypeId", defaultValue = "") String elementTypeId + ) { + + return ResponseEntity.ok(playerEsService.search(keyword, elementTypeId)); + } + + /* @GetMapping("/searchWithKeyword") public ResponseEntity> searchWithKeyword( @RequestParam(value = "keyword", defaultValue = "") String keyword) { @@ -38,4 +47,5 @@ public ResponseEntity> searchWithElementTypeId( return ResponseEntity.ok(playerEsService.searchWithElementTypeId(elementTypeId)); } + */ } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java index b3a9597..95f2862 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java @@ -46,14 +46,14 @@ public void deleteById(String id) { } // 검색 키워드를 받아서 엘라스틱서치에서 검색하는 메서드 - public List searchWithKeyword(String keyword) { + public List search(String keyword, String elementTypeId) { try { // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 Query query; - // 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 - if (keyword == null || keyword.isBlank()) { + // 포지션과 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 + if ((keyword == null || keyword.isBlank()) && (elementTypeId == null || elementTypeId.isBlank())) { query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 } // 검색어가 있을 때 @@ -62,26 +62,17 @@ public List searchWithKeyword(String keyword) { // 이 쿼리 안에서 여러 개의 조건을 나열 query = BoolQuery.of(b ->{ - /* - * must: 모두 일치해야 함 (AND) - * should: 하나라도 일치하면 됨 (OR) - * must_not: 해당 조건을 만족하면 제외 - * filter : must와 같지만 점수 계산 안함 (속도가 빠름) - */ + // keyword 조건이 있으면 should + if (keyword != null && !keyword.isBlank()) { + b.should(MatchQuery.of(m -> m.field("webName.ngram").query(keyword))._toQuery()); + b.should(MatchQuery.of(m -> m.field("krName.ngram").query(keyword))._toQuery()); + b.minimumShouldMatch("1"); // keyword 조건 중 최소 1개는 만족해야 함 + } - // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 - b.should(MatchQuery.of(m->m.field("webName.ngram").query(keyword))._toQuery()); - b.should(MatchQuery.of(m->m.field("krName.ngram").query(keyword))._toQuery()); - - // fuzziness: "AUTO"는 오타 허용 검색 기능을 자동으로 켜주는 설정 -> 유사도 계산을 매번 수행하기 때문에 느림 - // 짧은 키워드에는 사용 X - // 오타 허용 (오타허용은 match만 가능) - /* - if (keyword.length()>=3){ // 검색어가 3글자 이상일 시 - b.should(MatchQuery.of(m ->m.field("webName").query(keyword).fuzziness("AUTO"))._toQuery()); - b.should(MatchQuery.of(m ->m.field("krName").query(keyword).fuzziness("AUTO"))._toQuery()); + // elementTypeId 조건이 있으면 must + if (elementTypeId != null && !elementTypeId.isBlank()) { + b.must(TermQuery.of(t -> t.field("elementTypeId.keyword").value(elementTypeId))._toQuery()); } - */ return b; })._toQuery(); @@ -90,6 +81,10 @@ public List searchWithKeyword(String keyword) { // 인덱스명, 쿼리를 포함한 검색 요청 SearchRequest request = SearchRequest.of(s->s .index("player-index") + + // 한 번에 최대 700개 데이터 가져올 수 있음 + // 화면에서 페이징을 안 쓰기 때문에 scroll혹은 search_after방식은 적합 X + .size(700) .query(query) ); @@ -115,6 +110,7 @@ public List searchWithKeyword(String keyword) { } // 선수 포지션 pk값 받아서 엘라스틱서치에서 검색하는 메서드 + /* public List searchWithElementTypeId(String elementTypeId) { try { @@ -131,13 +127,6 @@ public List searchWithElementTypeId(String elementTypeId) { // 이 쿼리 안에서 여러 개의 조건을 나열 query = BoolQuery.of(b ->{ - /* - * must: 모두 일치해야 함 (AND) - * should: 하나라도 일치하면 됨 (OR) - * must_not: 해당 조건을 만족하면 제외 - * filter : must와 같지만 점수 계산 안함 (속도가 빠름) - */ - // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 b.should(MatchQuery.of(m->m.field("elementTypeId.ngram").query(elementTypeId))._toQuery()); @@ -171,5 +160,6 @@ public List searchWithElementTypeId(String elementTypeId) { } } + */ } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java index a3695e7..62a02bf 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java @@ -1,6 +1,10 @@ package likelion.mlb.backendProject.domain.player.entity; import jakarta.persistence.*; +import likelion.mlb.backendProject.domain.draft.dto.DraftResponse; +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.dto.PreviousBestPlayerDto; +import likelion.mlb.backendProject.domain.player.entity.live.PlayerFixtureStat; import likelion.mlb.backendProject.global.staticdata.dto.bootstrap.FplElementType; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Entity @Table(name = "element_type") @@ -62,4 +67,14 @@ public static List elementTypeBuilder(List fplEleme } return ets; } + + public static List toDtoList(List elementTypes) { + return elementTypes.stream().map(e -> ElementTypeDto.builder() + .id(e.getId()) // 선수 포지션 pk값 + .fplId(e.getFplId()) // fpl의 각 포지션별 id + .pluralName(e.getPluralName()) //ex) Goalkeepers, Defenders, fpl에서 받아오는 영어 이름 + .krName(e.getKrName()) // 선수 포지션 한글명 + .build() + ).collect(Collectors.toList()); + } } \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java new file mode 100644 index 0000000..cc93ab3 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java @@ -0,0 +1,23 @@ +package likelion.mlb.backendProject.domain.player.service; + + +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.entity.ElementType; +import likelion.mlb.backendProject.domain.player.repository.ElementTypeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ElementTypeService { + + private final ElementTypeRepository elementTypeRepository; + + public List getAllElementType(){ + List elementTypes = elementTypeRepository.findAll(); + + return ElementType.toDtoList(elementTypes); + } +} From 092ae56ce05a4e7e1b0617684ab88f079a8ca6af Mon Sep 17 00:00:00 2001 From: roode Date: Thu, 14 Aug 2025 12:27:05 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat/=20=EB=B0=B0=ED=8F=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20docker=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?securityconfig=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95,=20hea?= =?UTF-8?q?lthcheck=EB=A5=BC=20=EC=9C=84=ED=95=9C=20controller=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/health/HealthController.java | 13 ++ .../security/config/SecurityConfig.java | 1 + docker-compose-prod.yml | 139 ++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java create mode 100644 docker-compose-prod.yml diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java new file mode 100644 index 0000000..c0d4b5b --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java @@ -0,0 +1,13 @@ +package likelion.mlb.backendProject.global.health; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + @GetMapping("/health") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("ok"); + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java index a0b5a41..d6c0b9e 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java @@ -42,6 +42,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/login.html", "/login-success.html").permitAll() .requestMatchers("/api/**", "/oauth2/**", "/login.html").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers("/health").permitAll() .requestMatchers("/ws/**").permitAll() .requestMatchers( "/swagger-ui/**", diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..ecf3712 --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,139 @@ +version: '3.8' + +services: + + api1: + build: + context: ./backendProject + container_name: mlb-api-1 + ports: + - "8080:8080" + env_file: + - .env + depends_on: + mlb-redis1: + condition: service_healthy + mlb-elasticsearch: + condition: service_healthy + mlb-kafka: + condition: service_healthy + networks: + - backend + + api2: + build: + context: ./backendProject + container_name: mlb-api-2 + ports: + - "8081:8080" + env_file: + - .env + depends_on: + mlb-redis1: + condition: service_healthy + mlb-elasticsearch: + condition: service_healthy + mlb-kafka: + condition: service_healthy + networks: + - backend + + # Redis - 3 인스턴스 + mlb-redis1: + image: redis:7.2 + container_name: mlb-redis1 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - backend + + mlb-redis2: + image: redis:7.2 + container_name: mlb-redis2 + ports: + - "6380:6379" + networks: + - backend + + mlb-redis3: + image: redis:7.2 + container_name: mlb-redis3 + ports: + - "6381:6379" + networks: + - backend + + # Elasticsearch + mlb-elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 + container_name: mlb-elasticsearch + environment: + - node.name=es01 + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.transport.ssl.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + - "9300:9300" + volumes: + - es_data:/usr/share/elasticsearch/data + healthcheck: + # TCP 열림 확인(의존 패키지 없이 동작) + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/9200'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + + # Zookeeper (for Kafka) + mlb-zookeeper: + image: confluentinc/cp-zookeeper:7.4.1 + container_name: mlb-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + healthcheck: + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/2181'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + + # Kafka + mlb-kafka: + image: confluentinc/cp-kafka:7.4.1 + container_name: mlb-kafka + depends_on: + - mlb-zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: mlb-zookeeper:2181 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://mlb-kafka:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + healthcheck: + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/9092'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + +volumes: + es_data: + +networks: + backend: + driver: bridge From 1bab887b9975e2a6f395fdd5e35743a3c340137a Mon Sep 17 00:00:00 2001 From: roode Date: Thu, 14 Aug 2025 14:36:29 +0900 Subject: [PATCH 12/12] =?UTF-8?q?hotfix/=20deploy=20=ED=8D=84=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 145bb8d..f19a139 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,7 @@ name: Deploy to Amazon Linux EC2 (compose-prod) on: push: branches: [ main ] # main 브랜치 푸시 시 배포 + workflow_dispatch: {} jobs: deploy: