diff --git a/build.gradle b/build.gradle index 0d90f7b..c3711a7 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // kafka 추가 + implementation 'org.springframework.kafka:spring-kafka' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index c05bb31..f4cc270 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,54 +1,100 @@ -services: - redis: - image: redis:7-alpine - container_name: redis - ports: - - "6379:6379" - restart: unless-stopped - mem_limit: 128m - command: redis-server --appendonly yes - volumes: - - redis_data:/data - # 블루 배포 - app-blue: - image: ddhi7/ccapp:latest #이미지는 동일 이미지 사용 : (깃허브 플젝 -> ccapp 이미지) - container_name: ccapp-blue - ports: - - "8081:8080" - env_file: - - .env - restart: unless-stopped - pull_policy: always - mem_limit: 400m - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - depends_on: - - redis + services: + redis: + image: redis:7-alpine + container_name: redis + ports: + - "6379:6379" + restart: unless-stopped + mem_limit: 128m + command: redis-server --appendonly yes + volumes: + - redis_data:/data + # 블루 배포 + app-blue: + image: ddhi7/ccapp:latest #이미지는 동일 이미지 사용 : (깃허브 플젝 -> ccapp 이미지) + container_name: ccapp-blue + ports: + - "8081:8080" + env_file: + - .env + restart: unless-stopped + pull_policy: always + mem_limit: 400m + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + depends_on: + - redis + - kafka #추가 - app-green: - #그린 배포 - image: ddhi7/ccapp:latest - container_name: ccapp-green - ports: - - "8082:8080" - env_file: - - .env - restart: unless-stopped - pull_policy: always - mem_limit: 400m - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - depends_on: - - redis + app-green: + #그린 배포 + image: ddhi7/ccapp:latest + container_name: ccapp-green + ports: + - "8082:8080" + env_file: + - .env + restart: unless-stopped + pull_policy: always + mem_limit: 400m + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + depends_on: + - redis + - kafka -volumes: - redis_data: - driver: local \ No newline at end of file + + kafka: + image: confluentinc/cp-kafka:latest + container_name: kafka + ports: + - "29092:29092" # 로컬호스트 접속용 + environment: + # 필수 KRaft 설정 + CLUSTER_ID: "hjeeg3q1SoCw7IKoRw-rMQ" + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: "broker,controller" # 브로커와 컨트롤러 역할 + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" # 컨트롤러 지정 + + # 리스너 설정 (CONTROLLER 추가 필수) + KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29092' + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + + # Mac(ARM) 호환 설정 및 성능 최적화 + _JAVA_OPTIONS: "-XX:UseSVE=0" + KAFKA_HEAP_OPTS: "-Xms256M -Xmx256M" # JVM 힙 메모리 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + volumes: + - kafka_data:/var/lib/kafka/data # <- Docker 내부 볼륨만 사용 + restart: unless-stopped + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8085:8080" # 호스트의 브라우저는 8085로 접속 가능 + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + KAFKA_CLUSTERS_0_ZOOKEEPER: "" # KRaft 모드라 Zookeeper 필요 없음 + depends_on: + - kafka + restart: unless-stopped + + volumes: + redis_data: + driver: local + kafka_data: \ No newline at end of file diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java index d8650ce..831db9e 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java @@ -1,30 +1,20 @@ package cc.backend.admin.amateurShow.service; import cc.backend.admin.amateurShow.dto.AdminAmateurShowListResponseDTO; -import cc.backend.admin.amateurShow.dto.AdminAmateurShowRejectRequestDTO; import cc.backend.admin.amateurShow.dto.AdminAmateurShowReviseRequestDTO; import cc.backend.admin.amateurShow.dto.AdminAmateurShowSummaryResponseDTO; import cc.backend.amateurShow.entity.AmateurShow; import cc.backend.amateurShow.repository.AmateurShowRepository; -import cc.backend.apiPayLoad.ApiResponse; -import cc.backend.apiPayLoad.SliceResponse; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.NewShowEvent; -import cc.backend.event.entity.RejectShowEvent; -import cc.backend.member.entity.Member; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.PathVariable; import java.util.List; -import static io.micrometer.common.util.StringUtils.isNotBlank; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java index be3dfe4..f99868c 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java @@ -7,9 +7,9 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.RejectShowEvent; import cc.backend.member.entity.Member; +import cc.backend.notice.event.ApproveCommitEvent; +import cc.backend.notice.event.RejectCommitEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; @@ -33,8 +33,13 @@ public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) { show.approve(); - Member member = show.getMember(); - eventPublisher.publishEvent(new ApproveShowEvent(show, member)); //공연등록 승인 이벤트 생성 + Member performer = show.getMember(); + + // 등록 승인 트랜잭션 커밋에 대해 이벤트 발행 + eventPublisher.publishEvent( + new ApproveCommitEvent(show.getId(), performer.getId() + ) + ); return AdminAmateurShowSummaryResponseDTO.from(show); } @@ -47,7 +52,12 @@ public AdminAmateurShowSummaryResponseDTO rejectShow(Long showId, AdminAmateurSh show.reject(dto.getRejectReason()); Member member = show.getMember(); - eventPublisher.publishEvent(new RejectShowEvent(show, member)); //공연등록 반려 이벤트 생성 + + // 등록 거부 커밋 트랜잭션 이벤트 발행 + eventPublisher.publishEvent( + new RejectCommitEvent(show.getId(), member.getId(), show.getRejectReason() + ) + ); return AdminAmateurShowSummaryResponseDTO.from(show); } diff --git a/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java b/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java index 27736a7..487d380 100644 --- a/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java +++ b/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java @@ -167,4 +167,5 @@ public void reject(String rejectReason){ this.rejectReason = rejectReason; } + } diff --git a/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java b/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java index 98f628e..6a2de6a 100644 --- a/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java +++ b/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java @@ -91,4 +91,7 @@ List findHotShows( @Query("UPDATE AmateurShow s SET s.status = 'ENDED' " + "WHERE s.status = 'ONGOING' AND s.end < :today") int updateShowsToEnded(@Param("today") LocalDate today); + + @Query("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId") + List findHashtagsByMemberId(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java index f41e111..8a273dc 100644 --- a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java +++ b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java @@ -3,7 +3,6 @@ import cc.backend.amateurShow.dto.AmateurShowResponseDTO; import cc.backend.amateurShow.dto.AmateurUpdateRequestDTO; import cc.backend.amateurShow.entity.*; -import cc.backend.amateurShow.entity.enums.ApprovalStatus; import cc.backend.amateurShow.repository.*; import cc.backend.amateurShow.converter.AmateurConverter; import cc.backend.amateurShow.dto.AmateurEnrollRequestDTO; @@ -11,10 +10,7 @@ import cc.backend.amateurShow.repository.specification.AmateurShowSpecification; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.board.entity.enums.BoardType; -import cc.backend.event.entity.NewShowEvent; import cc.backend.image.DTO.ImageRequestDTO; -import cc.backend.image.DTO.ImageResponseDTO; import cc.backend.image.FilePath; import cc.backend.image.entity.Image; import cc.backend.image.repository.ImageRepository; @@ -22,20 +18,13 @@ import cc.backend.member.entity.Member; import cc.backend.member.enumerate.Role; import cc.backend.member.repository.MemberRepository; -import cc.backend.memberLike.entity.MemberLike; -import cc.backend.memberLike.repository.MemberLikeRepository; -import cc.backend.ticket.dto.response.ReserveListResponseDTO; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.text.Collator; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -51,10 +40,8 @@ public class AmateurServiceImpl implements AmateurService { private final AmateurTicketRepository amateurTicketRepository; private final AmateurStaffRepository amateurStaffRepository; private final AmateurRoundsRepository amateurRoundsRepository; - private final MemberLikeRepository memberLikeRepository; private final ImageService imageService; private final ImageRepository imageRepository; - private final ApplicationEventPublisher eventPublisher; //이벤트 생성 // 소극장 공연 등록 @Transactional @@ -92,18 +79,6 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult enrollShow(Long memberId, imageService.saveImageWithImageUrl(memberId, fullImageRequestDTO, Optional.ofNullable(dto.getImageUrl())); - - // 좋아요한 멤버리스트 - List memberLikers = memberLikeRepository.findByPerformerId(memberId); - // 좋아요한 멤버가 한 명 이상일 때만 - if(!memberLikers.isEmpty()) { - List likers = memberLikers.stream() - .map(MemberLike::getLiker) - .collect(Collectors.toList()); - - eventPublisher.publishEvent(new NewShowEvent(newAmateurShow.getId(), memberId, likers)); //공연등록 이벤트 생성 - } - // response return AmateurConverter.toAmateurEnrollDTO(newAmateurShow); } diff --git a/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java b/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java index c4ca635..641b7d4 100644 --- a/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java +++ b/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java @@ -85,6 +85,7 @@ public enum ErrorStatus implements BaseErrorCode { AMATEUR_TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "AMATEURTICKET4000", "존재하지 않는 소극장 공연 티켓입니다."), AMATEUR_TICKET_STOCK(HttpStatus.BAD_REQUEST, "AMATEURTICKET4001", "주문 수량은 최소 1개 이상이어야 합니다."), AMATEUR_SHOW_MISMATCH(HttpStatus.NOT_FOUND, "AMATEURTICKET4002", "회차와 티켓에 해당하는 공연이 일치하지 않습니다."), + // PHOTOALBUM ERROR PHOTOALBUM_NOT_FOUND(HttpStatus.NOT_FOUND, "PHOTOALBUM4000", "존재하지 않는 사진첩입니다."), @@ -108,6 +109,7 @@ public enum ErrorStatus implements BaseErrorCode { //NOTICE ERROR MEMBERNOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBERNOTICE4001", "존재하지 않는 알림입니다."), + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE4001", "존재하지 않는 알림입니다."), // INQUIRY ERROR INQUIRY_NOT_FOUND(HttpStatus.NOT_FOUND, "INQUIRY4000", "존재하지 않는 문의글입니다."), FORBIDDEN_INQUIRY_ACCESS(HttpStatus.NOT_FOUND, "INQUIRY4001", "로그인한 멤버가 작성하지 않는 문의글입니다."), diff --git a/src/main/java/cc/backend/board/service/BoardService.java b/src/main/java/cc/backend/board/service/BoardService.java index acbd478..35dfac3 100644 --- a/src/main/java/cc/backend/board/service/BoardService.java +++ b/src/main/java/cc/backend/board/service/BoardService.java @@ -13,13 +13,16 @@ import cc.backend.board.entity.enums.BoardType; import cc.backend.board.repository.BoardLikeRepository; import cc.backend.board.repository.HotBoardRepository; -import cc.backend.event.entity.PromoteHotEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; import cc.backend.image.DTO.ImageRequestDTO; import cc.backend.image.DTO.ImageResponseDTO; import cc.backend.image.FilePath; import cc.backend.image.entity.Image; import cc.backend.image.repository.ImageRepository; import cc.backend.image.service.ImageService; +import cc.backend.kafka.event.hotBoardEvent.HotBoardProducer; +import cc.backend.kafka.event.replyEvent.ReplyProducer; import cc.backend.member.entity.Member; import cc.backend.board.repository.BoardRepository; import cc.backend.member.enumerate.Role; @@ -48,7 +51,7 @@ public class BoardService { private final ImageService imageService; private final ImageRepository imageRepository; - private final ApplicationEventPublisher eventPublisher; + private final HotBoardProducer hotBoardProducer; // 게시글 작성 @Transactional @@ -361,7 +364,8 @@ private void promoteToHotBoard(Board board) { .build(); hotBoardRepository.save(hotBoard); - eventPublisher.publishEvent(new PromoteHotEvent(board.getId(), board.getMember().getId())); //핫게 이벤트 생성 + //핫게 알림용 카프카 이벤트 생성 + hotBoardProducer.publish(new HotBoardEvent(board.getId(), board.getMember().getId())); } } diff --git a/src/main/java/cc/backend/board/service/CommentService.java b/src/main/java/cc/backend/board/service/CommentService.java index a460e6d..288911f 100644 --- a/src/main/java/cc/backend/board/service/CommentService.java +++ b/src/main/java/cc/backend/board/service/CommentService.java @@ -11,10 +11,15 @@ import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentLikeRepository; import cc.backend.board.repository.CommentRepository; -import cc.backend.event.entity.CommentEvent; -import cc.backend.event.entity.ReplyEvent; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.replyEvent.ReplyEvent; +import cc.backend.kafka.event.replyEvent.ReplyProducer; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.event.CommentCommitEvent; +import cc.backend.notice.event.ReplyCommitEvent; +import cc.backend.notice.event.TicketReservationCommitEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -32,7 +37,8 @@ public class CommentService { private final BoardRepository boardRepository; private final MemberRepository memberRepository; - private final ApplicationEventPublisher eventPublisher; //이벤트 생성자 + private final ApplicationEventPublisher eventPublisher; + //댓글 작성 @Transactional public CommentCreateResponse createComment(Long boardId, Long memberId, CommentRequest req) { @@ -47,7 +53,11 @@ public CommentCreateResponse createComment(Long boardId, Long memberId, CommentR comment = Comment.createComment(req.getContent(), member, board); commentRepository.save(comment); - eventPublisher.publishEvent(new CommentEvent(boardId, board.getMember().getId(), comment.getId(), comment.getMember().getId())); //댓글 이벤트 생성 + //댓글 커밋 이벤트 생성 + eventPublisher.publishEvent( + new CommentCommitEvent(boardId, board.getMember().getId(), comment.getId(), comment.getMember().getId()) + ); + } else { // 대댓글 Comment parent = commentRepository.findById(req.getParentCommentId()) @@ -62,7 +72,12 @@ public CommentCreateResponse createComment(Long boardId, Long memberId, CommentR comment = Comment.createReply(req.getContent(), member, board, parent); commentRepository.save(comment); - eventPublisher.publishEvent(new ReplyEvent(parent.getId(), parent.getMember().getId(), comment.getId(), comment.getMember().getId())); //대댓글 이벤트 생성 + //대댓글 커밋 이벤트 생성 + eventPublisher.publishEvent( + new ReplyCommitEvent(parent.getId(), parent.getMember().getId(), comment.getId(), comment.getMember().getId()) + ); + + } diff --git a/src/main/java/cc/backend/event/entity/ApproveShowEvent.java b/src/main/java/cc/backend/event/entity/ApproveShowEvent.java deleted file mode 100644 index b8cb2ee..0000000 --- a/src/main/java/cc/backend/event/entity/ApproveShowEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.member.entity.Member; -import lombok.Getter; - -@Getter -public class ApproveShowEvent { - private final AmateurShow amateurShow; - private final Member member; //공연 등록자 - - public ApproveShowEvent(AmateurShow amateurShow, Member member) { - this.amateurShow = amateurShow; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/event/entity/CommentEvent.java b/src/main/java/cc/backend/event/entity/CommentEvent.java deleted file mode 100644 index e4197c0..0000000 --- a/src/main/java/cc/backend/event/entity/CommentEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package cc.backend.event.entity; - -import lombok.Getter; - -@Getter -public class CommentEvent { - private final Long boardId; - private final Long boardWriterId; //게시물 작성자 id - private final Long commentId; - private final Long commentWriterId; //댓글 작성자 id - - public CommentEvent(Long boardId, Long boardWriterId, Long commentId, Long commentWriterId) { - this.boardId = boardId; - this.boardWriterId = boardWriterId; - this.commentId = commentId; - this.commentWriterId = commentWriterId; - } - -} diff --git a/src/main/java/cc/backend/event/entity/NewShowEvent.java b/src/main/java/cc/backend/event/entity/NewShowEvent.java deleted file mode 100644 index b618a28..0000000 --- a/src/main/java/cc/backend/event/entity/NewShowEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.member.entity.Member; -import lombok.Getter; - -import java.util.List; - -@Getter -public class NewShowEvent { - private final Long amateurShowId; - private final Long memberId; //등록자 id - private final List members; // 등록자를 좋아요한 member 리스트(memberNotice 생성시 필요 - 알림 전달용) - - public NewShowEvent(Long amateurShowId, Long memberId, List members) { - this.amateurShowId = amateurShowId; - this.memberId = memberId; - this.members = members; - } - -} diff --git a/src/main/java/cc/backend/event/entity/PromoteHotEvent.java b/src/main/java/cc/backend/event/entity/PromoteHotEvent.java deleted file mode 100644 index 292954a..0000000 --- a/src/main/java/cc/backend/event/entity/PromoteHotEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.member.entity.Member; -import lombok.Getter; - -import java.util.List; - -@Getter -public class PromoteHotEvent { - private final Long boardId; - private final Long writerId; - - public PromoteHotEvent(Long boardId, Long writerId) { - this.boardId = boardId; - this.writerId = writerId; - } -} diff --git a/src/main/java/cc/backend/event/entity/RejectShowEvent.java b/src/main/java/cc/backend/event/entity/RejectShowEvent.java deleted file mode 100644 index d9c16eb..0000000 --- a/src/main/java/cc/backend/event/entity/RejectShowEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.member.entity.Member; -import lombok.Getter; - -@Getter -public class RejectShowEvent { - private final AmateurShow amateurShow; - private final Member member; //공연 등록자 - - public RejectShowEvent(AmateurShow amateurShow, Member member) { - this.amateurShow = amateurShow; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/event/entity/ReplyEvent.java b/src/main/java/cc/backend/event/entity/ReplyEvent.java deleted file mode 100644 index 3a73b9a..0000000 --- a/src/main/java/cc/backend/event/entity/ReplyEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package cc.backend.event.entity; - -import lombok.Getter; - -@Getter -public class ReplyEvent { - private final Long commentId; - private final Long commentWriterId; //댓글 작성자 id - private final Long replyId; // 대댓글 commentId - private final Long replyWriterId; //대댓글 작성자 id - - public ReplyEvent(Long commentId, Long commentWriterId, Long replyId, Long replyWriterId) { - this.commentId = commentId; - this.commentWriterId = commentWriterId; - this.replyId = replyId; - this.replyWriterId = replyWriterId; - } -} diff --git a/src/main/java/cc/backend/event/entity/TicketReservationEvent.java b/src/main/java/cc/backend/event/entity/TicketReservationEvent.java deleted file mode 100644 index bb1e6da..0000000 --- a/src/main/java/cc/backend/event/entity/TicketReservationEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.amateurShow.entity.AmateurTicket; -import cc.backend.member.entity.Member; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class TicketReservationEvent { - private final AmateurShow amateurShow; - private final AmateurTicket amateurTicket; - private final Member member; //예약자 - - public TicketReservationEvent(AmateurShow amateurShow, AmateurTicket amateurTicket, Member member) { - this.amateurShow = amateurShow; - this.amateurTicket = amateurTicket; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/event/service/ApproveShowEventListener.java b/src/main/java/cc/backend/event/service/ApproveShowEventListener.java deleted file mode 100644 index 0efd877..0000000 --- a/src/main/java/cc/backend/event/service/ApproveShowEventListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ApproveShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleApproveShowEvent(ApproveShowEvent event) { - - return noticeService.notifyApproval(event); - } - -} diff --git a/src/main/java/cc/backend/event/service/CommentEventListener.java b/src/main/java/cc/backend/event/service/CommentEventListener.java deleted file mode 100644 index ace7df8..0000000 --- a/src/main/java/cc/backend/event/service/CommentEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.CommentEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CommentEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleCommentCreate(CommentEvent event) { - - return noticeService.notifyNewComment(event); - } -} diff --git a/src/main/java/cc/backend/event/service/NewShowEventListener.java b/src/main/java/cc/backend/event/service/NewShowEventListener.java deleted file mode 100644 index d569b09..0000000 --- a/src/main/java/cc/backend/event/service/NewShowEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.NewShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NewShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleAmateurShowCreate(NewShowEvent event) { - - return noticeService.notifyNewShow(event); - } -} diff --git a/src/main/java/cc/backend/event/service/PromoteHotEventListener.java b/src/main/java/cc/backend/event/service/PromoteHotEventListener.java deleted file mode 100644 index 06d1fd8..0000000 --- a/src/main/java/cc/backend/event/service/PromoteHotEventListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.PromoteHotEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.hibernate.procedure.ProcedureOutputs; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PromoteHotEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleHotPromote(PromoteHotEvent event) { - - return noticeService.notifyHotBoard(event); - } -} diff --git a/src/main/java/cc/backend/event/service/RejectShowEventListener.java b/src/main/java/cc/backend/event/service/RejectShowEventListener.java deleted file mode 100644 index e58be80..0000000 --- a/src/main/java/cc/backend/event/service/RejectShowEventListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.RejectShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RejectShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleApproveShowEvent(RejectShowEvent event) { - - return noticeService.notifyRejection(event); - } -} diff --git a/src/main/java/cc/backend/event/service/ReplyEventListener.java b/src/main/java/cc/backend/event/service/ReplyEventListener.java deleted file mode 100644 index 9fa7857..0000000 --- a/src/main/java/cc/backend/event/service/ReplyEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.CommentEvent; -import cc.backend.event.entity.ReplyEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReplyEventListener { - private final NoticeService noticeService; - @EventListener - public NoticeResponseDTO.NoticeDTO handleReplyCreate(ReplyEvent event) { - - return noticeService.notifyNewReply(event); - } -} diff --git a/src/main/java/cc/backend/event/service/TicketReservationEventListener.java b/src/main/java/cc/backend/event/service/TicketReservationEventListener.java deleted file mode 100644 index c021a85..0000000 --- a/src/main/java/cc/backend/event/service/TicketReservationEventListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.ReplyEvent; -import cc.backend.event.entity.TicketReservationEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TicketReservationEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleReservation(TicketReservationEvent event) { - - return noticeService.notifyTicketReservation(event); - } -} diff --git a/src/main/java/cc/backend/image/service/ImageService.java b/src/main/java/cc/backend/image/service/ImageService.java index ecf0a49..33f956c 100644 --- a/src/main/java/cc/backend/image/service/ImageService.java +++ b/src/main/java/cc/backend/image/service/ImageService.java @@ -15,11 +15,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; -import java.util.Optional; import java.util.stream.Collectors; @Slf4j @Service @@ -41,6 +38,10 @@ public class ImageService { @Transactional public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImage(Long memberId, ImageRequestDTO.FullImageRequestDTO requestDTO) { + if (requestDTO.getKeyName() == null || requestDTO.getKeyName().isEmpty()) { + return null; // 빈 DTO 무시 + } + return saveImageWithImageUrl(memberId, requestDTO, Optional.empty()); } @@ -74,9 +75,22 @@ public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImageWithImageUrl( //다중 이미지 저장 @Transactional public List saveImages(Long memberId, List requestDTOs){ - return requestDTOs.stream() - .map(requestDTO-> saveImage(memberId, requestDTO)) - .collect(Collectors.toList()); + List results = new ArrayList<>(); + + for (ImageRequestDTO.FullImageRequestDTO dto : requestDTOs) { + try { + ImageResponseDTO.ImageResultWithPresignedUrlDTO saved = saveImage(memberId, dto); + if (saved != null) { + results.add(saved); + } + } catch (GeneralException ex) { + // 실패한 이미지는 로깅, 알림, DLQ 등 처리 가능 + log.warn("이미지 저장 실패: keyName={}, memberId={}, reason={}", + dto.getKeyName(), memberId, ex.getMessage()); + } + } + + return results; } // 이미지 단건 조회 diff --git a/src/main/java/cc/backend/kafka/KafkaConfig.java b/src/main/java/cc/backend/kafka/KafkaConfig.java new file mode 100644 index 0000000..0044125 --- /dev/null +++ b/src/main/java/cc/backend/kafka/KafkaConfig.java @@ -0,0 +1,98 @@ +package cc.backend.kafka; + +import cc.backend.kafka.event.common.DomainEvent; +import org.apache.kafka.common.TopicPartition; +import org.springframework.beans.factory.annotation.Value; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.util.backoff.FixedBackOff; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Bean + public ProducerFactory producerFactory() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true); + + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + @Bean + public ConsumerFactory consumerFactory() { + JsonDeserializer deserializer = new JsonDeserializer<>(DomainEvent.class); + deserializer.addTrustedPackages("*"); + deserializer.setUseTypeMapperForKey(false); + + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + return new DefaultKafkaConsumerFactory<>(props, new StringDeserializer(), deserializer); + } + + // 재시도 + DLQ 설정 + @Bean + public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = + new DeadLetterPublishingRecoverer(kafkaTemplate, + (record, ex) -> new TopicPartition(record.topic() + "-dlq", record.partition())); + + // 1초 간격, 최대 3회 재시도 + FixedBackOff backOff = new FixedBackOff(1000L, 3); + + return new DefaultErrorHandler(recoverer, backOff); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(1); // 컨슈머 병렬 처리 스레드 수 = 1 + factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러 발생시 처리 로직 + return factory; + } + + @Bean + public ConcurrentKafkaListenerContainerFactory highThroughputFactory(KafkaTemplate kafkaTemplate) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 파티션 여러 개인 토픽은 워커 스레드 3개로 처리 + factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); + return factory; + } + + +} diff --git a/src/main/java/cc/backend/kafka/TopicConfig.java b/src/main/java/cc/backend/kafka/TopicConfig.java new file mode 100644 index 0000000..0a30b89 --- /dev/null +++ b/src/main/java/cc/backend/kafka/TopicConfig.java @@ -0,0 +1,18 @@ +package cc.backend.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class TopicConfig { + + @Bean + public NewTopic approvalShowTopic() { + return TopicBuilder.name("approval-show-topic") + .partitions(3) // 파티션 수를 3개로 설정 + .replicas(1) // 단일 브로커(Docker 1대) 환경 + .build(); + } +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java new file mode 100644 index 0000000..50774a6 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java @@ -0,0 +1,16 @@ +package cc.backend.kafka.event.approvalShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record ApprovalShowEvent( + Long amateurShowId, + Long performerId +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.SHOW_APPROVED; + } +} + diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java new file mode 100644 index 0000000..7de3674 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java @@ -0,0 +1,22 @@ +package cc.backend.kafka.event.approvalShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApprovalShowProducer { + + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "approval-show-topic"; + + public void publish(ApprovalShowEvent event) { + if (event == null) return; + + // amateurShowId로 파티션 + kafkaTemplate.send(TOPIC, event.amateurShowId().toString(), event); + } + +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java new file mode 100644 index 0000000..2b01c67 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApprovalConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "approved-performer-group", + containerFactory = "kafkaListenerContainerFactory" + ) + + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyApproval(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java new file mode 100644 index 0000000..0489a1d --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java @@ -0,0 +1,31 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; + + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class LikerConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "liker-group", + containerFactory = "kafkaListenerContainerFactory" + ) + + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyLikers(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java new file mode 100644 index 0000000..9ed124e --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java @@ -0,0 +1,32 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; + + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; + + +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +@RequiredArgsConstructor +public class RecommendConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "recommend-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyRecommendation(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java new file mode 100644 index 0000000..363e1f6 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java @@ -0,0 +1,42 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.board.entity.Board; +import cc.backend.board.entity.Comment; +import cc.backend.board.repository.BoardRepository; +import cc.backend.board.repository.CommentRepository; +import cc.backend.member.entity.Member; +import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.entity.MemberNotice; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.repository.MemberNoticeRepository; +import cc.backend.notice.repository.NoticeRepository; +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + + +@Component +@RequiredArgsConstructor +public class CommentConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "comment-created-topic", + groupId = "comment-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(CommentEvent event) { + + if (event == null) return; + + noticeService.notifyNewComment(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java new file mode 100644 index 0000000..2d3b34d --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java @@ -0,0 +1,19 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record CommentEvent ( + Long boardId, + Long boardWriterId, //게시물 작성자 id + Long commentId, + Long commentWriterId //댓글 작성자 id +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.COMMENT_ON_BOARD; + } + +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java new file mode 100644 index 0000000..10a68ef --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java @@ -0,0 +1,26 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "comment-created-topic"; + + public void publish(CommentEvent event) { + if (event == null) return; + + // boardId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.boardId().toString(), + event + ); + } + +} diff --git a/src/main/java/cc/backend/kafka/event/common/DomainEvent.java b/src/main/java/cc/backend/kafka/event/common/DomainEvent.java new file mode 100644 index 0000000..b5f9989 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/common/DomainEvent.java @@ -0,0 +1,10 @@ +package cc.backend.kafka.event.common; + +import cc.backend.kafka.event.common.enums.DomainEventType; + +import java.io.Serializable; + +public interface DomainEvent extends Serializable { + DomainEventType getEventType(); + +} diff --git a/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java b/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java new file mode 100644 index 0000000..1cdd556 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java @@ -0,0 +1,10 @@ +package cc.backend.kafka.event.common.enums; + +public enum DomainEventType { + SHOW_APPROVED, + SHOW_REJECTED, + COMMENT_ON_BOARD, + REPLY_ON_COMMENT, + HOT_BOARD_BECAME, + TICKET_RESERVATION_COMPLETED +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java new file mode 100644 index 0000000..ad394ed --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java @@ -0,0 +1,26 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class HotBoardConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "hot-board-topic", + groupId = "hot-board-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(HotBoardEvent event) { + if (event == null) return; + + noticeService.notifyHotBoard(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java new file mode 100644 index 0000000..f91bf2f --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java @@ -0,0 +1,15 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record HotBoardEvent( + Long boardId, + Long writerId +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.HOT_BOARD_BECAME; + } +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java new file mode 100644 index 0000000..e04c262 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java @@ -0,0 +1,24 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HotBoardProducer { + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "hot-board-topic"; + + public void publish(HotBoardEvent event) { + if (event == null) return; + + // boardId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.boardId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java new file mode 100644 index 0000000..ee79b42 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java @@ -0,0 +1,37 @@ +package cc.backend.kafka.event.rejectShowEvent; + +import cc.backend.amateurShow.entity.AmateurShow; +import cc.backend.amateurShow.repository.AmateurShowRepository; +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.member.entity.Member; +import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.entity.MemberNotice; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.repository.MemberNoticeRepository; +import cc.backend.notice.repository.NoticeRepository; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RejectShowConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reject-show-topic", + groupId = "reject-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(RejectShowEvent event) { + if (event == null) return; + + noticeService.notifyRejection(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java new file mode 100644 index 0000000..340d92b --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java @@ -0,0 +1,17 @@ +package cc.backend.kafka.event.rejectShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record RejectShowEvent( + Long amateurShowId, + Long performerId, + String rejectReason +) implements DomainEvent { + + @Override + public DomainEventType getEventType(){ + return DomainEventType.SHOW_REJECTED; + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java new file mode 100644 index 0000000..5a2c0de --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.rejectShowEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RejectShowProducer { + + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reject-show-topic"; + + public void publish(RejectShowEvent event) { + if (event == null) return; + + // 공연 ID 기준으로 파티션 + kafkaTemplate.send( + TOPIC, + event.amateurShowId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java new file mode 100644 index 0000000..57d05f9 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.replyEvent; + +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ReplyConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reply-created-topic", + groupId = "reply-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(ReplyEvent event) { + if (event == null) return; + + noticeService.notifyNewReply(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java new file mode 100644 index 0000000..0eace3e --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java @@ -0,0 +1,18 @@ +package cc.backend.kafka.event.replyEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record ReplyEvent ( + Long commentId, + Long commentWriterId, //댓글 작성자 id + Long replyId, // 대댓글 commentId + Long replyWriterId //대댓글 작성자 id +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.REPLY_ON_COMMENT; + } +} + diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java new file mode 100644 index 0000000..11ae17f --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java @@ -0,0 +1,26 @@ +package cc.backend.kafka.event.replyEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReplyProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reply-created-topic"; + + public void publish(ReplyEvent event) { + if (event == null) return; + + // commentId 기준으로 파티션 + kafkaTemplate.send( + TOPIC, + event.commentId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java new file mode 100644 index 0000000..439e614 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class ReservationCompletedConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reservation-completed-topic", + groupId = "reservation-completed-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(ReservationCompletedEvent event) { + if (event == null) return; + + noticeService.notifyTicketReservation(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java new file mode 100644 index 0000000..7d439a9 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java @@ -0,0 +1,17 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record ReservationCompletedEvent( + Long amateurShowId, + Long realTicketId, + Long memberId //예약자 +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.TICKET_RESERVATION_COMPLETED; + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java new file mode 100644 index 0000000..da45577 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java @@ -0,0 +1,28 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReservationCompletedProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reservation-completed-topic"; + + + public void publish(ReservationCompletedEvent event) { + if (event == null) return; + + // memberId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.memberId().toString(), + event + ); + } +} + diff --git a/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java b/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java index 8cc0408..5138df3 100644 --- a/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java +++ b/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java @@ -37,4 +37,12 @@ Slice findLikedPerformersSortedBySoonestShow( @Param("now") LocalDateTime now, Pageable pageable ); + + // memberId 기준으로 해당 멤버가 좋아요한 모든 공연자 조회 + @Query("SELECT ml FROM MemberLike ml WHERE ml.liker.id = :memberId") + List findByLikerId(@Param("memberId") Long memberId); + + // 모든 회원을 대상으로, 추천 검사를 위해 distinct Member만 가져오기 + @Query("SELECT DISTINCT ml.liker FROM MemberLike ml") + List findAllDistinctMembers(); } diff --git a/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java b/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java index 759d18a..8c04610 100644 --- a/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java +++ b/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java @@ -97,15 +97,6 @@ public Slice getLikedPerformers(Long memberId, Pageable p } - private LocalDateTime findSoonestEndingShowDateForPerformer(Member performer) { - return amateurShowRepository.findAllByMemberWithRounds(performer).stream() - .flatMap(show -> show.getAmateurRounds().stream()) - .map(AmateurRounds::getPerformanceDateTime) - .filter(date -> date.isAfter(LocalDateTime.now())) - .min(Comparator.naturalOrder()) - .orElse(LocalDateTime.MAX); - } - private Member getMemberById(Long id) { return memberRepository.findById(id) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); diff --git a/src/main/java/cc/backend/notice/entity/MemberNotice.java b/src/main/java/cc/backend/notice/entity/MemberNotice.java index c1cfbe5..c25d84d 100644 --- a/src/main/java/cc/backend/notice/entity/MemberNotice.java +++ b/src/main/java/cc/backend/notice/entity/MemberNotice.java @@ -18,25 +18,29 @@ public class MemberNotice extends BaseEntity { @Column(nullable = false, columnDefinition = "bigint") private Long id; - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) - @JoinColumn(name = "notice_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notice_id", nullable = false) private Notice notice; - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) - @JoinColumn(name = "member_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) private Member member; - private Boolean isRead = false; + private String personalMsg; + + private boolean isRead = false; @Builder - public MemberNotice( Notice notice, Member member) { - this.isRead = false ; + public MemberNotice(Notice notice, Member member, String personalMsg, boolean isRead) { this.notice = notice; this.member = member; + this.personalMsg = personalMsg; + this.isRead = isRead; } - public MemberNotice updateIsRead(){ + public void updateIsRead(){ this.isRead = true ; - return this; } + + } diff --git a/src/main/java/cc/backend/notice/entity/enums/NoticeType.java b/src/main/java/cc/backend/notice/entity/enums/NoticeType.java index 02a1158..fe08853 100644 --- a/src/main/java/cc/backend/notice/entity/enums/NoticeType.java +++ b/src/main/java/cc/backend/notice/entity/enums/NoticeType.java @@ -1,5 +1,5 @@ package cc.backend.notice.entity.enums; public enum NoticeType { - AMATEURSHOW, HOT, COMMENT, REPLY, TICKET, REMIND, AD + AMATEURSHOW, HOT, COMMENT, REPLY, TICKET, REMIND, RECOMMEND } diff --git a/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java b/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java new file mode 100644 index 0000000..1f1c911 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java @@ -0,0 +1,6 @@ +package cc.backend.notice.event; + +public record ApproveCommitEvent ( + Long amateurShowId, + Long performerId +){} diff --git a/src/main/java/cc/backend/notice/event/CommentCommitEvent.java b/src/main/java/cc/backend/notice/event/CommentCommitEvent.java new file mode 100644 index 0000000..82b53b9 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/CommentCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + +public record CommentCommitEvent ( + Long boardId, + Long boardWriterId, + Long commentId, + Long commentWriterId +){} diff --git a/src/main/java/cc/backend/notice/event/RejectCommitEvent.java b/src/main/java/cc/backend/notice/event/RejectCommitEvent.java new file mode 100644 index 0000000..5ce9421 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/RejectCommitEvent.java @@ -0,0 +1,7 @@ +package cc.backend.notice.event; + +public record RejectCommitEvent ( + Long amateurShowId, + Long performerId, + String rejectReason +){ } diff --git a/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java b/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java new file mode 100644 index 0000000..36f8779 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + +public record ReplyCommitEvent ( + Long commentId, + Long commentWriterId, + Long replyId, + Long replyWriterId +){ } diff --git a/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java b/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java new file mode 100644 index 0000000..1779162 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + + +public record TicketReservationCommitEvent( + Long amateurShowId, + Long realTicketId, + Long memberId +) { } diff --git a/src/main/java/cc/backend/notice/repository/NoticeRepository.java b/src/main/java/cc/backend/notice/repository/NoticeRepository.java index 80fada6..7ed59d3 100644 --- a/src/main/java/cc/backend/notice/repository/NoticeRepository.java +++ b/src/main/java/cc/backend/notice/repository/NoticeRepository.java @@ -1,9 +1,12 @@ package cc.backend.notice.repository; import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface NoticeRepository extends JpaRepository { + + Boolean existsByContentIdAndType(Long contentId, NoticeType type); } diff --git a/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java new file mode 100644 index 0000000..9f73a99 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java @@ -0,0 +1,24 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; +import cc.backend.notice.event.ApproveCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ApproveCommitEventListener { + private final ApprovalShowProducer approvalShowProducer; + + @TransactionalEventListener (phase = TransactionPhase.AFTER_COMMIT) + public void onApproveCommit(ApproveCommitEvent event) { + + //APPROVED 수정 트랜잭션 커밋 이벤트 감지 후 kafka 이벤트 발송 + approvalShowProducer.publish( + new ApprovalShowEvent(event.amateurShowId(), event.performerId()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java b/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java new file mode 100644 index 0000000..b3190d1 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java @@ -0,0 +1,27 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.notice.dto.NoticeResponseDTO; +import cc.backend.notice.event.CommentCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class CommentCommitEventListener { + private final CommentProducer commentProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCommentCommit(CommentCommitEvent event) { + + // Comment 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + commentProducer.publish( + new CommentEvent(event.boardId(), event.boardWriterId(), event.commentId(), event.commentWriterId()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/MemberNoticeService.java b/src/main/java/cc/backend/notice/service/MemberNoticeService.java index e5ee185..a235466 100644 --- a/src/main/java/cc/backend/notice/service/MemberNoticeService.java +++ b/src/main/java/cc/backend/notice/service/MemberNoticeService.java @@ -48,7 +48,7 @@ public MemberNoticeResponseDTO.MemberNoticeListDTO getAllMemberNotice( .id(notice.getId()) .noticeType(notice.getNotice().getType()) .message(notice.getNotice().getMessage()) - .isRead(notice.getIsRead()) + .isRead(notice.isRead()) .contentId(notice.getNotice().getContentId()) .createdAt(notice.getCreatedAt()) .build()) @@ -89,7 +89,7 @@ public MemberNoticeResponseDTO.MemberNoticeDTO readNotice(Long noticeId, Long me .id(memberNotice.getId()) .noticeType(memberNotice.getNotice().getType()) .message(memberNotice.getNotice().getMessage()) - .isRead(memberNotice.getIsRead()) + .isRead(memberNotice.isRead()) .contentId(memberNotice.getNotice().getContentId()) .createdAt(memberNotice.getCreatedAt()) .build(); diff --git a/src/main/java/cc/backend/notice/service/NoticeService.java b/src/main/java/cc/backend/notice/service/NoticeService.java index d724423..cf0c818 100644 --- a/src/main/java/cc/backend/notice/service/NoticeService.java +++ b/src/main/java/cc/backend/notice/service/NoticeService.java @@ -1,18 +1,23 @@ package cc.backend.notice.service; -import cc.backend.event.entity.*; -import cc.backend.notice.dto.MemberNoticeResponseDTO; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; import cc.backend.notice.dto.NoticeResponseDTO; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.replyEvent.ReplyEvent; import org.springframework.stereotype.Service; @Service public interface NoticeService { - public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event); + public NoticeResponseDTO.NoticeDTO notifyHotBoard(HotBoardEvent event); public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event); public NoticeResponseDTO.NoticeDTO notifyNewReply(ReplyEvent event); - public NoticeResponseDTO.NoticeDTO notifyNewShow(NewShowEvent event); - public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEvent event); - public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyTicketReservation(ReservationCompletedEvent event); public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyApproval(ApprovalShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyLikers(ApprovalShowEvent event); } diff --git a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java index 4bdbd31..0ffdc47 100644 --- a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java +++ b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java @@ -10,24 +10,27 @@ import cc.backend.board.entity.Comment; import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentRepository; -import cc.backend.event.entity.*; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; -import cc.backend.notice.dto.MemberNoticeResponseDTO; +import cc.backend.memberLike.entity.MemberLike; +import cc.backend.memberLike.repository.MemberLikeRepository; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.entity.MemberNotice; import cc.backend.notice.entity.Notice; import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.replyEvent.ReplyEvent; import cc.backend.notice.repository.MemberNoticeRepository; import cc.backend.notice.repository.NoticeRepository; -import cc.backend.photoAlbum.dto.PhotoAlbumResponseDTO; -import cc.backend.photoAlbum.entity.PhotoAlbum; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; @Service @@ -41,16 +44,19 @@ public class NoticeServiceImpl implements NoticeService { private final AmateurShowRepository amateurShowRepository; private final CommentRepository commentRepository; private final AmateurTicketRepository amateurTicketRepository; + private final MemberLikeRepository memberLikeRepository; + + private static final int BATCH_SIZE = 50; @Transactional @Override - public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event) { - Long boardId = event.getBoardId(); + public NoticeResponseDTO.NoticeDTO notifyHotBoard(HotBoardEvent event) { + Long boardId = event.boardId(); - Member writer = memberRepository.findById(event.getWriterId()) + Member writer = memberRepository.findById(event.writerId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Notice newNotice = noticeRepository.save( + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.HOT) .message("회원님의 게시글이 HOT 게시글에 등록되었습니다!") @@ -61,152 +67,187 @@ public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event) { memberNoticeRepository.save( MemberNotice.builder() .member(writer) - .notice(newNotice) + .notice(notice) .build()); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) .contentId(boardId) - .createdAt(newNotice.getCreatedAt()) + .createdAt(notice.getCreatedAt()) .build(); } @Override @Transactional public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event) { - Board board = boardRepository.findById(event.getBoardId()) + // 1. 게시글 조회 + Board board = boardRepository.findById(event.boardId()) .orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND)); - Member boardWriter = memberRepository.findById(event.getBoardWriterId()) + + // 2. 게시글 작성자 조회 + Member boardWriter = memberRepository.findById(event.boardWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Comment comment = commentRepository.findById(event.getCommentId()) + + // 3. 댓글 조회 + Comment comment = commentRepository.findById(event.commentId()) .orElseThrow(() -> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Member commentWriter = memberRepository.findById(event.getCommentWriterId()) + + // 4. 댓글 작성자 조회 + Member commentWriter = memberRepository.findById(event.commentWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - if(boardWriter.equals(commentWriter)) { + // 5. 자기 글에 자기가 댓글 단 경우 알림 X + if (boardWriter.equals(commentWriter)) { return null; } - String preview = comment.getContent().length() > 15 ? comment.getContent().substring(0, 15) + "..." : comment.getContent(); + // 6. 댓글 미리보기 생성 + String preview = comment.getContent().length() > 15 + ? comment.getContent().substring(0, 15) + "..." + : comment.getContent(); - Notice newNotice = noticeRepository.save( + // 7. Notice 생성 + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.COMMENT) - .message("게시글 '" + board.getTitle() + "'에 새로운 댓글이 달렸습니다.\n" + '"' + preview + '"') - .contentId(event.getBoardId()) + .message( + "게시글 '" + board.getTitle() + "'에 새로운 댓글이 달렸습니다.\n" + + "\"" + preview + "\"" + ) + .contentId(board.getId()) .build() ); - memberNoticeRepository.save(MemberNotice.builder() - .notice(newNotice) - .member(boardWriter).build()); + // 8. 게시글 작성자에게 알림 발송 + memberNoticeRepository.save( + MemberNotice.builder() + .notice(notice) + .member(boardWriter) + .build() + ); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) .build(); } + @Override @Transactional - public NoticeResponseDTO.NoticeDTO notifyNewShow(NewShowEvent event){ - Long amateurShowId = event.getAmateurShowId(); - - AmateurShow amateurShow = amateurShowRepository.findById(amateurShowId) - .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event) { + // 1. 공연 조회 + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - List receivers = event.getMembers(); + // 2. 공연자 조회 + Member performer = memberRepository.findById(event.performerId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Notice newNotice = noticeRepository.save( + // 3. 반려 알림 생성 + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.AMATEURSHOW) - .message("소극장 공연 " + "'" + amateurShow.getName() + "'" + " 등록 완료! 소극장 공연 페이지에서 확인해보세요!") - .contentId(amateurShowId) + .message( + "요청하신 '" + show.getName() + "' 공연 등록이 반려되었습니다.\n" + + event.rejectReason() + ) + .contentId(show.getId()) .build() ); - memberNoticeRepository.saveAll( - receivers.stream() - .map(member -> MemberNotice.builder() - .notice(newNotice) - .member(member) - .build()) - .collect(Collectors.toList())); + // 4. 공연자에게 알림 발송 + memberNoticeRepository.save( + MemberNotice.builder() + .notice(notice) + .member(performer) + .build() + ); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) .build(); - } @Override @Transactional public NoticeResponseDTO.NoticeDTO notifyNewReply(ReplyEvent event) { - Comment comment = commentRepository.findById(event.getCommentId()) + Comment comment = commentRepository.findById(event.commentId()) .orElseThrow(()-> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Comment reply = commentRepository.findById(event.getReplyId()) + Comment reply = commentRepository.findById(event.replyId()) .orElseThrow(()-> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Member commentWriter = memberRepository.findById(event.getCommentWriterId()) + Member commentWriter = memberRepository.findById(event.commentWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Member replyWriter = memberRepository.findById(event.getReplyWriterId()) + Member replyWriter = memberRepository.findById(event.replyWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); if(commentWriter.equals(replyWriter)){ return null; } - String commentPreview = comment.getContent().length() > 15 ? comment.getContent().substring(0, 15) + "..." : comment.getContent(); - String replyPreview = reply.getContent().length() > 15 ? reply.getContent().substring(0, 15) + "..." : reply.getContent(); + String commentPreview = comment.getContent().length() > 15 + ? comment.getContent().substring(0, 15) + "..." + : comment.getContent(); + + String replyPreview = reply.getContent().length() > 15 + ? reply.getContent().substring(0, 15) + "..." + : reply.getContent(); - Notice newNotice = noticeRepository.save( + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.REPLY) .message("댓글 " + '"' + commentPreview + '"' + "에 새로운 대댓글이 달렸습니다.\n" + '"' + replyPreview + '"') - .contentId(event.getCommentId()) + .contentId(event.commentId()) .build() ); memberNoticeRepository.save( MemberNotice.builder() - .notice(newNotice) + .notice(notice) .member(commentWriter) .build()); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .noticeType(newNotice.getType()) - .message(newNotice.getMessage()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) + .id(notice.getId()) + .noticeType(notice.getType()) + .message(notice.getMessage()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) .build(); } @Override @Transactional - public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEvent event) { + public NoticeResponseDTO.NoticeDTO notifyTicketReservation(ReservationCompletedEvent event) { + AmateurShow amateurShow = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Member member = memberRepository.findById(event.memberId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + Notice notice = noticeRepository.save(Notice.builder() .type(NoticeType.TICKET) - .message("'" + event.getAmateurShow().getName() + "'" + " 공연 예약이 완료되었습니다.") - .contentId(event.getAmateurTicket().getId()) + .message("'" + amateurShow.getName() + "'" + " 공연 예약이 완료되었습니다.") + .contentId(event.realTicketId()) .build() ); memberNoticeRepository.save(MemberNotice.builder() .notice(notice) - .member(event.getMember()) + .member(member) .build() ); @@ -219,22 +260,69 @@ public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEven .build(); } - @Override - @Transactional - public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event) { - Notice notice = noticeRepository.save(Notice.builder() - .type(NoticeType.AMATEURSHOW) - .message("요청하신 " + "'" + event.getAmateurShow().getName() + "'"+ " 공연 등록이 승인되었습니다.") - .contentId(event.getAmateurShow().getId()) - .build() - ); + public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event) { - memberNoticeRepository.save(MemberNotice.builder() - .notice(notice) - .member(event.getMember()) - .build() + if (noticeRepository.existsByContentIdAndType( + event.amateurShowId(), NoticeType.RECOMMEND)) { + return null; + } + // 1. 공연 조회 + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + + // 2. 새 공연 해시태그 계산 + Set newTagsSet = Arrays.stream(Optional.ofNullable(show.getHashtag()).orElse("").split("[#,\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + if (newTagsSet.isEmpty()) return null; // 태그 없으면 추천 불가 + + + // 3. Notice 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.RECOMMEND) + .message("새로운 공연 '" + show.getName() + "' 어떠세요?") + .contentId(show.getId()) + .build() ); + + // 4. 추천 대상 회원 조회 (좋아요한 회원 기준) + List allMembers = memberLikeRepository.findAllDistinctMembers(); + List batch = new ArrayList<>(); + + for (Member member : allMembers) { + + //추천 대상이 아닌 멤버는 패스 + if (!shouldRecommendToMember(member, newTagsSet)) continue; + + /* 5. 개인화 메시지 생성 */ + String personalMsg = + "새로운 공연 '" + show.getName() + "' 어떠세요? " + + show.getHashtag() + " " + + member.getName() + "님 취향에 딱!"; + + batch.add(MemberNotice.builder() + .member(member) + .notice(notice) + .personalMsg(personalMsg) + .isRead(false) + .build() + ); + + if (batch.size() >= BATCH_SIZE) { + memberNoticeRepository.saveAll(batch); + batch.clear(); + } + } + + if (!batch.isEmpty()) { + memberNoticeRepository.saveAll(batch); + } + return NoticeResponseDTO.NoticeDTO.builder() .id(notice.getId()) .noticeType(notice.getType()) @@ -242,25 +330,95 @@ public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event) { .contentId(notice.getContentId()) .createdAt(notice.getCreatedAt()) .build(); + } - @Override - @Transactional - public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event){ - Notice notice = noticeRepository.save(Notice.builder() - .type(NoticeType.AMATEURSHOW) - .message("요청하신 " + "'" + event.getAmateurShow().getName() + "'" + " 공연 등록이 반려되었습니다." + "\n" - + event.getAmateurShow().getRejectReason()) - .contentId(event.getAmateurShow().getId()) - .build() + private boolean shouldRecommendToMember(Member member, Set newTagsSet) { + // 회원이 좋아요한 공연자 목록 조회 + List likedPerformers = memberLikeRepository.findByLikerId(member.getId()); + if (likedPerformers.isEmpty()) return false; + + for (MemberLike like : likedPerformers) { + Long likedPerformerId = like.getPerformer().getId(); + List hashtags = amateurShowRepository.findHashtagsByMemberId(likedPerformerId); + + for (String existingHashtags : hashtags) { + Set existingTagsSet = Arrays.stream(existingHashtags.split("#")) + .map(String::trim) + .collect(Collectors.toSet()); + Set intersection = new HashSet<>(newTagsSet); + intersection.retainAll(existingTagsSet); + + if (!intersection.isEmpty()) return true; + } + } + return false; + } + + public NoticeResponseDTO.NoticeDTO notifyApproval(ApprovalShowEvent event) { + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Member performer = memberRepository.findById(event.performerId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + // 승인된 공연 알림 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.AMATEURSHOW) + .message("요청하신 '" + show.getName() + "' 공연 등록이 승인되었습니다.") + .contentId(event.amateurShowId()) + .build() ); - memberNoticeRepository.save(MemberNotice.builder() + // 공연 등록자에게 MemberNotice 발송 + MemberNotice memberNotice = MemberNotice.builder() .notice(notice) - .member(event.getMember()) - .build() + .member(performer) + .build(); + + memberNoticeRepository.save(memberNotice); + + return NoticeResponseDTO.NoticeDTO.builder() + .id(notice.getId()) + .noticeType(notice.getType()) + .message(notice.getMessage()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) + .build(); + + } + + public NoticeResponseDTO.NoticeDTO notifyLikers(ApprovalShowEvent event) { + Long amateurShowId = event.amateurShowId(); + + // 공연 조회 + AmateurShow amateurShow = amateurShowRepository.findById(amateurShowId) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + // 공연자 좋아요한 유저 조회 + List likers = memberLikeRepository.findByPerformerId(event.performerId()); + if (likers.isEmpty()) return null; + + // 등록 알림 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.AMATEURSHOW) + .message("소극장 공연 '" + amateurShow.getName() + "' 등록 완료! 소극장 공연 페이지에서 확인해보세요!") + .contentId(amateurShowId) + .build() ); + // 등록자 계정 좋아요한 사람에게 MemberNotice 발송 + List memberNotices = likers.stream() + .map(liker -> MemberNotice.builder() + .notice(notice) + .member(liker.getLiker()) + .build()) + .toList(); + + memberNoticeRepository.saveAll(memberNotices); + return NoticeResponseDTO.NoticeDTO.builder() .id(notice.getId()) .noticeType(notice.getType()) @@ -270,4 +428,5 @@ public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event){ .build(); } + } diff --git a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java new file mode 100644 index 0000000..0125416 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java @@ -0,0 +1,25 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowProducer; +import cc.backend.notice.event.RejectCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class RejectCommitEventListener { + + private final RejectShowProducer rejectShowProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRejectCommit(RejectCommitEvent event) { + + // REJECTED 수정 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + rejectShowProducer.publish( + new RejectShowEvent(event.amateurShowId(), event.performerId(), event.rejectReason()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java b/src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java new file mode 100644 index 0000000..56b7679 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java @@ -0,0 +1,29 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.replyEvent.ReplyEvent; +import cc.backend.kafka.event.replyEvent.ReplyProducer; +import cc.backend.notice.event.ReplyCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ReplyCommitEventListener { + + private final ReplyProducer replyProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReplyCommit(ReplyCommitEvent event) { + + // Reply 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + replyProducer.publish( new ReplyEvent( + event.commentId(), + event.commentWriterId(), + event.replyId(), + event.replyWriterId() + ) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java new file mode 100644 index 0000000..1bc1df4 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java @@ -0,0 +1,25 @@ +package cc.backend.notice.service; + +import cc.backend.notice.event.TicketReservationCommitEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ReservationCommitEventListener { + + private final ReservationCompletedProducer reservationCompletedProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReservationCommit(TicketReservationCommitEvent event) { + + // TempTicket 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + reservationCompletedProducer.publish( + new ReservationCompletedEvent(event.amateurShowId(), event.realTicketId(), event.memberId()) + ); + } +} diff --git a/src/main/java/cc/backend/ticket/service/RealTicketService.java b/src/main/java/cc/backend/ticket/service/RealTicketService.java index 5b8f6cc..53c7aec 100644 --- a/src/main/java/cc/backend/ticket/service/RealTicketService.java +++ b/src/main/java/cc/backend/ticket/service/RealTicketService.java @@ -6,6 +6,7 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.notice.event.TicketReservationCommitEvent; import cc.backend.ticket.dto.response.RealTicketResponseDTO; import cc.backend.ticket.dto.response.ShowSnapshot; import cc.backend.ticket.entity.TempTicket; @@ -16,6 +17,7 @@ import cc.backend.ticket.repository.RealTicketRepository; import cc.backend.ticket.util.CancelPolicy; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -32,6 +34,7 @@ public class RealTicketService { private final RealTicketRepository realTicketRepository; private final TempTicketRepository tempTicketRepository; private final AmateurRoundsRepository amateurRoundsRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional @@ -67,7 +70,15 @@ public void createRealTicketFromTempTicket(TempTicket tempTicket) { .kakaoTid(tempTicket.getKakaoTid()) .build(); - realTicketRepository.save(realTicket); + RealTicket ticket = realTicketRepository.save(realTicket); + + // -> 먼저 ApplicationEvent를 완충 이벤트로 커밋 이후를 보장받고 나서 카프카 이벤트 발행 + eventPublisher.publishEvent( + new TicketReservationCommitEvent( + ticket.getAmateurRound().getAmateurShow().getId(), + ticket.getId(), + ticket.getMember().getId()) + ); } public Slice getMyTicketList(Long memberId, String status, int page, int size) { diff --git a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java index 7ef1367..2835f4a 100644 --- a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java +++ b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java @@ -8,7 +8,8 @@ import cc.backend.amateurShow.repository.AmateurTicketRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.TicketReservationEvent; +import cc.backend.notice.event.TicketReservationCommitEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedProducer; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; import cc.backend.ticket.dto.request.TempTicketCreateRequestDTO; @@ -39,6 +40,7 @@ public class TempTicketServiceImpl implements TempTicketService { private final ApplicationEventPublisher eventPublisher; private final RealTicketService realTicketService; private final MemberRepository memberRepository; + private final ReservationCompletedProducer reservationCompletedProducer; @Override @@ -90,9 +92,6 @@ public TempTicketCreateResponseDTO createTempTicket(Long amateurShowId, Long ama TempTicket saved = tempTicketRepository.save(ticket); - //티켓 예매 알림 이벤트 생성 - eventPublisher.publishEvent(new TicketReservationEvent(ticket.getAmateurTicket().getAmateurShow(), ticket.getAmateurTicket(), memberRef)); - // realTicket은 API를 사용해 호출 return TempTicketCreateResponseDTO.builder() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e760e90..6481356 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,21 @@ spring: host: ${REDIS_HOST} port: 6379 + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: true + + consumer: + group-id: cc-backend-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + auto-offset-reset: earliest url: access-token: https://oauth2.googleapis.com/token