diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bfaefd12..9785622f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,5 @@ ## πŸ“Œ κ΄€λ ¨ 이슈 -κ΄€λ ¨ 이슈 번호 # +κ΄€λ ¨ 이슈 번호 # Close # diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 00000000..1f273a76 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,71 @@ +name: GachTaxi-BE dev CI/CD + +on: + push: + branches: [ "dev" , "feat/#57/OCICICDνŒŒμ΄ν”„λΌμΈκ΅¬μΆ•"] # develop λΈŒλžœμΉ˜μ— push μ‹œ 트리거 + pull_request: + branches: [ "dev", "feat/#57/OCICICDνŒŒμ΄ν”„λΌμΈκ΅¬μΆ•" ] # develop λΈŒλžœμΉ˜μ— λŒ€ν•œ PR μ‹œ 트리거 + types: [opened, synchronize, reopened] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Gradle μΊμ‹œ μ„€μ • + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build with Gradle Wrapper + run: ./gradlew build -x test + + # λΉŒλ“œλœ JAR 파일 확인 + - name: List JAR files + run: ls build/libs + + # Docker 이미지 λΉŒλ“œ 및 ν‘Έμ‹œ + - name: Docker build & push + run: | + docker login -u ${{ secrets.DEV_DOCKER_USER_NAME }} -p ${{ secrets.DEV_DOCKER_USER_TOKEN }} + docker buildx create --use + docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile-dev -t ${{ secrets.DEV_DOCKER_USER_NAME }}/gachtaxi:${{ secrets.DEV_DOCKER_TAG }} --push . + + deploy: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' + + steps: + # SSHλ₯Ό μ‚¬μš©ν•˜μ—¬ 원격 μ„œλ²„μ— 배포 + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.DEV_SSH_SECRET_HOST }} + username: ${{ secrets.DEV_SSH_SECRET_USER }} + port: 22 + key: ${{ secrets.DEV_SSH_SECRET_PRIVATE_KEY }} + script: | + sudo docker pull ${{ secrets.DEV_DOCKER_USER_NAME }}/gachtaxi:${{ secrets.DEV_DOCKER_TAG }} + + sudo docker compose up -d --no-deps gachtaxi + + # μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” 이미지 정리 + echo "** μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” Docker 이미지 정리" + sudo docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml new file mode 100644 index 00000000..50672a9e --- /dev/null +++ b/.github/workflows/prod.yml @@ -0,0 +1,71 @@ +name: GachTaxi-BE prod CI/CD + +on: + push: + branches: [ "main"] + pull_request: + branches: [ "main"] + types: [opened, synchronize, reopened] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Gradle μΊμ‹œ μ„€μ • + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build with Gradle Wrapper + run: ./gradlew build -x test + + # λΉŒλ“œλœ JAR 파일 확인 + - name: List JAR files + run: ls build/libs + + # Docker 이미지 λΉŒλ“œ 및 ν‘Έμ‹œ + - name: Docker build & push + run: | + docker login -u ${{ secrets.DEV_DOCKER_USER_NAME }} -p ${{ secrets.DEV_DOCKER_USER_TOKEN }} + docker buildx create --use + docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile-dev -t ${{ secrets.DEV_DOCKER_USER_NAME }}/gachtaxi:${{ secrets.PROD_DOCKER_TAG }} --push . + + deploy: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' + + steps: + # SSHλ₯Ό μ‚¬μš©ν•˜μ—¬ 원격 μ„œλ²„μ— 배포 + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.PROD_SSH_SECRET_HOST }} + username: ${{ secrets.PROD_SSH_SECRET_USER }} + port: 22 + key: ${{ secrets.PROD_SSH_SECRET_PRIVATE_KEY }} + script: | + sudo docker pull ${{ secrets.PROD_DOCKER_USER_NAME }}/gachtaxi:${{ secrets.PROD_DOCKER_TAG }} + + sudo docker compose up -d --no-deps gachtaxi + + # μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” 이미지 정리 + echo "** μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” Docker 이미지 정리" + sudo docker image prune -f \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc2..7634ebb5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,11 @@ out/ ### VS Code ### .vscode/ + +src/main/resources/.env + +docker-compose.yml + +### firebase ### +/src/main/resources/serviceAccountKey.json + diff --git a/Dockerfile-dev b/Dockerfile-dev new file mode 100644 index 00000000..44e9a81b --- /dev/null +++ b/Dockerfile-dev @@ -0,0 +1,7 @@ +FROM eclipse-temurin:17-jre-focal + +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} docker-springboot.jar + +ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=dev", "/docker-springboot.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 88d352c6..b1e429a0 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ dependencies { // Spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' -// implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-websocket' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -38,17 +38,23 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - // Java Mail Sender - implementation 'org.springframework.boot:spring-boot-starter-mail' + // AWS SES + implementation 'software.amazon.awssdk:ses:2.29.46' // S3 Client - implementation 'software.amazon.awssdk:s3:2.20.126' + implementation 'software.amazon.awssdk:s3:2.29.46' // MongoDB, Redis, MySQL implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.mysql:mysql-connector-j' + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + + // Firebase + implementation 'com.google.firebase:firebase-admin:9.4.2' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/gachtaxi/GachtaxiApplication.java b/src/main/java/com/gachtaxi/GachtaxiApplication.java index cbdd3f1f..bb667bc3 100644 --- a/src/main/java/com/gachtaxi/GachtaxiApplication.java +++ b/src/main/java/com/gachtaxi/GachtaxiApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.mongodb.config.EnableMongoAuditing; @SpringBootApplication +@EnableJpaAuditing +@EnableMongoAuditing public class GachtaxiApplication { public static void main(String[] args) { diff --git a/src/main/java/com/gachtaxi/domain/chat/controller/ChattingController.java b/src/main/java/com/gachtaxi/domain/chat/controller/ChattingController.java new file mode 100644 index 00000000..33f6bdcc --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/controller/ChattingController.java @@ -0,0 +1,66 @@ +package com.gachtaxi.domain.chat.controller; + +import com.gachtaxi.domain.chat.dto.request.ChatMessageRequest; +import com.gachtaxi.domain.chat.dto.response.ChatResponse; +import com.gachtaxi.domain.chat.dto.response.ChattingRoomCountResponse; +import com.gachtaxi.domain.chat.dto.response.ChattingRoomResponse; +import com.gachtaxi.domain.chat.service.ChattingRoomService; +import com.gachtaxi.domain.chat.service.ChattingService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +import static com.gachtaxi.domain.chat.controller.ResponseMessage.*; +import static org.springframework.http.HttpStatus.OK; + +@Tag(name = "CHAT") +@RestController +@RequiredArgsConstructor +public class ChattingController { + + private final ChattingService chattingService; + private final ChattingRoomService chattingRoomService; + + @GetMapping("/api/chat/{roomId}") + @Operation(summary = "μ±„νŒ…λ°© μž¬μž…μž₯μ‹œ 이전 λ©”μ‹œμ§€λ₯Ό μ‘°νšŒν•˜κΈ° μœ„ν•œ APIμž…λ‹ˆλ‹€.") + public ApiResponse getChattingMessages(@PathVariable Long roomId, + @CurrentMemberId Long memberId, + @RequestParam int pageNumber, + @RequestParam int pageSize, + @RequestParam(required = false) LocalDateTime lastMessageTimeStamp) { + ChatResponse response = chattingService.getMessage(roomId, memberId, pageNumber, pageSize, lastMessageTimeStamp); + + return ApiResponse.response(OK, GET_CHATTING_MESSAGE_SUCCESS.getMessage(), response); + } + + @GetMapping("/api/chat/count/{roomId}") + @Operation(summary = "μ±„νŒ…λ°©μ˜ 총 μ°Έμ—¬μž 수λ₯Ό μ‘°νšŒν•˜κΈ° μœ„ν•œ APIμž…λ‹ˆλ‹€.") + public ApiResponse getChattingMessageCount(@CurrentMemberId Long memberId, + @PathVariable Long roomId) { + ChattingRoomCountResponse response = chattingRoomService.getCount(memberId, roomId); + + return ApiResponse.response(OK, GET_CHATTING_PARTICIPANT_COUNT_SUCCESS.getMessage(), response); + } + + @DeleteMapping("/api/chat/{roomId}") + @Operation(summary = "μ±„νŒ…λ°©μ„ 퇴μž₯ν•˜λŠ” APIμž…λ‹ˆλ‹€. 퇴μž₯μ‹œ STOMP UNSUBSCRIBEλ₯Ό κΌ­ ν•΄μ£Όμ„Έμš”") + public ApiResponse exitChattingRoom(@PathVariable Long roomId, + @CurrentMemberId Long memberId) { + chattingRoomService.exitChatRoom(roomId, memberId); + + return ApiResponse.response(OK, EXIT_CHATTING_ROOM_SUCCESS.getMessage()); + } + + @MessageMapping("/chat/message") + public void message(@Valid ChatMessageRequest request, SimpMessageHeaderAccessor headerAccessor) { + chattingService.chat(request, headerAccessor); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/domain/chat/controller/ResponseMessage.java new file mode 100644 index 00000000..020c7367 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/controller/ResponseMessage.java @@ -0,0 +1,16 @@ +package com.gachtaxi.domain.chat.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + CREATE_CHATTING_ROOM_SUCCESS("μ±„νŒ…λ°© 생성에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + GET_CHATTING_MESSAGE_SUCCESS("이전 λ©”μ‹œμ§€ μ‘°νšŒμ— 성곡 ν–ˆμŠ΅λ‹ˆλ‹€."), + EXIT_CHATTING_ROOM_SUCCESS("μ±„νŒ…λ°© 퇴μž₯에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + GET_CHATTING_PARTICIPANT_COUNT_SUCCESS("μ±„νŒ…λ°© 전체 μ°Έμ—¬μž μ‘°νšŒμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessage.java b/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessage.java index 4d63b7d1..66a75f76 100644 --- a/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessage.java +++ b/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessage.java @@ -1,14 +1,49 @@ package com.gachtaxi.domain.chat.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gachtaxi.domain.chat.dto.response.ReadMessageRange; +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import com.gachtaxi.domain.chat.entity.enums.MessageType; +import lombok.Builder; + import java.time.LocalDateTime; +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) public record ChatMessage( - long roomId, - long senderId, + String messageId, + Long roomId, + Long senderId, + String senderName, String message, - LocalDateTime timeStamp + String profilePicture, + ReadMessageRange range, + Long unreadCount, + LocalDateTime timeStamp, + MessageType messageType ) { - public static ChatMessage of(long roomId, long senderId, String message, LocalDateTime timeStamp) { - return new ChatMessage(roomId, senderId, message, timeStamp); + public static ChatMessage from(ChattingMessage chattingMessage) { + return ChatMessage.builder() + .messageId(chattingMessage.getId()) + .roomId(chattingMessage.getRoomId()) + .senderId(chattingMessage.getSenderId()) + .senderName(chattingMessage.getSenderName()) + .message(chattingMessage.getMessage()) + .profilePicture(chattingMessage.getProfilePicture()) + .unreadCount(chattingMessage.getUnreadCount()) + .timeStamp(chattingMessage.getTimeStamp()) + .messageType(chattingMessage.getMessageType()) + .build(); + } + + public static ChatMessage of(long roomId, Long senderId, String senderName, ReadMessageRange range, MessageType messageType) { + return ChatMessage.builder() + .roomId(roomId) + .senderId(senderId) + .senderName(senderName) + .range(range) + .timeStamp(LocalDateTime.now()) + .messageType(messageType) + .build(); } } diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessageRequest.java b/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessageRequest.java index 2552ac13..c25afa98 100644 --- a/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessageRequest.java +++ b/src/main/java/com/gachtaxi/domain/chat/dto/request/ChatMessageRequest.java @@ -1,7 +1,8 @@ package com.gachtaxi.domain.chat.dto.request; +import jakarta.validation.constraints.NotBlank; + public record ChatMessageRequest( - Long roomId, - String message + @NotBlank String message ) { } diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatPageableResponse.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatPageableResponse.java new file mode 100644 index 00000000..3ca3ff13 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatPageableResponse.java @@ -0,0 +1,24 @@ +package com.gachtaxi.domain.chat.dto.response; + +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import lombok.Builder; +import org.springframework.data.domain.Slice; + +@Builder +public record ChatPageableResponse( + int pageNumber, + int pageSize, + int numberOfElements, + boolean last, + boolean empty +) { + public static ChatPageableResponse of(int pageNumber, Slice slice) { + return ChatPageableResponse.builder() + .pageNumber(pageNumber) + .pageSize(slice.getSize()) + .numberOfElements(slice.getNumberOfElements()) + .last(slice.isLast()) + .empty(slice.isEmpty()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatResponse.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatResponse.java new file mode 100644 index 00000000..60f64ff1 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChatResponse.java @@ -0,0 +1,24 @@ +package com.gachtaxi.domain.chat.dto.response; + +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +public record ChatResponse( + Long memberId, + LocalDateTime disconnectedAt, + List chattingMessage, + ChatPageableResponse pageable +) { + public static ChatResponse of(ChattingParticipant chattingParticipant, List chattingMessages, ChatPageableResponse chatPageableResponse) { + return ChatResponse.builder() + .memberId(chattingParticipant.getMembers().getId()) + .disconnectedAt(chattingParticipant.getDisconnectedAt()) + .chattingMessage(chattingMessages) + .pageable(chatPageableResponse) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingMessageResponse.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingMessageResponse.java new file mode 100644 index 00000000..a7dfaa8d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingMessageResponse.java @@ -0,0 +1,32 @@ +package com.gachtaxi.domain.chat.dto.response; + +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import com.gachtaxi.domain.chat.entity.enums.MessageType; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record ChattingMessageResponse( + String messageId, + Long senderId, + String senderName, + String message, + String profilePicture, + Long unreadCount, + LocalDateTime timeStamp, + MessageType messageType +) { + public static ChattingMessageResponse from(ChattingMessage chattingMessage) { + return ChattingMessageResponse.builder() + .messageId(chattingMessage.getId()) + .senderId(chattingMessage.getSenderId()) + .senderName(chattingMessage.getSenderName()) + .message(chattingMessage.getMessage()) + .profilePicture(chattingMessage.getProfilePicture()) + .unreadCount(chattingMessage.getUnreadCount()) + .timeStamp(chattingMessage.getTimeStamp()) + .messageType(chattingMessage.getMessageType()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomCountResponse.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomCountResponse.java new file mode 100644 index 00000000..b8cb308f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomCountResponse.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.chat.dto.response; + +public record ChattingRoomCountResponse( + Long roomId, + Long totalParticipantCount +) { + public static ChattingRoomCountResponse of(Long roomId, Long totalParticipantCount) { + return new ChattingRoomCountResponse(roomId, totalParticipantCount); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomResponse.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomResponse.java new file mode 100644 index 00000000..d6b32522 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ChattingRoomResponse.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.chat.dto.response; + +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.entity.enums.ChatStatus; + +public record ChattingRoomResponse( + Long roomId, + ChatStatus status +) { + public static ChattingRoomResponse from(ChattingRoom chattingRoom) { + return new ChattingRoomResponse(chattingRoom.getId(), chattingRoom.getStatus()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/dto/response/ReadMessageRange.java b/src/main/java/com/gachtaxi/domain/chat/dto/response/ReadMessageRange.java new file mode 100644 index 00000000..a47a5b58 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/dto/response/ReadMessageRange.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.dto.response; + +import org.springframework.data.util.Pair; + +public record ReadMessageRange( + String startMessageId, + String endMessageId +) { + public static ReadMessageRange from(Pair pair) { + return new ReadMessageRange(pair.getFirst(), pair.getSecond()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/entity/ChattingMessage.java b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingMessage.java new file mode 100644 index 00000000..f985b7b9 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingMessage.java @@ -0,0 +1,66 @@ +package com.gachtaxi.domain.chat.entity; + +import com.gachtaxi.domain.chat.dto.request.ChatMessageRequest; +import com.gachtaxi.domain.chat.entity.enums.MessageType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Document(collection = "chatting_messages") +public class ChattingMessage { + + @Id + private String id; + + private Long senderId; + + private String senderName; + + private String profilePicture; + + private Long roomId; + + private String message; + + private Long unreadCount; + + private MessageType messageType; + + private LocalDateTime timeStamp; + + @LastModifiedDate + private LocalDateTime updatedAt; + + public static ChattingMessage of(ChatMessageRequest request, long roomId, long senderId, String senderName, long unreadCount, String profilePicture) { + return ChattingMessage.builder() + .senderId(senderId) + .senderName(senderName) + .roomId(roomId) + .message(request.message()) + .profilePicture(profilePicture) + .unreadCount(unreadCount) + .messageType(MessageType.MESSAGE) + .timeStamp(LocalDateTime.now()) + .build(); + } + + public static ChattingMessage of(long roomId, Long senderId, String senderName, String message, MessageType messageType) { + return ChattingMessage.builder() + .senderId(senderId) + .senderName(senderName) + .roomId(roomId) + .message(message) + .messageType(messageType) + .timeStamp(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/entity/ChattingParticipant.java b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingParticipant.java new file mode 100644 index 00000000..7a488407 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingParticipant.java @@ -0,0 +1,63 @@ +package com.gachtaxi.domain.chat.entity; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Getter +@Entity +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "chatting_participant", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"members_id", "chatting_room_id"}) + } +) +public class ChattingParticipant extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "chatting_room_id") + private ChattingRoom chattingRoom; + + @ManyToOne + @JoinColumn(name = "members_id") + private Members members; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime joinedAt; + + @CreatedDate + private LocalDateTime lastReadAt; + + private LocalDateTime disconnectedAt; + + public static ChattingParticipant of(ChattingRoom chattingRoom, Members members) { + return ChattingParticipant.builder() + .chattingRoom(chattingRoom) + .members(members) + .build(); + } + + public void reSubscribe() { + this.lastReadAt = LocalDateTime.now(); + } + + public void unsubscribe() { + this.lastReadAt = LocalDateTime.now(); + this.disconnectedAt = LocalDateTime.now(); + } + + public void disconnect() { + this.lastReadAt = LocalDateTime.now(); + this.disconnectedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/entity/ChattingRoom.java b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingRoom.java new file mode 100644 index 00000000..7a86d219 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/entity/ChattingRoom.java @@ -0,0 +1,24 @@ +package com.gachtaxi.domain.chat.entity; + +import com.gachtaxi.domain.chat.entity.enums.ChatStatus; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Getter +@Entity +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChattingRoom extends BaseEntity { + + @Builder.Default + @Enumerated(EnumType.STRING) + private ChatStatus status = ChatStatus.ACTIVE; + + public void delete() { + status = ChatStatus.INACTIVE; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/entity/enums/ChatStatus.java b/src/main/java/com/gachtaxi/domain/chat/entity/enums/ChatStatus.java new file mode 100644 index 00000000..2bb1d2ab --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/entity/enums/ChatStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.chat.entity.enums; + +public enum ChatStatus { + ACTIVE, INACTIVE +} diff --git a/src/main/java/com/gachtaxi/domain/chat/entity/enums/MessageType.java b/src/main/java/com/gachtaxi/domain/chat/entity/enums/MessageType.java new file mode 100644 index 00000000..095750a7 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/entity/enums/MessageType.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.chat.entity.enums; + +public enum MessageType { + MESSAGE, ENTER, EXIT, READ +} diff --git a/src/main/java/com/gachtaxi/domain/chat/event/WebSocketEventHandler.java b/src/main/java/com/gachtaxi/domain/chat/event/WebSocketEventHandler.java new file mode 100644 index 00000000..4e81f1fc --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/event/WebSocketEventHandler.java @@ -0,0 +1,68 @@ +package com.gachtaxi.domain.chat.event; + +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import com.gachtaxi.domain.chat.exception.ChattingParticipantNotFoundException; +import com.gachtaxi.domain.chat.service.ChattingParticipantService; +import com.gachtaxi.domain.chat.service.ChattingRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import static com.gachtaxi.domain.chat.stomp.strategy.StompConnectStrategy.CHAT_USER_ID; +import static com.gachtaxi.domain.chat.stomp.strategy.StompSubscribeStrategy.CHAT_ROOM_ID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketEventHandler { + + private final ChattingParticipantService chattingParticipantService; + private final ChattingRedisService chattingRedisService; + + @EventListener + @Transactional + public void handleDisconnect(SessionDisconnectEvent event) { + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(event.getMessage()); + + try { + long roomId = (long) accessor.getSessionAttributes().get(CHAT_ROOM_ID); + long userId = (long) accessor.getSessionAttributes().get(CHAT_USER_ID); + + ChattingParticipant chattingParticipant = chattingParticipantService.find(roomId, userId); + + if (chattingRedisService.isActive(roomId, userId)) { + chattingParticipant.disconnect(); + chattingRedisService.removeSubscribeMember(roomId, userId); + } + } catch (NullPointerException e) { + log.info("[handleDisconnect] ꡬ독 정보가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } catch (ChattingParticipantNotFoundException e) { + log.warn("[handleDisconnect] 이미 퇴μž₯ν•œ μ°Έμ—¬μž μž…λ‹ˆλ‹€."); + } + } + + @EventListener + @Transactional + public void handleUnsubscribe(SessionUnsubscribeEvent event) { + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(event.getMessage()); + + long roomId = (long) accessor.getSessionAttributes().get(CHAT_ROOM_ID); + long userId = (long) accessor.getSessionAttributes().get(CHAT_USER_ID); + + try { + ChattingParticipant chattingParticipant = chattingParticipantService.find(roomId, userId); + + if (chattingRedisService.isActive(roomId, userId)) { + chattingParticipant.unsubscribe(); + chattingRedisService.removeSubscribeMember(roomId, userId); + } + } catch (ChattingParticipantNotFoundException e) { + log.warn("[handleUnsubscribe] 이미 퇴μž₯ν•œ μ°Έμ—¬μž μž…λ‹ˆλ‹€."); + } + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/ChatSendEndPointException.java b/src/main/java/com/gachtaxi/domain/chat/exception/ChatSendEndPointException.java new file mode 100644 index 00000000..235d01af --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/ChatSendEndPointException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.CHAT_SEND_END_POINT_ERROR; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class ChatSendEndPointException extends BaseException { + public ChatSendEndPointException() { + super(BAD_REQUEST, CHAT_SEND_END_POINT_ERROR.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/ChatSubscribeException.java b/src/main/java/com/gachtaxi/domain/chat/exception/ChatSubscribeException.java new file mode 100644 index 00000000..1c981f58 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/ChatSubscribeException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.CHAT_SUBSCRIBE_ERROR; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class ChatSubscribeException extends BaseException { + public ChatSubscribeException() { + super(BAD_REQUEST, CHAT_SUBSCRIBE_ERROR.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/ChattingParticipantNotFoundException.java b/src/main/java/com/gachtaxi/domain/chat/exception/ChattingParticipantNotFoundException.java new file mode 100644 index 00000000..920d3707 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/ChattingParticipantNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.CHATTING_PARTICIPANT_NOT_FOUND; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class ChattingParticipantNotFoundException extends BaseException { + public ChattingParticipantNotFoundException() { + super(NOT_FOUND, CHATTING_PARTICIPANT_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/ChattingRoomNotFoundException.java b/src/main/java/com/gachtaxi/domain/chat/exception/ChattingRoomNotFoundException.java new file mode 100644 index 00000000..c30c9ea9 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/ChattingRoomNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.CHATTING_ROOM_NOT_FOUND; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class ChattingRoomNotFoundException extends BaseException { + public ChattingRoomNotFoundException() { + super(NOT_FOUND, CHATTING_ROOM_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/DuplicateSubscribeException.java b/src/main/java/com/gachtaxi/domain/chat/exception/DuplicateSubscribeException.java new file mode 100644 index 00000000..6d91c409 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/DuplicateSubscribeException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.DUPLICATE_SUBSCRIBE; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class DuplicateSubscribeException extends BaseException { + public DuplicateSubscribeException() { + super(BAD_REQUEST, DUPLICATE_SUBSCRIBE.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/ErrorMessage.java b/src/main/java/com/gachtaxi/domain/chat/exception/ErrorMessage.java index 923884cb..e43987e1 100644 --- a/src/main/java/com/gachtaxi/domain/chat/exception/ErrorMessage.java +++ b/src/main/java/com/gachtaxi/domain/chat/exception/ErrorMessage.java @@ -10,7 +10,15 @@ public enum ErrorMessage { SERIALIZATION_ERROR("[Redis] 데이터 직렬화에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€"), MESSAGING_ERROR("STOMP λ©”μ‹œμ§€ 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€"), JSON_PROCESSING_ERROR("Json 직렬화에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), - REDIS_SUB_ERROR("[Redis] λ©”μ‹œμ§€ 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + REDIS_SUB_ERROR("[Redis] λ©”μ‹œμ§€ 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + CHAT_SUBSCRIBE_ERROR("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ±„νŒ… ꡬ독 κ²½λ‘œμž…λ‹ˆλ‹€."), + CHATTING_ROOM_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ±„νŒ…λ°©μž…λ‹ˆλ‹€."), + CHAT_SEND_END_POINT_ERROR("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ±„νŒ… λ©”μ‹œμ§€ κ²½λ‘œμž…λ‹ˆλ‹€."), + WEB_SOCKET_SESSION_ATTR_NOT_FOUND(" κ°€ μ›Ήμ†ŒμΌ“ μ„Έμ…˜μ— μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + DUPLICATE_SUBSCRIBE("같은 μ±„νŒ…λ°©μ„ μ€‘λ³΅μœΌλ‘œ κ΅¬λ…ν–ˆμŠ΅λ‹ˆλ‹€."), + CHATTING_PARTICIPANT_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ±„νŒ… μ°Έμ—¬μž μž…λ‹ˆλ‹€."), + LAST_TIME_STAMP_NULL("λ§ˆμ§€λ§‰ μ±„νŒ…μ˜ μΌμžλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."), + UN_SUBSCRIBE("μ±„νŒ…λ°©μ„ κ΅¬λ…ν•˜μ§€ μ•Šμ€ μ°Έμ—¬μž μž…λ‹ˆλ‹€."); private final String message; } diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/LastMessageTimeStampNullException.java b/src/main/java/com/gachtaxi/domain/chat/exception/LastMessageTimeStampNullException.java new file mode 100644 index 00000000..b00249d2 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/LastMessageTimeStampNullException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.LAST_TIME_STAMP_NULL; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class LastMessageTimeStampNullException extends BaseException { + public LastMessageTimeStampNullException() { + super(BAD_REQUEST, LAST_TIME_STAMP_NULL.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/UnSubscriptionException.java b/src/main/java/com/gachtaxi/domain/chat/exception/UnSubscriptionException.java new file mode 100644 index 00000000..d8129423 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/UnSubscriptionException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.UN_SUBSCRIBE; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class UnSubscriptionException extends BaseException { + public UnSubscriptionException() { + super(BAD_REQUEST, UN_SUBSCRIBE.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/exception/WebSocketSessionException.java b/src/main/java/com/gachtaxi/domain/chat/exception/WebSocketSessionException.java new file mode 100644 index 00000000..134f9295 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/exception/WebSocketSessionException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.chat.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.chat.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +public class WebSocketSessionException extends BaseException { + public WebSocketSessionException(String keyword) { + super(INTERNAL_SERVER_ERROR, keyword + WEB_SOCKET_SESSION_ATTR_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageMongoRepository.java b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageMongoRepository.java new file mode 100644 index 00000000..84b6e2da --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageMongoRepository.java @@ -0,0 +1,69 @@ +package com.gachtaxi.domain.chat.repository; + +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import com.gachtaxi.domain.members.entity.Members; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +@RequiredArgsConstructor +public class ChattingMessageMongoRepository { + + private static final String COLLECTION = "chatting_messages"; + private static final String EMPTY_READ_MESSAGE = "NO_MESSAGES"; + private final MongoTemplate mongoTemplate; + + public Pair updateUnreadCount(Long roomId, LocalDateTime lastReadAt, Long senderId) { + Query query = new Query().addCriteria(buildCommonCriteria(roomId, lastReadAt, senderId)); + + Update update = new Update().inc("unreadCount", -1); + + Pair range = getUpdatedMessageRange(roomId, lastReadAt, senderId); + mongoTemplate.updateMulti(query, update, ChattingMessage.class); + + return range; + } + + public void updateMemberInfo(Members member) { + Query query = new Query(); + query.addCriteria(Criteria.where("senderId").is(member.getId())); + + Update update = new Update() + .set("profilePicture", member.getProfilePicture()) + .set("senderName", member.getNickname()) + .set("updatedAt", LocalDateTime.now()); + + mongoTemplate.updateMulti(query, update, ChattingMessage.class); + } + + private Pair getUpdatedMessageRange(Long roomId, LocalDateTime lastReadAt, Long senderId) { + Query minQuery = new Query().addCriteria(buildCommonCriteria(roomId, lastReadAt, senderId)) + .with(Sort.by(Sort.Direction.ASC, "_id")).limit(1); + + Query maxQuery = new Query().addCriteria(buildCommonCriteria(roomId, lastReadAt, senderId)) + .with(Sort.by(Sort.Direction.DESC, "_id")).limit(1); + + ChattingMessage minMessage = mongoTemplate.findOne(minQuery, ChattingMessage.class, COLLECTION); + ChattingMessage maxMessage = mongoTemplate.findOne(maxQuery, ChattingMessage.class, COLLECTION); + + String minId = (minMessage != null) ? minMessage.getId() : EMPTY_READ_MESSAGE; + String maxId = (maxMessage != null) ? maxMessage.getId() : EMPTY_READ_MESSAGE; + + return Pair.of(minId, maxId); + } + + private Criteria buildCommonCriteria(Long roomId, LocalDateTime lastReadAt, Long senderId) { + return Criteria.where("roomId").is(roomId) + .and("timeStamp").gt(lastReadAt) + .and("senderId").ne(senderId) + .and("unreadCount").gt(0); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageRepository.java b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageRepository.java new file mode 100644 index 00000000..0071ec4d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingMessageRepository.java @@ -0,0 +1,26 @@ +package com.gachtaxi.domain.chat.repository; + +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; + +import java.time.LocalDateTime; + +public interface ChattingMessageRepository extends MongoRepository { + Slice findAllByRoomIdAndTimeStampAfterOrderByTimeStampDesc(Long roomId, LocalDateTime joinedAt, Pageable pageable); + + @Query(""" + { + 'roomId': ?0, + 'timeStamp': { + $gt: ?1, + $lt: ?2 + } + } + """) + Slice findAllByRoomIdAndTimeStampAfterAndTimeStampBeforeOrderByTimeStampDesc(Long roomId, LocalDateTime joinedAt, LocalDateTime lastMessageTimeStamp, Pageable pageable); + + Integer countAllByRoomIdAndTimeStampAfterOrderByTimeStampDesc(Long roomId, LocalDateTime timestamp); +} diff --git a/src/main/java/com/gachtaxi/domain/chat/repository/ChattingParticipantRepository.java b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingParticipantRepository.java new file mode 100644 index 00000000..1eb70788 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingParticipantRepository.java @@ -0,0 +1,17 @@ +package com.gachtaxi.domain.chat.repository; + +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.members.entity.Members; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ChattingParticipantRepository extends JpaRepository { + + Optional findByChattingRoomAndMembers(ChattingRoom chattingRoom, Members member); + + Optional findByChattingRoomIdAndMembersId(long roomId, long memberId); + + long countByChattingRoomId(long chattingRoomId); +} diff --git a/src/main/java/com/gachtaxi/domain/chat/repository/ChattingRoomRepository.java b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingRoomRepository.java new file mode 100644 index 00000000..3e1baa94 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/repository/ChattingRoomRepository.java @@ -0,0 +1,8 @@ +package com.gachtaxi.domain.chat.repository; + +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChattingRoomRepository extends JpaRepository { + +} diff --git a/src/main/java/com/gachtaxi/domain/chat/service/ChattingParticipantService.java b/src/main/java/com/gachtaxi/domain/chat/service/ChattingParticipantService.java new file mode 100644 index 00000000..c0c8ca85 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/service/ChattingParticipantService.java @@ -0,0 +1,88 @@ +package com.gachtaxi.domain.chat.service; + +import com.gachtaxi.domain.chat.dto.request.ChatMessage; +import com.gachtaxi.domain.chat.dto.response.ReadMessageRange; +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.entity.enums.MessageType; +import com.gachtaxi.domain.chat.exception.ChattingParticipantNotFoundException; +import com.gachtaxi.domain.chat.exception.DuplicateSubscribeException; +import com.gachtaxi.domain.chat.redis.RedisChatPublisher; +import com.gachtaxi.domain.chat.repository.ChattingMessageMongoRepository; +import com.gachtaxi.domain.chat.repository.ChattingParticipantRepository; +import com.gachtaxi.domain.members.entity.Members; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ChattingParticipantService { + + private final ChattingParticipantRepository chattingParticipantRepository; + private final ChattingMessageMongoRepository chattingMessageMongoRepository; + private final ChattingRedisService chattingRedisService; + private final RedisChatPublisher redisChatPublisher; + + @Value("${chat.topic}") + public String chatTopic; + + public void save(ChattingParticipant chattingParticipant) { + chattingParticipantRepository.save(chattingParticipant); + } + + public ChattingParticipant find(ChattingRoom chattingRoom, Members member) { + return chattingParticipantRepository.findByChattingRoomAndMembers(chattingRoom, member) + .orElseThrow(ChattingParticipantNotFoundException::new); + } + + public ChattingParticipant find(long roomId, long memberId) { + return chattingParticipantRepository.findByChattingRoomIdAndMembersId(roomId, memberId) + .orElseThrow(ChattingParticipantNotFoundException::new); + } + + public boolean checkSubscription(ChattingRoom chattingRoom, Members members) { + Optional optionalParticipant = chattingParticipantRepository.findByChattingRoomAndMembers(chattingRoom, members); + + if (optionalParticipant.isPresent()) { + ChattingParticipant chattingParticipant = optionalParticipant.get(); + +// checkDuplicateSubscription(chattingRoom.getId(), members.getId()); + + Pair pair = chattingMessageMongoRepository.updateUnreadCount(chattingRoom.getId(), chattingParticipant.getLastReadAt(), members.getId()); + + reEnterEvent(chattingRoom.getId(), members.getId(), members.getNickname(), ReadMessageRange.from(pair)); + + chattingParticipant.reSubscribe(); + + return true; + } + + return false; + } + + public void delete(ChattingParticipant chattingParticipant) { + chattingParticipantRepository.delete(chattingParticipant); + } + + public long getParticipantCount(Long roomId) { + return chattingParticipantRepository.countByChattingRoomId(roomId); + } + + private void checkDuplicateSubscription(long roomId, long memberId) { + if (chattingRedisService.isActive(roomId, memberId)) { + throw new DuplicateSubscribeException(); + } + } + + private void reEnterEvent(long roomId, long senderId, String senderName, ReadMessageRange range) { + ChannelTopic topic = new ChannelTopic(chatTopic + roomId); + ChatMessage chatMessage = ChatMessage.of(roomId, senderId, senderName, range, MessageType.READ); + + redisChatPublisher.publish(topic, chatMessage); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/service/ChattingRedisService.java b/src/main/java/com/gachtaxi/domain/chat/service/ChattingRedisService.java new file mode 100644 index 00000000..5f0f59f4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/service/ChattingRedisService.java @@ -0,0 +1,58 @@ +package com.gachtaxi.domain.chat.service; + +import com.gachtaxi.domain.chat.exception.UnSubscriptionException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ChattingRedisService { + public static final String REDIS_CHAT_KEY_PREFIX = "ROOM:"; + + private final RedisTemplate chatRoomRedisTemplate; + + public void saveSubscribeMember(long roomId, long senderId, String profilePicture) { + String key = getKey(roomId); + + chatRoomRedisTemplate.opsForHash().put(key, String.valueOf(senderId), profilePicture); + } + + public boolean isActive(long roomId, long senderId) { + String key = getKey(roomId); + + return Boolean.TRUE.equals(chatRoomRedisTemplate.opsForHash().hasKey(key, String.valueOf(senderId))); + } + + public void removeSubscribeMember(long roomId, long senderId) { + String key = getKey(roomId); + + chatRoomRedisTemplate.opsForHash().delete(key, String.valueOf(senderId)); + } + + public void checkSubscriptionStatus(long roomId, long senderId) { + if (!isActive(roomId, senderId)) { + throw new UnSubscriptionException(); + } + } + + public long getSubscriberCount(long roomId) { + String key = getKey(roomId); + + return chatRoomRedisTemplate.opsForHash().size(key); + } + + public String getProfilePicture(long roomId, long senderId) { + String key = getKey(roomId); + + return Optional.ofNullable(chatRoomRedisTemplate.opsForHash().get(key, String.valueOf(senderId))) + .map(Object::toString) + .orElse(null); + } + + private String getKey(long roomId) { + return REDIS_CHAT_KEY_PREFIX + roomId; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/service/ChattingRoomService.java b/src/main/java/com/gachtaxi/domain/chat/service/ChattingRoomService.java new file mode 100644 index 00000000..7886f4b3 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/service/ChattingRoomService.java @@ -0,0 +1,117 @@ +package com.gachtaxi.domain.chat.service; + +import com.gachtaxi.domain.chat.dto.request.ChatMessage; +import com.gachtaxi.domain.chat.dto.response.ChattingRoomCountResponse; +import com.gachtaxi.domain.chat.dto.response.ChattingRoomResponse; +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.entity.enums.ChatStatus; +import com.gachtaxi.domain.chat.entity.enums.MessageType; +import com.gachtaxi.domain.chat.exception.ChattingRoomNotFoundException; +import com.gachtaxi.domain.chat.redis.RedisChatPublisher; +import com.gachtaxi.domain.chat.repository.ChattingMessageRepository; +import com.gachtaxi.domain.chat.repository.ChattingRoomRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.gachtaxi.domain.chat.stomp.strategy.StompConnectStrategy.CHAT_USER_ID; +import static com.gachtaxi.domain.chat.stomp.strategy.StompSubscribeStrategy.CHAT_ROOM_ID; +import static com.gachtaxi.domain.chat.stomp.strategy.StompSubscribeStrategy.CHAT_USER_NAME; + +@Service +@RequiredArgsConstructor +public class ChattingRoomService { + + private static final String ENTER_MESSAGE =" λ‹˜μ΄ μž…μž₯ν•˜μ…¨μŠ΅λ‹ˆλ‹€."; + private static final String EXIT_MESSAGE =" λ‹˜μ΄ 퇴μž₯ν•˜μ…¨μŠ΅λ‹ˆλ‹€."; + + private final ChattingRoomRepository chattingRoomRepository; + private final ChattingMessageRepository chattingMessageRepository; + private final ChattingParticipantService chattingParticipantService; + private final MemberService memberService; + private final RedisChatPublisher redisChatPublisher; + private final ChattingRedisService chattingRedisService; + + @Value("${chat.topic}") + public String chatTopic; + + @Transactional + public ChattingRoom create() { + ChattingRoom chattingRoom = ChattingRoom.builder().build(); + + return chattingRoomRepository.save(chattingRoom); + } + + @Transactional + public void delete(long chattingRoomId) { + ChattingRoom chattingRoom = find(chattingRoomId); + + chattingRoom.delete(); + } + + public ChattingRoomCountResponse getCount(Long memberId, Long roomId) { + chattingParticipantService.find(roomId, memberId); + Long count = chattingParticipantService.getParticipantCount(roomId); + + return ChattingRoomCountResponse.of(roomId, count); + } + + @Transactional + public void postSubscribeChatroom(SimpMessageHeaderAccessor accessor) { + Long roomId = (Long) accessor.getSessionAttributes().get(CHAT_ROOM_ID); + Long senderId = (Long) accessor.getSessionAttributes().get(CHAT_USER_ID); + + ChattingRoom chattingRoom = find(roomId); + Members members = memberService.findById(senderId); + + accessor.getSessionAttributes().put(CHAT_USER_NAME, members.getNickname()); + + if (chattingParticipantService.checkSubscription(chattingRoom, members)) { + chattingRedisService.saveSubscribeMember(chattingRoom.getId(), members.getId(), members.getProfilePicture()); + + return; + } + + chattingRedisService.saveSubscribeMember(chattingRoom.getId(), members.getId(), members.getProfilePicture()); + + ChattingParticipant newParticipant = ChattingParticipant.of(chattingRoom, members); + chattingParticipantService.save(newParticipant); + + publishMessage(roomId, senderId, members.getNickname(), ENTER_MESSAGE, MessageType.ENTER); + } + + @Transactional + public void exitChatRoom(long roomId, long senderId) { + ChattingRoom chattingRoom = find(roomId); + Members members = memberService.findById(senderId); + ChattingParticipant chattingParticipant = chattingParticipantService.find(chattingRoom, members); + + chattingParticipantService.delete(chattingParticipant); + + publishMessage(roomId, senderId, members.getNickname(), EXIT_MESSAGE, MessageType.EXIT); + } + + public ChattingRoom find(long chattingRoomId) { + return chattingRoomRepository.findById(chattingRoomId) + .filter(chattingRoom -> chattingRoom.getStatus() == ChatStatus.ACTIVE) + .orElseThrow(ChattingRoomNotFoundException::new); + } + + private void publishMessage(long roomId, long senderId, String senderName, String message, MessageType messageType) { + ChattingMessage chattingMessage = ChattingMessage.of(roomId, senderId, senderName, senderName + message, messageType); + + chattingMessageRepository.save(chattingMessage); + + ChannelTopic topic = new ChannelTopic(chatTopic + roomId); + ChatMessage chatMessage = ChatMessage.from(chattingMessage); + + redisChatPublisher.publish(topic, chatMessage); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/service/ChattingService.java b/src/main/java/com/gachtaxi/domain/chat/service/ChattingService.java new file mode 100644 index 00000000..6868df84 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/service/ChattingService.java @@ -0,0 +1,123 @@ +package com.gachtaxi.domain.chat.service; + +import com.gachtaxi.domain.chat.dto.request.ChatMessage; +import com.gachtaxi.domain.chat.dto.request.ChatMessageRequest; +import com.gachtaxi.domain.chat.dto.response.ChatPageableResponse; +import com.gachtaxi.domain.chat.dto.response.ChatResponse; +import com.gachtaxi.domain.chat.dto.response.ChattingMessageResponse; +import com.gachtaxi.domain.chat.entity.ChattingMessage; +import com.gachtaxi.domain.chat.entity.ChattingParticipant; +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.exception.WebSocketSessionException; +import com.gachtaxi.domain.chat.redis.RedisChatPublisher; +import com.gachtaxi.domain.chat.repository.ChattingMessageRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static com.gachtaxi.domain.chat.stomp.strategy.StompConnectStrategy.CHAT_USER_ID; +import static com.gachtaxi.domain.chat.stomp.strategy.StompSubscribeStrategy.CHAT_ROOM_ID; +import static com.gachtaxi.domain.chat.stomp.strategy.StompSubscribeStrategy.CHAT_USER_NAME; + +@Service +@RequiredArgsConstructor +public class ChattingService { + + private final ChattingMessageRepository chattingMessageRepository; + private final RedisChatPublisher redisChatPublisher; + private final ChattingRoomService chattingRoomService; + private final ChattingParticipantService chattingParticipantService; + private final MemberService memberService; + private final ChattingRedisService chattingRedisService; + + @Value("${chat.topic}") + public String chatTopic; + + @Transactional + public void chat(ChatMessageRequest request, SimpMessageHeaderAccessor accessor) { + long roomId = getSessionAttribute(accessor, CHAT_ROOM_ID, Long.class); + long userId = getSessionAttribute(accessor, CHAT_USER_ID, Long.class); + String senderName = getSessionAttribute(accessor, CHAT_USER_NAME, String.class); + + long unreadCount = getUnreadCount(roomId); + String profilePicture = chattingRedisService.getProfilePicture(roomId, userId); + + ChattingMessage chattingMessage = ChattingMessage.of(request, roomId, userId, senderName, unreadCount, profilePicture); + + chattingMessageRepository.save(chattingMessage); + + ChannelTopic topic = new ChannelTopic(chatTopic + roomId); + ChatMessage chatMessage = ChatMessage.from(chattingMessage); + + redisChatPublisher.publish(topic, chatMessage); + /* + todo μ±„νŒ…μ— μ•Œλ¦Όμ΄ λ„μž…λ˜λ©΄ redis에 μ°Έμ—¬ν•˜μ§€ μ•Šμ€ μ‚¬λžŒ 리슀트λ₯Ό κ°€μ Έμ™€μ„œ ν‘Έμ‹œμ•Œλ¦Ό 보내기. μ°Έμ—¬ν•˜κ³  μžˆλ‹€λ©΄ X + */ + } + + public ChatResponse getMessage(long roomId, long senderId, int pageNumber, int pageSize, LocalDateTime lastMessageTimeStamp) { + ChattingRoom chattingRoom = chattingRoomService.find(roomId); + Members members = memberService.findById(senderId); + ChattingParticipant chattingParticipant = chattingParticipantService.find(chattingRoom, members); + + chattingRedisService.checkSubscriptionStatus(roomId, senderId); + + Slice chattingMessages = loadMessage(roomId, chattingParticipant, pageNumber, pageSize, lastMessageTimeStamp); + + return getChatResponse(pageNumber, chattingMessages, chattingParticipant); + } + + private ChatResponse getChatResponse(int pageNumber, Slice chattingMessages, ChattingParticipant chattingParticipant) { + List chattingMessageResponses = chattingMessages.stream() + .map(ChattingMessageResponse::from) + .toList(); + + ChatPageableResponse chatPageableResponse = ChatPageableResponse.of(pageNumber, chattingMessages); + + return ChatResponse.of(chattingParticipant, chattingMessageResponses, chatPageableResponse); + } + + private Slice loadMessage(long roomId, ChattingParticipant chattingParticipant, int pageNumber, int pageSize, LocalDateTime lastMessageTimeStamp) { + if (pageNumber == 0) { + return loadInitialMessage(roomId, chattingParticipant, pageSize); + } + + Pageable pageable = PageRequest.of(pageNumber - 1, pageSize, Sort.by(Sort.Direction.DESC, "timeStamp")); + return chattingMessageRepository.findAllByRoomIdAndTimeStampAfterAndTimeStampBeforeOrderByTimeStampDesc(roomId, chattingParticipant.getJoinedAt(), lastMessageTimeStamp, pageable); + } + + private Slice loadInitialMessage(long roomId, ChattingParticipant chattingParticipant, int pageSize) { + int chattingCount = chattingMessageRepository.countAllByRoomIdAndTimeStampAfterOrderByTimeStampDesc(roomId, chattingParticipant.getDisconnectedAt()); + + int effectivePageSize = Math.max(chattingCount, pageSize); + Pageable pageable = PageRequest.of(0, effectivePageSize, Sort.by(Sort.Direction.DESC, "timeStamp")); + + return chattingMessageRepository.findAllByRoomIdAndTimeStampAfterOrderByTimeStampDesc(roomId, chattingParticipant.getJoinedAt(), pageable); + } + + private T getSessionAttribute(SimpMessageHeaderAccessor accessor, String attributeName, Class type) { + return Optional.ofNullable(accessor.getSessionAttributes()) + .map(attrs -> type.cast(attrs.get(attributeName))) + .orElseThrow(() -> new WebSocketSessionException(attributeName)); + } + + private long getUnreadCount(long roomId) { + long totalCount = chattingParticipantService.getParticipantCount(roomId); + long nowCount = chattingRedisService.getSubscriberCount(roomId); + + return totalCount - nowCount; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/CustomChannelInterceptor.java b/src/main/java/com/gachtaxi/domain/chat/stomp/CustomChannelInterceptor.java new file mode 100644 index 00000000..1c677513 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/CustomChannelInterceptor.java @@ -0,0 +1,29 @@ +package com.gachtaxi.domain.chat.stomp; + +import com.gachtaxi.domain.chat.stomp.strategy.ChatStrategyHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomChannelInterceptor implements ChannelInterceptor { + private final ChatStrategyHandler chatStrategyHandler; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + return chatStrategyHandler.handle(message, accessor, channel); + } + + @Override + public void postSend(Message message, MessageChannel channel, boolean sent) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + chatStrategyHandler.handle(message, accessor, channel, sent); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/ChatStrategyHandler.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/ChatStrategyHandler.java new file mode 100644 index 00000000..adb9ad8a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/ChatStrategyHandler.java @@ -0,0 +1,38 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ChatStrategyHandler { + + private final List stompCommandStrategies; + private final DefaultCommandStrategy defaultCommandStrategy; + + public Message handle(Message message, StompHeaderAccessor accessor, MessageChannel channel) { + StompCommand command = accessor.getCommand(); + + return stompCommandStrategies.stream() + .filter(strategy -> strategy.supports(command)) + .findFirst() + .orElse(defaultCommandStrategy) + .preSend(message, accessor, channel); + } + + public void handle(Message message, StompHeaderAccessor accessor, MessageChannel channel, boolean sent) { + StompCommand command = accessor.getCommand(); + + stompCommandStrategies.stream() + .filter(strategy -> strategy.supports(command)) + .findFirst() + .orElse(defaultCommandStrategy) + .postSend(message, accessor, channel, sent); + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/DefaultCommandStrategy.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/DefaultCommandStrategy.java new file mode 100644 index 00000000..5b9b2f72 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/DefaultCommandStrategy.java @@ -0,0 +1,23 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class DefaultCommandStrategy implements StompCommandStrategy{ + + @Override + public boolean supports(StompCommand command) { + return false; + } + + @Override + public Message preSend(Message message, StompHeaderAccessor accessor, MessageChannel channel) { + return message; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompCommandStrategy.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompCommandStrategy.java new file mode 100644 index 00000000..0f22f15c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompCommandStrategy.java @@ -0,0 +1,17 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; + +public interface StompCommandStrategy { + + boolean supports(StompCommand command); + + Message preSend(Message message, StompHeaderAccessor accessor, MessageChannel channel); + + default void postSend(Message message, StompHeaderAccessor accessor, MessageChannel channel, boolean sent) { + + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompConnectStrategy.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompConnectStrategy.java new file mode 100644 index 00000000..4543bb5c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompConnectStrategy.java @@ -0,0 +1,43 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + +import com.gachtaxi.global.auth.jwt.exception.TokenNotExistException; +import com.gachtaxi.global.auth.jwt.util.JwtExtractor; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +import static com.gachtaxi.global.auth.jwt.util.JwtProvider.ACCESS_TOKEN_SUBJECT; + +@Component +@RequiredArgsConstructor +public class StompConnectStrategy implements StompCommandStrategy{ + + private static final String TOKEN_PREFIX = "Bearer "; + public static final String CHAT_USER_ID = "CHAT_USER_ID"; + + private final JwtExtractor jwtExtractor; + + @Override + public boolean supports(StompCommand command) { + return StompCommand.CONNECT.equals(command); + } + + @Override + public Message preSend(Message message, StompHeaderAccessor accessor, MessageChannel channel) { + String jwtToken = accessor.getFirstNativeHeader(ACCESS_TOKEN_SUBJECT); + + if(jwtToken == null || !jwtToken.startsWith(TOKEN_PREFIX)) { + throw new TokenNotExistException(); + } + + String token = jwtToken.replace(TOKEN_PREFIX, "").trim(); + + Long userId = jwtExtractor.getId(token); + accessor.getSessionAttributes().put(CHAT_USER_ID, userId); + + return message; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSendStrategy.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSendStrategy.java new file mode 100644 index 00000000..dad9674d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSendStrategy.java @@ -0,0 +1,31 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + + +import com.gachtaxi.domain.chat.exception.ChatSendEndPointException; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Component +public class StompSendStrategy implements StompCommandStrategy { + + private static final String SEND_END_POINT = "/pub/chat/message"; + + @Override + public boolean supports(StompCommand command) { + return StompCommand.SEND.equals(command); + } + + @Override + public Message preSend(Message message, StompHeaderAccessor accessor, MessageChannel channel) { + String destination = accessor.getDestination(); + + if (!destination.startsWith(SEND_END_POINT)) { + throw new ChatSendEndPointException(); + } + + return message; + } +} diff --git a/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSubscribeStrategy.java b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSubscribeStrategy.java new file mode 100644 index 00000000..96545832 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/chat/stomp/strategy/StompSubscribeStrategy.java @@ -0,0 +1,56 @@ +package com.gachtaxi.domain.chat.stomp.strategy; + +import com.gachtaxi.domain.chat.exception.ChatSubscribeException; +import com.gachtaxi.domain.chat.service.ChattingRoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StompSubscribeStrategy implements StompCommandStrategy { + + public static final String CHAT_ROOM_ID = "CHAT_ROOM_ID"; + public static final String CHAT_USER_NAME = "CHAT_USER_NAME"; + private static final String SUB_END_POINT = "/sub/chat/room/"; + private static final String ERROR_END_POINT = "/user/queue/errors"; + private final ChattingRoomService chattingRoomService; + + @Value("${chat.topic}") + public String chatTopic; + + @Override + public boolean supports(StompCommand command) { + return StompCommand.SUBSCRIBE.equals(command); + } + + @Override + public Message preSend(Message message, StompHeaderAccessor accessor, MessageChannel channel) { + String destination = accessor.getDestination(); + + if (destination.startsWith(SUB_END_POINT)) { + Long roomId = Long.valueOf(destination.replace(SUB_END_POINT, "")); + accessor.getSessionAttributes().put(CHAT_ROOM_ID, roomId); + + return message; + } + + if (destination.startsWith(ERROR_END_POINT)) { + return message; + } + + throw new ChatSubscribeException(); + } + + @Override + public void postSend(Message message, StompHeaderAccessor accessor, MessageChannel channel, boolean sent) { + if (sent) { + chattingRoomService.postSubscribeChatroom(accessor); + } + } +} + diff --git a/src/main/java/com/gachtaxi/domain/friend/controller/FriendController.java b/src/main/java/com/gachtaxi/domain/friend/controller/FriendController.java new file mode 100644 index 00000000..f44c11b2 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/controller/FriendController.java @@ -0,0 +1,70 @@ +package com.gachtaxi.domain.friend.controller; + +import com.gachtaxi.domain.friend.dto.request.FriendRequestDto; +import com.gachtaxi.domain.friend.dto.request.FriendUpdateDto; +import com.gachtaxi.domain.friend.dto.response.FriendsSliceResponse; +import com.gachtaxi.domain.friend.service.FriendService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.gachtaxi.domain.friend.controller.ResponseMessage.*; +import static com.gachtaxi.domain.friend.entity.enums.FriendStatus.REJECTED; +import static org.springframework.http.HttpStatus.OK; + +@RestController +@RequestMapping("/api/friends") +@RequiredArgsConstructor +public class FriendController { + + private final FriendService friendService; + + @PostMapping + @Operation(summary = "친ꡬ μš”μ²­μ„ λ³΄λ‚΄λŠ” API λ°›λŠ” 이의 nickName을 μž…λ ₯ν•΄μ£Όμ„Έμš”") + public ApiResponse sendFriendRequest( + @CurrentMemberId Long senderId, + @RequestBody FriendRequestDto dto + ){ + friendService.sendFriendRequest(senderId, dto); + return ApiResponse.response(OK, FRIEND_REQUEST_SUCCESS.getMessage()); + } + + // λ‚˜μ˜ 친ꡬλ₯Ό λ°˜ν™˜ν•˜λŠ” API + @GetMapping + @Operation(summary = "친ꡬ λͺ©λ‘μ„ λ°˜ν™˜ν•˜λŠ” APIμž…λ‹ˆλ‹€. (λ¬΄ν•œμŠ€ν¬λ‘€)") + public ApiResponse getFriendsList( + @CurrentMemberId Long memberId, + @RequestParam int pageNum, + @RequestParam int pageSize + ){ + FriendsSliceResponse response = friendService.findFriendsListByMemberId(memberId, pageNum, pageSize); + return ApiResponse.response(OK, FRIEND_LIST_SUCCESS.getMessage(), response); + } + + @PatchMapping("") + @Operation(summary = "친ꡬ μš”μ²­μ„ 수락/κ±°μ ˆν•˜λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse updateFriendRequest( + @CurrentMemberId Long currentId, + @RequestBody FriendUpdateDto dto + ){ + friendService.updateFriendRequest(dto, currentId); // 친ꡬ μš”μ²­ 보낸 μ‚¬λžŒ(dto), 받은 μ‚¬λžŒ(토큰 μΆ”μΆœ) + if(dto.status() == REJECTED){ + return ApiResponse.response(OK, FRIEND_STATUS_REJECTED.getMessage()); + } + + return ApiResponse.response(OK, FRIEND_STATUS_ACCEPTED.getMessage()); + } + + @DeleteMapping("/{memberId}") + @Operation(summary = "친ꡬλ₯Ό μ‚­μ œν•˜λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse deleteFriend( + @CurrentMemberId Long currentId, + @PathVariable Long memberId + ) { + friendService.deleteFriend(currentId, memberId); + return ApiResponse.response(OK, FRIEND_DELETE.getMessage()); + } + +} diff --git a/src/main/java/com/gachtaxi/domain/friend/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/domain/friend/controller/ResponseMessage.java new file mode 100644 index 00000000..bc348559 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/controller/ResponseMessage.java @@ -0,0 +1,17 @@ +package com.gachtaxi.domain.friend.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + FRIEND_REQUEST_SUCCESS("친ꡬ μš”μ²­μ„ λ³΄λƒˆμŠ΅λ‹ˆλ‹€."), + FRIEND_STATUS_ACCEPTED("친ꡬ μš”μ²­μ„ μˆ˜λ½ν–ˆμŠ΅λ‹ˆλ‹€"), + FRIEND_STATUS_REJECTED("친ꡬ μš”μ²­μ„ κ±°μ ˆν–ˆμŠ΅λ‹ˆλ‹€"), + FRIEND_DELETE("친ꡬλ₯Ό μ‚­μ œν–ˆμŠ΅λ‹ˆλ‹€."), + FRIEND_LIST_SUCCESS("친ꡬ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€"); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendRequestDto.java b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendRequestDto.java new file mode 100644 index 00000000..9c0bd57b --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendRequestDto.java @@ -0,0 +1,8 @@ +package com.gachtaxi.domain.friend.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FriendRequestDto( + @NotNull String nickName +) { +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendStatusUpdateReqeustDto.java b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendStatusUpdateReqeustDto.java new file mode 100644 index 00000000..2c580a5d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendStatusUpdateReqeustDto.java @@ -0,0 +1,8 @@ +package com.gachtaxi.domain.friend.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FriendStatusUpdateReqeustDto( + @NotNull Long senderId +) { +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendUpdateDto.java b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendUpdateDto.java new file mode 100644 index 00000000..37a5d872 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/request/FriendUpdateDto.java @@ -0,0 +1,11 @@ +package com.gachtaxi.domain.friend.dto.request; + +import com.gachtaxi.domain.friend.entity.enums.FriendStatus; +import jakarta.validation.constraints.NotNull; + +public record FriendUpdateDto( + @NotNull Long memberId, + @NotNull String notificationId, + @NotNull FriendStatus status +) { +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsPageableResponse.java b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsPageableResponse.java new file mode 100644 index 00000000..ab6fec3c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsPageableResponse.java @@ -0,0 +1,22 @@ +package com.gachtaxi.domain.friend.dto.response; + +import com.gachtaxi.domain.friend.entity.Friends; +import lombok.Builder; +import org.springframework.data.domain.Slice; + +@Builder +public record FriendsPageableResponse( + int pageNumber, + int pageSize, + int numberOfElements, + boolean last +) { + public static FriendsPageableResponse from(Slice slice) { + return FriendsPageableResponse.builder() + .pageNumber(slice.getNumber()) + .pageSize(slice.getSize()) + .numberOfElements(slice.getNumberOfElements()) + .last(slice.isLast()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsResponseDto.java b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsResponseDto.java new file mode 100644 index 00000000..e1a1b552 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsResponseDto.java @@ -0,0 +1,30 @@ +package com.gachtaxi.domain.friend.dto.response; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.entity.enums.Gender; +import lombok.Builder; + +@Builder +public record FriendsResponseDto( + Long friendsId, + String friendsNickName, + String friendsProfileUrl, + Gender gender +) { + public static FriendsResponseDto from(Members friends) { + return FriendsResponseDto.builder() + .friendsId(friends.getId()) + .friendsNickName(friends.getNickname()) + .friendsProfileUrl(friends.getProfilePicture()) + .gender(friends.getGender()) + .build(); + } + + // Constructor for JPQL Result - DTO Mapping + public FriendsResponseDto(Long friendsId, String friendsNickName, String friendsProfileUrl, Gender gender) { + this.friendsId = friendsId; + this.friendsNickName = friendsNickName; + this.friendsProfileUrl = friendsProfileUrl; + this.gender = gender; + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsSliceResponse.java b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsSliceResponse.java new file mode 100644 index 00000000..8b07798c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/dto/response/FriendsSliceResponse.java @@ -0,0 +1,18 @@ +package com.gachtaxi.domain.friend.dto.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record FriendsSliceResponse( + List response, + FriendsPageableResponse pageable +) { + public static FriendsSliceResponse of(List response, FriendsPageableResponse pageable) { + return FriendsSliceResponse.builder() + .response(response) + .pageable(pageable) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/entity/Friends.java b/src/main/java/com/gachtaxi/domain/friend/entity/Friends.java new file mode 100644 index 00000000..cbcb2308 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/entity/Friends.java @@ -0,0 +1,48 @@ +package com.gachtaxi.domain.friend.entity; + +import com.gachtaxi.domain.friend.entity.enums.FriendStatus; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import static com.gachtaxi.domain.friend.entity.enums.FriendStatus.ACCEPTED; +import static com.gachtaxi.domain.friend.entity.enums.FriendStatus.PENDING; + + +@Entity +@Getter +@Table( + name = "Friends", + uniqueConstraints = @UniqueConstraint(columnNames = {"sender_id", "receiver_id"}) +)@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Friends extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private Members sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private Members receiver; + + @Builder.Default + @Enumerated(EnumType.STRING) + private FriendStatus status = PENDING; + + public static Friends of(Members sender, Members receiver) { + return Friends.builder() + .sender(sender) + .receiver(receiver) + .build(); + } + + public void updateStatus(){ + this.status = ACCEPTED; + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/entity/enums/FriendStatus.java b/src/main/java/com/gachtaxi/domain/friend/entity/enums/FriendStatus.java new file mode 100644 index 00000000..cc0bc7e7 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/entity/enums/FriendStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.friend.entity.enums; + +public enum FriendStatus { + PENDING, ACCEPTED, REJECTED +} diff --git a/src/main/java/com/gachtaxi/domain/friend/exception/ErrorMessage.java b/src/main/java/com/gachtaxi/domain/friend/exception/ErrorMessage.java new file mode 100644 index 00000000..d44a79eb --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/exception/ErrorMessage.java @@ -0,0 +1,16 @@ +package com.gachtaxi.domain.friend.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + FRIEND_DO_NOT_SEND_MYSELF("자기 μžμ‹ μ—κ²Œ 친ꡬ μš”μ²­μ„ 보낼 수 μ—†μ–΄μš”"), + FRIEND_NOT_EXISTS("잘λͺ»λœ 친ꡬ κ΄€κ³„μž…λ‹ˆλ‹€."), + FRIEND_EXISTS("이미 친ꡬ μž…λ‹ˆλ‹€."), + FRIEND_PENDING("친ꡬ μš”μ²­ λŒ€κΈ°μ€‘μž…λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/friend/exception/FriendNotExistsException.java b/src/main/java/com/gachtaxi/domain/friend/exception/FriendNotExistsException.java new file mode 100644 index 00000000..bb29becf --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/exception/FriendNotExistsException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.friend.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.friend.exception.ErrorMessage.FRIEND_NOT_EXISTS; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class FriendNotExistsException extends BaseException { + public FriendNotExistsException() { + super(BAD_REQUEST, FRIEND_NOT_EXISTS.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipDoesNotSendMySelfException.java b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipDoesNotSendMySelfException.java new file mode 100644 index 00000000..cdf50e63 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipDoesNotSendMySelfException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.friend.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.friend.exception.ErrorMessage.FRIEND_DO_NOT_SEND_MYSELF; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class FriendShipDoesNotSendMySelfException extends BaseException { + public FriendShipDoesNotSendMySelfException() { + super(BAD_REQUEST, FRIEND_DO_NOT_SEND_MYSELF.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipExistsException.java b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipExistsException.java new file mode 100644 index 00000000..38f91f71 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipExistsException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.friend.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.friend.exception.ErrorMessage.FRIEND_EXISTS; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class FriendShipExistsException extends BaseException { + public FriendShipExistsException() { + super(BAD_REQUEST, FRIEND_EXISTS.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipPendingException.java b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipPendingException.java new file mode 100644 index 00000000..99308658 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/exception/FriendShipPendingException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.friend.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.friend.exception.ErrorMessage.FRIEND_PENDING; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class FriendShipPendingException extends BaseException { + public FriendShipPendingException() { + super(BAD_REQUEST, FRIEND_PENDING.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/mapper/FriendsMapper.java b/src/main/java/com/gachtaxi/domain/friend/mapper/FriendsMapper.java new file mode 100644 index 00000000..3a4071d4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/mapper/FriendsMapper.java @@ -0,0 +1,15 @@ +package com.gachtaxi.domain.friend.mapper; + +import com.gachtaxi.domain.friend.dto.response.FriendsResponseDto; +import com.gachtaxi.domain.friend.entity.Friends; + +public class FriendsMapper { + + public static FriendsResponseDto toResponseDto(Friends friends, Long memberId) { + if(friends.getSender().getId().equals(memberId)) { + return FriendsResponseDto.from(friends.getReceiver()); + }else{ + return FriendsResponseDto.from(friends.getSender()); + } + } +} diff --git a/src/main/java/com/gachtaxi/domain/friend/repository/FriendRepository.java b/src/main/java/com/gachtaxi/domain/friend/repository/FriendRepository.java new file mode 100644 index 00000000..bd83f939 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/repository/FriendRepository.java @@ -0,0 +1,29 @@ +package com.gachtaxi.domain.friend.repository; + +import com.gachtaxi.domain.friend.entity.Friends; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface FriendRepository extends JpaRepository { + + @Query("SELECT f FROM Friends f " + + "JOIN FETCH f.sender s " + + "JOIN FETCH f.receiver r " + + "WHERE (s.id = :memberId OR r.id = :memberId) " + + "AND f.status = 'ACCEPTED'") + Slice findFriendsListByMemberId(@Param("memberId") Long memberId, Pageable pageable); + + @Query("SELECT f FROM Friends f WHERE" + + "(f.sender.id = :member1Id AND f.receiver.id = :member2Id) OR" + + "(f.sender.id = :member2Id AND f.receiver.id = :member1Id)") + Optional findFriendShip(Long member1Id, Long member2Id); + + Optional findBySenderIdAndReceiverId(Long senderId, Long receiverId); +} diff --git a/src/main/java/com/gachtaxi/domain/friend/service/FriendService.java b/src/main/java/com/gachtaxi/domain/friend/service/FriendService.java new file mode 100644 index 00000000..82c33caf --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/friend/service/FriendService.java @@ -0,0 +1,124 @@ +package com.gachtaxi.domain.friend.service; + +import com.gachtaxi.domain.friend.dto.request.FriendRequestDto; +import com.gachtaxi.domain.friend.dto.request.FriendUpdateDto; +import com.gachtaxi.domain.friend.dto.response.FriendsPageableResponse; +import com.gachtaxi.domain.friend.dto.response.FriendsResponseDto; +import com.gachtaxi.domain.friend.dto.response.FriendsSliceResponse; +import com.gachtaxi.domain.friend.entity.Friends; +import com.gachtaxi.domain.friend.entity.enums.FriendStatus; +import com.gachtaxi.domain.friend.exception.FriendNotExistsException; +import com.gachtaxi.domain.friend.exception.FriendShipDoesNotSendMySelfException; +import com.gachtaxi.domain.friend.exception.FriendShipExistsException; +import com.gachtaxi.domain.friend.exception.FriendShipPendingException; +import com.gachtaxi.domain.friend.mapper.FriendsMapper; +import com.gachtaxi.domain.friend.repository.FriendRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.service.MemberService; +import com.gachtaxi.domain.notification.entity.payload.FriendRequestPayload; +import com.gachtaxi.domain.notification.service.NotificationService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.gachtaxi.domain.friend.entity.enums.FriendStatus.REJECTED; +import static com.gachtaxi.domain.notification.entity.enums.NotificationType.FRIEND_REQUEST; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FriendService { + + private final FriendRepository friendRepository; + private final NotificationService notificationService; + private final MemberService memberService; + + public static final String FRIEND_REQUEST_CONTENT = "%s λ‹˜μ΄ 친ꡬ μš”μ²­μ„ λ³΄λƒˆμ–΄μš”."; + public static final String FRIEND_REQUEST_TITLE = "친ꡬ μš”μ²­"; + + @Transactional + public void sendFriendRequest(Long senderId, FriendRequestDto dto) { + Members sender = memberService.findById(senderId); + Members receiver = memberService.findByNickname(dto.nickName()); + + checkDuplicatedFriendShip(senderId, receiver.getId()); + friendRepository.save(Friends.of(sender, receiver)); + + notificationService.sendWithPush( + receiver, + FRIEND_REQUEST, + FRIEND_REQUEST_TITLE, + String.format(FRIEND_REQUEST_CONTENT, sender.getNickname()), + FriendRequestPayload.from(senderId)); + } + + public FriendsSliceResponse findFriendsListByMemberId(Long memberId, int pageNum, int pageSize) { + Pageable pageable = PageRequest.of(pageNum, pageSize); + + Slice friendsList = friendRepository.findFriendsListByMemberId(memberId, pageable); + + List friendsListDto = friendsList + .map(f -> FriendsMapper.toResponseDto(f, memberId)) + .toList(); + + FriendsPageableResponse pageableResponse = FriendsPageableResponse.from(friendsList); + + return FriendsSliceResponse.of(friendsListDto, pageableResponse); + } + + @Transactional + public void updateFriendRequest(FriendUpdateDto dto, Long receiverId) { + Friends friendShip = findBySenderIdAndReceiverId(dto.memberId(), receiverId); + notificationService.delete(receiverId, dto.notificationId()); + + if(dto.status() == REJECTED){ + friendRepository.delete(friendShip); + }else{ + friendShip.updateStatus(); + } + } + + @Transactional + public void deleteFriend(Long currentId, Long memberId) { + Friends friendShip = getFriendShip(currentId, memberId); + friendRepository.delete(friendShip); + } + + /* + * refactoring + * */ + + private void checkDuplicatedFriendShip(Long senderId, Long receiverId) { + if(senderId.equals(receiverId)) { // 자기 μžμ‹ ν•œν…Œ 친ꡬ μš”μ²­μ„ 보낼 경우 + throw new FriendShipDoesNotSendMySelfException(); + } + + friendRepository.findFriendShip(senderId, receiverId) + .ifPresent(f -> { + if(f.getStatus() == FriendStatus.ACCEPTED) { // 이미 친ꡬ 관계인 경우 + throw new FriendShipExistsException(); + } + if(f.getStatus() == FriendStatus.PENDING) { // 친ꡬ μš”μ²­ λŒ€κΈ°μ€‘μΈ 경우 + throw new FriendShipPendingException(); + } + }); + } + + // A와 B 쀑 λˆ„κ°€ sender이고 receiver인지 μ •ν™•νžˆ μ•„λŠ” 경우 (ex Notification에 μ €μž₯된 친ꡬ μš”μ²­) + public Friends findBySenderIdAndReceiverId(Long senderId, Long receiverId) { + return friendRepository.findBySenderIdAndReceiverId(senderId, receiverId) + .orElseThrow(FriendNotExistsException::new); + } + + // A와 B 쀑 λˆ„κ°€ sender이고 receiver인지 λͺ¨λ₯΄λŠ” 경우 (ex A와Bκ°€ 친ꡬ인 지 확인할 λ•Œ) + public Friends getFriendShip(Long senderId, Long receiverId) { + return friendRepository.findFriendShip(senderId, receiverId) + .orElseThrow(FriendNotExistsException::new); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/algorithm/dto/FindRoomResult.java b/src/main/java/com/gachtaxi/domain/matching/algorithm/dto/FindRoomResult.java new file mode 100644 index 00000000..b2d1a6da --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/algorithm/dto/FindRoomResult.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.algorithm.dto; + +import lombok.Builder; + +@Builder +public record FindRoomResult( + Long roomId, + Integer currentMembers, + Integer maxCapacity, + Long chattingRoomId +) { + +} diff --git a/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmService.java b/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmService.java new file mode 100644 index 00000000..3bfb9247 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmService.java @@ -0,0 +1,22 @@ +package com.gachtaxi.domain.matching.algorithm.service; + +import com.gachtaxi.domain.matching.algorithm.dto.FindRoomResult; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import java.util.List; +import java.util.Optional; + +public interface MatchingAlgorithmService { + + /** + * 방을 μ°ΎλŠ” λ©”μ„œλ“œ + * 이미 방에 λ“€μ–΄κ°€μžˆλŠ” 멀버가 λ‹€μ‹œ μš”μ²­ν–ˆμ„ λ•Œ Optional.empty()λ₯Ό λ°˜ν™˜ν•˜λ„λ‘ λ‘œμ§μ„ ꡬ성해야함 + * @param userId 방에 λ“€μ–΄κ°€λ €λŠ” μ‚¬μš©μž ID + * @param startLongitude μ‹œμž‘ 지점 경도 + * @param startLatitude μ‹œμž‘ 지점 μœ„λ„ + * @param destinationLongitude 도착 지점 경도 + * @param destinationLatitude 도착 지점 μœ„λ„ + * @param criteria λ°© 검색에 ν•„μš”ν•œ 기타 쑰건 (νƒœκ·Έ λ“±) + * @return Optional - 맀칭 κ°€λŠ₯ν•œ λ°© 정보가 있으면 값이 있고, μ—†μœΌλ©΄ empty + */ + Optional findRoom(Long userId, double startLongitude, double startLatitude, double destinationLongitude, double destinationLatitude, List criteria); +} diff --git a/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmServiceImpl.java b/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmServiceImpl.java new file mode 100644 index 00000000..884cdf09 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/algorithm/service/MatchingAlgorithmServiceImpl.java @@ -0,0 +1,84 @@ +package com.gachtaxi.domain.matching.algorithm.service; + +import com.gachtaxi.domain.matching.algorithm.dto.FindRoomResult; +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import com.gachtaxi.domain.matching.common.exception.AlreadyInMatchingRoomException; +import com.gachtaxi.domain.matching.common.repository.MatchingRoomRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.service.BlacklistService; +import com.gachtaxi.domain.members.service.MemberService; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MatchingAlgorithmServiceImpl implements MatchingAlgorithmService { + + private final MatchingRoomRepository matchingRoomRepository; + private final MemberService memberService; + private final BlacklistService blacklistService; + + @Value("${gachtaxi.matching.auto-matching-description}") + private String autoMatchingDescription; + + private static final double SEARCH_RADIUS = 300.0; + + @Override + public Optional findRoom(Long userId, double startLongitude, double startLatitude, + double destinationLongitude, double destinationLatitude, + List criteria) { + /* + μ‚¬μš©μž ID둜 μ‚¬μš©μž 정보 쑰회(이미 방에 μ°Έμ—¬ν•˜κ³  μžˆλŠ”μ§€ 쀑볡체크) + */ + Members user = memberService.findById(userId); + + matchingRoomRepository.findByMemberInMatchingRoom(user) + .forEach(room -> { + if (room.getDescription().equals(autoMatchingDescription)) { + throw new AlreadyInMatchingRoomException(room.getChattingRoomId()); + } + }); + + /* + μœ„μΉ˜ 정보λ₯Ό μ΄μš©ν•œ λ°© 검색(300M 이내)ΓΈ + */ + List matchingRooms = matchingRoomRepository.findRoomsByStartAndDestination( + startLongitude, + startLatitude, + destinationLongitude, + destinationLatitude, + SEARCH_RADIUS + ); + /* + ACTIVE μƒνƒœμΈ λ°© && λΈ”λž™λ¦¬μŠ€νŠΈκ°€ μ—†λŠ” 방만 필터링 + */ + matchingRooms = matchingRooms.stream() + .filter(MatchingRoom::isActive) + .filter(room -> !this.blacklistService.isBlacklistInMatchingRoom(user, room)) + .toList(); + + /* + νƒœκ·Έ 쑰건이 μžˆλŠ” κ²½μš°μ— νƒœκ·Έμ •λ³΄κΉŒμ§€ 필터링 + */ + if (criteria != null && !criteria.isEmpty()) { + matchingRooms = matchingRooms.stream() + .filter(room -> criteria.stream().anyMatch(room::containsTag)) + .toList(); + } + /* + 쑰건에 λ§žλŠ” 방이 있으면 첫 번째 방의 상세 정보 λ°˜ν™˜ + */ + if (!matchingRooms.isEmpty()) { + MatchingRoom room = matchingRooms.get(0); + return Optional.of(room.toFindRoomResult()); + } + /* + 쑰건에 λ§žλŠ” 방이 μ—†μœΌλ©΄ empty λ°˜ν™˜ + */ + return Optional.empty(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequired.java b/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequired.java new file mode 100644 index 00000000..a75b4a1f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequired.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.matching.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface SseSubscribeRequired { + +} diff --git a/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequiredAop.java b/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequiredAop.java new file mode 100644 index 00000000..fce23e7d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/aop/SseSubscribeRequiredAop.java @@ -0,0 +1,52 @@ +package com.gachtaxi.domain.matching.aop; + +import com.gachtaxi.domain.matching.common.controller.ResponseMessage; +import com.gachtaxi.domain.matching.common.exception.ControllerNotHasCurrentMemberIdException; +import com.gachtaxi.domain.matching.common.service.AutoMatchingService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import java.lang.reflect.Parameter; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class SseSubscribeRequiredAop { + + private final AutoMatchingService autoMatchingService; + + @Around("@annotation(com.gachtaxi.domain.matching.aop.SseSubscribeRequired)") + public Object checkSseSubscribe(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{ + Long memberId = null; + MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); + Parameter[] parameters = signature.getMethod().getParameters(); + + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + if (parameter.getType().equals(Long.class) && parameter.isAnnotationPresent( + CurrentMemberId.class)) { + memberId = (Long) proceedingJoinPoint.getArgs()[i]; + break; + } + } + + if (memberId == null) { + throw new ControllerNotHasCurrentMemberIdException(); + } + + if (!this.autoMatchingService.isSseSubscribed(memberId)) { + return ApiResponse.response( + HttpStatus.BAD_REQUEST, + ResponseMessage.NOT_SUBSCRIBED_SSE.getMessage() + ); + } + + return proceedingJoinPoint.proceed(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/controller/AutoMatchingController.java b/src/main/java/com/gachtaxi/domain/matching/common/controller/AutoMatchingController.java new file mode 100644 index 00000000..ec57f2fd --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/controller/AutoMatchingController.java @@ -0,0 +1,59 @@ +package com.gachtaxi.domain.matching.common.controller; + +import com.gachtaxi.domain.matching.aop.SseSubscribeRequired; +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingCancelledRequest; +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingPostRequest; +import com.gachtaxi.domain.matching.common.dto.response.AutoMatchingPostResponse; +import com.gachtaxi.domain.matching.common.service.AutoMatchingService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/matching/auto") +public class AutoMatchingController { + + private final AutoMatchingService autoMatchingService; + + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribeSse(@CurrentMemberId Long memberId) { + return this.autoMatchingService.handleSubscribe(memberId); + } + + @PostMapping("/request") + @SseSubscribeRequired + public ApiResponse requestMatching( + @CurrentMemberId Long memberId, + @RequestBody AutoMatchingPostRequest autoMatchingPostRequest + ) { + return ApiResponse.response( + HttpStatus.OK, + ResponseMessage.AUTO_MATCHING_REQUEST_ACCEPTED.getMessage(), + this.autoMatchingService.handlerAutoRequestMatching(memberId, autoMatchingPostRequest) + ); + } + + @PostMapping("/cancel") + @SseSubscribeRequired + public ApiResponse cancelMatching( + @CurrentMemberId Long memberId, + @RequestBody AutoMatchingCancelledRequest autoMatchingCancelledRequest + ) { + return ApiResponse.response( + HttpStatus.OK, + ResponseMessage.AUTO_MATCHING_REQUEST_CANCELLED.getMessage(), + this.autoMatchingService.handlerAutoCancelMatching(memberId, autoMatchingCancelledRequest) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/domain/matching/common/controller/ManualMatchingController.java b/src/main/java/com/gachtaxi/domain/matching/common/controller/ManualMatchingController.java new file mode 100644 index 00000000..e85ba87e --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/controller/ManualMatchingController.java @@ -0,0 +1,83 @@ +package com.gachtaxi.domain.matching.common.controller; + +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.ACCEPT_MATCHING_INVITE_SUCCESS; +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.CREATE_MANUAL_MATCHING_ROOM_SUCCESS; +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.GET_MANUAL_MATCHING_LIST_SUCCESS; +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.GET_MY_MATCHING_LIST_SUCCESS; +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.JOIN_MANUAL_MATCHING_ROOM_SUCCESS; +import static com.gachtaxi.domain.matching.common.controller.ResponseMessage.LEAVE_MANUAL_MATCHING_ROOM_SUCCESS; +import static org.springframework.http.HttpStatus.OK; + +import com.gachtaxi.domain.matching.common.dto.request.ManualMatchingInviteReplyRequest; +import com.gachtaxi.domain.matching.common.dto.request.ManualMatchingJoinRequest; +import com.gachtaxi.domain.matching.common.dto.request.ManualMatchingRequest; +import com.gachtaxi.domain.matching.common.dto.response.MatchingRoomListResponse; +import com.gachtaxi.domain.matching.common.dto.response.MatchingRoomResponse; +import com.gachtaxi.domain.matching.common.service.ManualMatchingService; +import com.gachtaxi.domain.matching.common.service.MatchingInvitationService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "MANUAL", description = "μˆ˜λ™λ§€μΉ­ API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/matching/manual") +public class ManualMatchingController { + + private final ManualMatchingService manualMatchingService; + private final MatchingInvitationService matchingInvitationService; + + @Operation(summary = "μˆ˜λ™ 맀칭방 생성") + @PostMapping("/creation") + public ApiResponse createManualMatchingRoom(@CurrentMemberId Long userId, @Valid @RequestBody ManualMatchingRequest request) { + Long roomId = manualMatchingService.createManualMatchingRoom(userId, request); + return ApiResponse.response(OK, CREATE_MANUAL_MATCHING_ROOM_SUCCESS.getMessage(), roomId); + } + + @Operation(summary = "μˆ˜λ™ 맀칭방 μ°Έμ—¬") + @PostMapping("/join") + public ApiResponse joinManualMatchingRoom(@CurrentMemberId Long userId, @Valid @RequestBody ManualMatchingJoinRequest request) { + manualMatchingService.joinManualMatchingRoom(userId, request.roomId()); + return ApiResponse.response(OK, JOIN_MANUAL_MATCHING_ROOM_SUCCESS.getMessage()); + } + + @Operation(summary = "μˆ˜λ™ 맀칭 μ΄ˆλŒ€ 수락/거절") + @PostMapping("/invite/reply") + public ApiResponse acceptInvitation(@CurrentMemberId Long userId, @Valid @RequestBody ManualMatchingInviteReplyRequest request) { + matchingInvitationService.acceptInvitation(userId, request); + return ApiResponse.response(OK, ACCEPT_MATCHING_INVITE_SUCCESS.getMessage()); + } + + @Operation(summary = "μˆ˜λ™ 맀칭방 퇴μž₯ (λ°© μ‚­μ œ 포함)") + @PatchMapping("/exit/{roomId}") + public ApiResponse leaveManualMatchingRoom(@CurrentMemberId Long userId, @PathVariable Long roomId) { + manualMatchingService.leaveManualMatchingRoom(userId, roomId); + return ApiResponse.response(OK, LEAVE_MANUAL_MATCHING_ROOM_SUCCESS.getMessage()); + } + + @Operation(summary = "μˆ˜λ™ 맀칭방 쑰회") + @GetMapping("/list") + public ApiResponse getManualMatchingList(int pageNumber, int pageSize) { + Slice rooms = manualMatchingService.getManualMatchingList(pageNumber, pageSize); + return ApiResponse.response(OK, GET_MANUAL_MATCHING_LIST_SUCCESS.getMessage(), MatchingRoomListResponse.of(rooms)); + } + + @Operation(summary = "λ‚˜μ˜ 맀칭(μˆ˜λ™) 쑰회") + @GetMapping("/my-list") + public ApiResponse getMyMatchingList(@CurrentMemberId Long userId, int pageNumber, int pageSize) { + Slice rooms = manualMatchingService.getMyMatchingList(userId, pageNumber, pageSize); + return ApiResponse.response(OK, GET_MY_MATCHING_LIST_SUCCESS.getMessage(), MatchingRoomListResponse.of(rooms)); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/domain/matching/common/controller/ResponseMessage.java new file mode 100644 index 00000000..ffad9479 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/controller/ResponseMessage.java @@ -0,0 +1,28 @@ +package com.gachtaxi.domain.matching.common.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ResponseMessage { + + // sse + SUBSCRIBE_SUCCESS("SSE ꡬ독에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + + // auto matching + AUTO_MATCHING_REQUEST_ACCEPTED("μžλ™ 맀칭 μš”μ²­ 전솑에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + NOT_SUBSCRIBED_SSE("SSE ꡬ독 ν›„ μžλ™ 맀칭을 μš”μ²­ν•  수 μžˆμŠ΅λ‹ˆλ‹€."), + AUTO_MATCHING_REQUEST_CANCELLED("μžλ™ 맀칭 μ·¨μ†Œ μš”μ²­ 전솑에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + + // manual matching + CREATE_MANUAL_MATCHING_ROOM_SUCCESS("μˆ˜λ™ 맀칭방 생성에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + JOIN_MANUAL_MATCHING_ROOM_SUCCESS("μˆ˜λ™ 맀칭방 참여에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + LEAVE_MANUAL_MATCHING_ROOM_SUCCESS("맀칭방 퇴μž₯이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + CONVERT_TO_AUTO_MATCHING_SUCCESS("μžλ™ 맀칭으둜 μ „ν™˜λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + GET_MANUAL_MATCHING_LIST_SUCCESS("μˆ˜λ™ 맀칭방 μ‘°νšŒμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + GET_MY_MATCHING_LIST_SUCCESS("λ‚΄ 맀칭방 μ‘°νšŒμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + ACCEPT_MATCHING_INVITE_SUCCESS("맀칭방 μ΄ˆλŒ€λ₯Ό 수락/거절이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€"); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/enums/AutoMatchingStatus.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/enums/AutoMatchingStatus.java new file mode 100644 index 00000000..03401cad --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/enums/AutoMatchingStatus.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.dto.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AutoMatchingStatus { + REQUESTED("REQUESTED"), + REJECTED("REJECTED"), + CANCELLED("CANCELLED"); + private final String value; +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingCancelledRequest.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingCancelledRequest.java new file mode 100644 index 00000000..bd0c66fe --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingCancelledRequest.java @@ -0,0 +1,7 @@ +package com.gachtaxi.domain.matching.common.dto.request; + +public record AutoMatchingCancelledRequest( + Long roomId +) { + +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingPostRequest.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingPostRequest.java new file mode 100644 index 00000000..a678d750 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/AutoMatchingPostRequest.java @@ -0,0 +1,21 @@ +package com.gachtaxi.domain.matching.common.dto.request; + +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import java.util.List; + +public record AutoMatchingPostRequest( + String startPoint, + String startName, + String destinationPoint, + String destinationName, + List criteria, + List members, + Integer expectedTotalCharge +) { + + public List getCriteria() { + return this.criteria.stream() + .map(Tags::valueOf) + .toList(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingInviteReplyRequest.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingInviteReplyRequest.java new file mode 100644 index 00000000..09581de5 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingInviteReplyRequest.java @@ -0,0 +1,14 @@ +package com.gachtaxi.domain.matching.common.dto.request; + +import com.gachtaxi.domain.matching.common.entity.enums.MatchingInviteStatus; +import jakarta.validation.constraints.NotNull; + +public record ManualMatchingInviteReplyRequest( + @NotNull + Long matchingRoomId, + @NotNull + String notificationId, + @NotNull + MatchingInviteStatus status + ) { +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingJoinRequest.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingJoinRequest.java new file mode 100644 index 00000000..eee8d24e --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingJoinRequest.java @@ -0,0 +1,9 @@ +package com.gachtaxi.domain.matching.common.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ManualMatchingJoinRequest( + @NotNull + Long roomId +) { +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingRequest.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingRequest.java new file mode 100644 index 00000000..31508d6c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/request/ManualMatchingRequest.java @@ -0,0 +1,55 @@ +package com.gachtaxi.domain.matching.common.dto.request; + +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; + +public record ManualMatchingRequest( + + String description, + + @NotBlank + String startName, + + @NotBlank + String destinationName, + + @NotNull + String departureTime, + + @Schema(description = "μ˜ˆμƒ μš”κΈˆ") + @Min(value = 0) + int expectedTotalCharge, + + @Schema(description = "맀칭 νƒœκ·Έ") + List criteria, + + @Schema(description = "μ΄ˆλŒ€ν•  친ꡬ λ‹‰λ„€μž„ 리슀트") + List members +) { + public List getCriteria() { + return this.criteria.stream() + .map(Tags::valueOf) + .toList(); + } + + public List getFriendNicknames() { + return members; + } + + public int getTotalCharge() { + return expectedTotalCharge; + } + + public String getDeparture() { + return startName; + } + + public String getDestination() { + return destinationName; + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/response/AutoMatchingPostResponse.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/AutoMatchingPostResponse.java new file mode 100644 index 00000000..f35d99b8 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/AutoMatchingPostResponse.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.dto.response; + +import com.gachtaxi.domain.matching.common.dto.enums.AutoMatchingStatus; + +public record AutoMatchingPostResponse( + String autoMatchingStatus + +) { + + public static AutoMatchingPostResponse of(AutoMatchingStatus autoMatchingStatus) { + return new AutoMatchingPostResponse(autoMatchingStatus.getValue()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingPageableResponse.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingPageableResponse.java new file mode 100644 index 00000000..0a9f6e26 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingPageableResponse.java @@ -0,0 +1,22 @@ +package com.gachtaxi.domain.matching.common.dto.response; + +import lombok.Builder; +import org.springframework.data.domain.Slice; + + +@Builder +public record MatchingPageableResponse( + int pageNumber, + int pageSize, + int numberOfElements, + boolean last +) { + public static MatchingPageableResponse of(Slice slice) { + return MatchingPageableResponse.builder() + .pageNumber(slice.getNumber()) + .pageSize(slice.getSize()) + .numberOfElements(slice.getNumberOfElements()) + .last(slice.isLast()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomListResponse.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomListResponse.java new file mode 100644 index 00000000..1af3fa45 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomListResponse.java @@ -0,0 +1,18 @@ +package com.gachtaxi.domain.matching.common.dto.response; + +import java.util.List; +import lombok.Builder; +import org.springframework.data.domain.Slice; + +@Builder +public record MatchingRoomListResponse( + List rooms, + MatchingPageableResponse pageable +) { + public static MatchingRoomListResponse of(Slice slice) { + return MatchingRoomListResponse.builder() + .rooms(slice.getContent().stream().toList()) + .pageable(MatchingPageableResponse.of(slice)) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomResponse.java b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomResponse.java new file mode 100644 index 00000000..f6f02547 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/dto/response/MatchingRoomResponse.java @@ -0,0 +1,31 @@ +package com.gachtaxi.domain.matching.common.dto.response; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import java.time.LocalDateTime; +import java.util.List; + +public record MatchingRoomResponse( + Long roomId, + Long chattingRoomId, + String description, + String departure, + String destination, + LocalDateTime departureTime, + int maxCapacity, + int currentMembers, + List tags +) { + public static MatchingRoomResponse from(MatchingRoom matchingRoom) { + return new MatchingRoomResponse( + matchingRoom.getId(), + matchingRoom.getChattingRoomId(), + matchingRoom.getDescription(), + matchingRoom.getDeparture(), + matchingRoom.getDestination(), + matchingRoom.getDepartureTime(), + matchingRoom.getCapacity(), + matchingRoom.getCurrentMemberCount(), + matchingRoom.getTags() + ); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoom.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoom.java new file mode 100644 index 00000000..6a086794 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoom.java @@ -0,0 +1,166 @@ +package com.gachtaxi.domain.matching.common.entity; + +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.matching.algorithm.dto.FindRoomResult; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomStatus; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomType; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCreatedEvent; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "matching_room") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MatchingRoom extends BaseEntity { + + @OneToMany(mappedBy = "matchingRoom") + private List matchingRoomTagInfo; + + @Column(name = "capacity", nullable = false, columnDefinition = "INT CHECK (capacity BETWEEN 1 AND 4)") + @Getter + private Integer capacity; + + // νŒ€μ›λ“€ 정보 + @OneToMany(mappedBy = "matchingRoom", fetch = FetchType.LAZY) + @Getter + private List memberMatchingRoomChargingInfo; + + @ManyToOne(cascade = CascadeType.PERSIST, optional = false) + @Getter + @Setter + private Members roomMaster; + + @Column(name = "title") + @Getter + private String title; + + @Column(name = "description", nullable = false) + @Getter + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + private Route route; + + @Column(name = "total_charge") + @Getter + private Integer totalCharge; + + @Column(name = "departure_time") + @Getter + private LocalDateTime departureTime; + + @Column(name = "departure") + @Getter + private String departure; + + @Column(name = "destination") + @Getter + private String destination; + + @Column(name = "chatting_room_id") + @Getter + private Long chattingRoomId; + + @Enumerated(EnumType.STRING) + private MatchingRoomStatus matchingRoomStatus; + + @Enumerated(EnumType.STRING) + private MatchingRoomType matchingRoomType; + + public boolean isActive() { + return this.matchingRoomStatus == MatchingRoomStatus.ACTIVE; + } + + public void changeRoomMaster(Members members) { + this.setRoomMaster(members); + } + + public void cancelMatchingRoom() { + this.matchingRoomStatus = MatchingRoomStatus.CANCELLED; + } + + public void completeMatchingRoom() { + this.matchingRoomStatus = MatchingRoomStatus.COMPLETE; + } + + public boolean isFull(int size) { + return size == totalCharge; + } + + public void convertToAutoMatching() { this.matchingRoomType = MatchingRoomType.AUTO; } + + public boolean isAutoConvertible(int currentMembers) { return currentMembers < this.capacity; } + + public static MatchingRoom activeOf(MatchRoomCreatedEvent matchRoomCreatedEvent, Members members, Route route, ChattingRoom chattingRoom) { + return MatchingRoom.builder() + .capacity(matchRoomCreatedEvent.maxCapacity()) + .roomMaster(members) + .title(matchRoomCreatedEvent.title()) + .description(matchRoomCreatedEvent.description()) + .route(route) + .totalCharge(matchRoomCreatedEvent.expectedTotalCharge()) + .matchingRoomStatus(MatchingRoomStatus.ACTIVE) + .chattingRoomId(chattingRoom.getId()) + .build(); + } + + public static MatchingRoom manualOf(Members roomMaster, String departure, String destination, String description, int maxCapacity, int totalCharge, LocalDateTime departureTime, Long chattingRoomId) { + return MatchingRoom.builder() + .capacity(4) + .roomMaster(roomMaster) + .description(description) + .departure(departure) + .destination(destination) + .totalCharge(totalCharge) + .departureTime(departureTime) + .chattingRoomId(chattingRoomId) + .matchingRoomType(MatchingRoomType.MANUAL) + .matchingRoomStatus(MatchingRoomStatus.ACTIVE) + .build(); + } + + public boolean containsTag(Tags tag) { + return this.matchingRoomTagInfo.stream() + .anyMatch(tagInfo -> tagInfo.matchesTag(tag)); + } + + public FindRoomResult toFindRoomResult() { + return FindRoomResult.builder() + .roomId(this.getId()) + .maxCapacity(this.getCapacity()) + .chattingRoomId(this.chattingRoomId) + .build(); + } + public int getCurrentMemberCount() { + return (int) memberMatchingRoomChargingInfo.stream() + .filter(info -> !info.isAlreadyLeft()) + .count(); + } + + public List getTags() { + return this.matchingRoomTagInfo.stream() + .map(tagInfo -> tagInfo.getTags().name()) + .toList(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoomTagInfo.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoomTagInfo.java new file mode 100644 index 00000000..e0abd5d7 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/MatchingRoomTagInfo.java @@ -0,0 +1,40 @@ +package com.gachtaxi.domain.matching.common.entity; + +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "matching_room_tag_info") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class MatchingRoomTagInfo extends BaseEntity { + + @ManyToOne + private MatchingRoom matchingRoom; + + @Enumerated(EnumType.STRING) + @Getter + private Tags tags; + + public static MatchingRoomTagInfo of(MatchingRoom matchingRoom, Tags tag) { + return MatchingRoomTagInfo.builder() + .matchingRoom(matchingRoom) + .tags(tag) + .build(); + } + + public boolean matchesTag(Tags tag) { + return this.tags == tag; + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/MemberMatchingRoomChargingInfo.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/MemberMatchingRoomChargingInfo.java new file mode 100644 index 00000000..ec1a7e59 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/MemberMatchingRoomChargingInfo.java @@ -0,0 +1,68 @@ +package com.gachtaxi.domain.matching.common.entity; + +import com.gachtaxi.domain.matching.common.entity.enums.PaymentStatus; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "member_matching_room_charging_info", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"members_id", "matching_room_id"}) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class MemberMatchingRoomChargingInfo extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @Getter + private Members members; + + @ManyToOne(fetch = FetchType.LAZY) + private MatchingRoom matchingRoom; + + @Column(name = "charge") + @Setter + private Integer charge; + + @Enumerated(EnumType.STRING) + private PaymentStatus paymentStatus; + + public void leftMatchingRoom() { + this.paymentStatus = PaymentStatus.LEFT; + } + + public boolean isAlreadyLeft() { + return this.paymentStatus == PaymentStatus.LEFT; + } + + public MemberMatchingRoomChargingInfo joinMatchingRoom() { + this.paymentStatus = PaymentStatus.NOT_PAYED; + return this; + } + + public static MemberMatchingRoomChargingInfo notPayedOf(MatchingRoom matchingRoom, Members members) { + return MemberMatchingRoomChargingInfo.builder() + .matchingRoom(matchingRoom) + .members(members) + .charge(matchingRoom.getTotalCharge()) + .paymentStatus(PaymentStatus.NOT_PAYED) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/Route.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/Route.java new file mode 100644 index 00000000..1a942627 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/Route.java @@ -0,0 +1,25 @@ +package com.gachtaxi.domain.matching.common.entity; + +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "route") +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Route extends BaseEntity { + + private double startLongitude; + private double startLatitude; + private String startLocationName; + + private double endLongitude; + private double endLatitude; + private String endLocationName; +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingInviteStatus.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingInviteStatus.java new file mode 100644 index 00000000..38088735 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingInviteStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.matching.common.entity.enums; + +public enum MatchingInviteStatus { + ACCEPT, REJECT +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomStatus.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomStatus.java new file mode 100644 index 00000000..601abd7b --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.matching.common.entity.enums; + +public enum MatchingRoomStatus { + COMPLETE, CANCELLED, ACTIVE +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomType.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomType.java new file mode 100644 index 00000000..f08a588a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/MatchingRoomType.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.matching.common.entity.enums; + +public enum MatchingRoomType { + AUTO, MANUAL +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/PaymentStatus.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/PaymentStatus.java new file mode 100644 index 00000000..bc0c4be4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/PaymentStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.matching.common.entity.enums; + +public enum PaymentStatus { + PAYED, NOT_PAYED, FAILED, LEFT +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/Tags.java b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/Tags.java new file mode 100644 index 00000000..93812d5d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/entity/enums/Tags.java @@ -0,0 +1,6 @@ +package com.gachtaxi.domain.matching.common.entity.enums; + +public enum Tags { + NO_SMOKE, + SAME_GENDER +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/AlreadyInMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/AlreadyInMatchingRoomException.java new file mode 100644 index 00000000..6d048baa --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/AlreadyInMatchingRoomException.java @@ -0,0 +1,18 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.ALREADY_IN_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.CONFLICT; + +import com.gachtaxi.global.common.exception.BaseException; +import java.util.Map; + +public class AlreadyInMatchingRoomException extends BaseException { + public AlreadyInMatchingRoomException(Long chattingRoomId) { + super(CONFLICT, Map.of("chattingRoomId", chattingRoomId, "message", ALREADY_IN_MATCHING_ROOM.getMessage()).toString()); + } + + public AlreadyInMatchingRoomException() { + super(CONFLICT, ALREADY_IN_MATCHING_ROOM.getMessage()); + } + +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/ControllerNotHasCurrentMemberIdException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/ControllerNotHasCurrentMemberIdException.java new file mode 100644 index 00000000..3e24768f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/ControllerNotHasCurrentMemberIdException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.CONTROLLER_NOT_HAS_CURRENT_MEMBER_ID; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import com.gachtaxi.global.common.exception.BaseException; + +public class ControllerNotHasCurrentMemberIdException extends BaseException { + + public ControllerNotHasCurrentMemberIdException() { + super(INTERNAL_SERVER_ERROR, CONTROLLER_NOT_HAS_CURRENT_MEMBER_ID.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/DuplicatedMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/DuplicatedMatchingRoomException.java new file mode 100644 index 00000000..3d13f73f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/DuplicatedMatchingRoomException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.DUPLICATED_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class DuplicatedMatchingRoomException extends BaseException { + public DuplicatedMatchingRoomException() { + super(BAD_REQUEST, DUPLICATED_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/ErrorMessage.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/ErrorMessage.java new file mode 100644 index 00000000..5fab8bbf --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/ErrorMessage.java @@ -0,0 +1,26 @@ +package com.gachtaxi.domain.matching.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ErrorMessage { + + NO_SUCH_MATCHING_ROOM("ν•΄λ‹Ή 맀칭 방이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + NOT_ACTIVE_MATCHING_ROOM("μ—΄λ¦° 맀칭 방이 μ•„λ‹™λ‹ˆλ‹€."), + MEMBER_NOT_IN_MATCHING_ROOM("ν•΄λ‹Ή 맀칭 방에 μ°Έκ°€ν•œ 멀버가 μ•„λ‹™λ‹ˆλ‹€."), + MEMBER_ALREADY_JOINED_MATCHING_ROOM("ν•΄λ‹Ή λ§΄λ²„λŠ” 이미 맀칭 방에 μ°Έκ°€ν•œ λ©€λ²„μž…λ‹ˆλ‹€"), + MEMBER_ALREADY_LEFT_MATCHING_ROOM("ν•΄λ‹Ή λ©€λ²„λŠ” 이미 맀칭 λ°©μ—μ„œ λ‚˜κ°„ λ©€λ²„μž…λ‹ˆλ‹€."), + CONTROLLER_NOT_HAS_CURRENT_MEMBER_ID("ν•΄λ‹Ή μ»¨νŠΈλ‘€λŸ¬λŠ” μΈκ°€λœ 멀버 IDκ°€ ν•„μš”ν•©λ‹ˆλ‹€."), + NOT_DEFINED_KAFKA_TEMPLATE("ν•΄λ‹Ή μ΄λ²€νŠΈμ™€ λ§žλŠ” KafkaTemplate이 μ •μ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), + DUPLICATED_MATCHING_ROOM("이미 μ‘΄μž¬ν•˜λŠ” 맀칭 λ°©μž…λ‹ˆλ‹€."), + NOT_FOUND_PAGE("νŽ˜μ΄μ§€ λ²ˆν˜ΈλŠ” 0 이상이어야 ν•©λ‹ˆλ‹€."), + ALREADY_IN_MATCHING_ROOM("이미 맀칭 방에 μ°Έκ°€ν•œ λ©€λ²„μž…λ‹ˆλ‹€."), + MATCHING_ROOM_NOT_JOIN_OWN("μžμ‹ μ΄ λ§Œλ“  맀칭 λ°©μ—λŠ” μ°Έκ°€ν•  수 μ—†μŠ΅λ‹ˆλ‹€."), + NOT_EQUAL_START_DESTINATION("μΆœλ°œμ§€μ™€ λ„μ°©μ§€λŠ” 같을 수 μ—†μŠ΅λ‹ˆλ‹€."), + NO_SUCH_INVITATION("ν•΄λ‹Ή μˆ˜λ™λ§€μΉ­ μ΄ˆλŒ€κ°€ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + MATCHING_ALREADY_ROOM_FULL("맀칭 방이 이미 꽉 μ°ΌμŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/MatchingRoomAlreadyFullException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/MatchingRoomAlreadyFullException.java new file mode 100644 index 00000000..ffa569b6 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/MatchingRoomAlreadyFullException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.MATCHING_ALREADY_ROOM_FULL; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class MatchingRoomAlreadyFullException extends BaseException { + + public MatchingRoomAlreadyFullException() { + super(BAD_REQUEST, MATCHING_ALREADY_ROOM_FULL.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyJoinedException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyJoinedException.java new file mode 100644 index 00000000..ad935d43 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyJoinedException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.MEMBER_ALREADY_JOINED_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.CONFLICT; + +import com.gachtaxi.global.common.exception.BaseException; + +public class MemberAlreadyJoinedException extends BaseException { + + public MemberAlreadyJoinedException() { + super(CONFLICT, MEMBER_ALREADY_JOINED_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyLeftMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyLeftMatchingRoomException.java new file mode 100644 index 00000000..344789ea --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberAlreadyLeftMatchingRoomException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.MEMBER_ALREADY_LEFT_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.CONFLICT; + +import com.gachtaxi.global.common.exception.BaseException; + +public class MemberAlreadyLeftMatchingRoomException extends BaseException { + + public MemberAlreadyLeftMatchingRoomException() { + super(CONFLICT, MEMBER_ALREADY_LEFT_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberNotInMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberNotInMatchingRoomException.java new file mode 100644 index 00000000..457aac99 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/MemberNotInMatchingRoomException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class MemberNotInMatchingRoomException extends BaseException { + + public MemberNotInMatchingRoomException() { + super(BAD_REQUEST, MEMBER_NOT_IN_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchInvitationException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchInvitationException.java new file mode 100644 index 00000000..797e8fac --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchInvitationException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.NO_SUCH_INVITATION; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import com.gachtaxi.global.common.exception.BaseException; + +public class NoSuchInvitationException extends BaseException { + public NoSuchInvitationException() { + super(NOT_FOUND, NO_SUCH_INVITATION.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchMatchingRoomException.java new file mode 100644 index 00000000..931e7fb6 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/NoSuchMatchingRoomException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.NO_SUCH_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import com.gachtaxi.global.common.exception.BaseException; + +public class NoSuchMatchingRoomException extends BaseException { + + public NoSuchMatchingRoomException() { + super(NOT_FOUND, NO_SUCH_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/NotActiveMatchingRoomException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotActiveMatchingRoomException.java new file mode 100644 index 00000000..58197e5a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotActiveMatchingRoomException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.NOT_ACTIVE_MATCHING_ROOM; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class NotActiveMatchingRoomException extends BaseException { + + public NotActiveMatchingRoomException() { + super(BAD_REQUEST, NOT_ACTIVE_MATCHING_ROOM.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/NotDefinedKafkaTemplateException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotDefinedKafkaTemplateException.java new file mode 100644 index 00000000..2bb75035 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotDefinedKafkaTemplateException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.NOT_DEFINED_KAFKA_TEMPLATE; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import com.gachtaxi.global.common.exception.BaseException; + +public class NotDefinedKafkaTemplateException extends BaseException { + + public NotDefinedKafkaTemplateException() { + super(INTERNAL_SERVER_ERROR, NOT_DEFINED_KAFKA_TEMPLATE.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/NotEqualStartAndDestinationException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotEqualStartAndDestinationException.java new file mode 100644 index 00000000..096c7385 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/NotEqualStartAndDestinationException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.NOT_EQUAL_START_DESTINATION; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class NotEqualStartAndDestinationException extends BaseException { + public NotEqualStartAndDestinationException() { + super(BAD_REQUEST, NOT_EQUAL_START_DESTINATION.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/PageNotFoundException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/PageNotFoundException.java new file mode 100644 index 00000000..96e01c83 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/PageNotFoundException.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.matching.common.exception; + +import com.gachtaxi.global.common.exception.BaseException; +import org.springframework.http.HttpStatus; + +public class PageNotFoundException extends BaseException { + public PageNotFoundException() { + super(HttpStatus.NOT_FOUND, ErrorMessage.NOT_FOUND_PAGE.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/exception/RoomMasterCantJoinException.java b/src/main/java/com/gachtaxi/domain/matching/common/exception/RoomMasterCantJoinException.java new file mode 100644 index 00000000..07b7e1ad --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/exception/RoomMasterCantJoinException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.matching.common.exception; + +import static com.gachtaxi.domain.matching.common.exception.ErrorMessage.MATCHING_ROOM_NOT_JOIN_OWN; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class RoomMasterCantJoinException extends BaseException { + public RoomMasterCantJoinException() { + super(BAD_REQUEST, MATCHING_ROOM_NOT_JOIN_OWN.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomRepository.java b/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomRepository.java new file mode 100644 index 00000000..d2f6ee77 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomRepository.java @@ -0,0 +1,47 @@ +package com.gachtaxi.domain.matching.common.repository; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomStatus; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomType; +import com.gachtaxi.domain.members.entity.Members; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MatchingRoomRepository extends JpaRepository { + @Query("SELECT r FROM MatchingRoom r " + + "WHERE " + + "FUNCTION('ST_Distance_Sphere', FUNCTION('POINT', :startLongitude, :startLatitude), FUNCTION('POINT', r.route.startLongitude, r.route.startLatitude)) <= :radius " + + "AND FUNCTION('ST_Distance_Sphere', FUNCTION('POINT', :destinationLongitude, :destinationLatitude), FUNCTION('POINT', r.route.endLongitude, r.route.endLatitude)) <= :radius ") + List findRoomsByStartAndDestination( + @Param("startLongitude") double startLongitude, + @Param("startLatitude") double startLatitude, + @Param("destinationLongitude") double destinationLongitude, + @Param("destinationLatitude") double destinationLatitude, + @Param("radius") double radius + ); + @Query("SELECT r " + + "FROM MatchingRoom r JOIN r.memberMatchingRoomChargingInfo m " + + "WHERE m.members = :user "+ + "AND r.matchingRoomStatus = 'ACTIVE' "+ + "AND m.paymentStatus != 'LEFT'") + List findByMemberInMatchingRoom(@Param("user") Members user); + + @Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END " + + "FROM MatchingRoom r JOIN r.memberMatchingRoomChargingInfo m " + + "WHERE m.members = :user "+ + "AND r.matchingRoomStatus = 'ACTIVE' "+ + "AND m.paymentStatus != 'LEFT'") + boolean existsByMemberInMatchingRoom(@Param("user") Members user); + + Page findByMatchingRoomTypeAndMatchingRoomStatus(MatchingRoomType type, MatchingRoomStatus status, Pageable pageable); + + @Query("SELECT m.matchingRoom FROM MemberMatchingRoomChargingInfo m WHERE m.members = :user ORDER BY m.matchingRoom.id DESC") + Page findByMemberInMatchingRoom(@Param("user") Members user, Pageable pageable); +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomTagInfoRepository.java b/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomTagInfoRepository.java new file mode 100644 index 00000000..12fc4830 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/repository/MatchingRoomTagInfoRepository.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.matching.common.repository; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoomTagInfo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MatchingRoomTagInfoRepository extends JpaRepository { + +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/repository/MemberMatchingRoomChargingInfoRepository.java b/src/main/java/com/gachtaxi/domain/matching/common/repository/MemberMatchingRoomChargingInfoRepository.java new file mode 100644 index 00000000..9a83f416 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/repository/MemberMatchingRoomChargingInfoRepository.java @@ -0,0 +1,21 @@ +package com.gachtaxi.domain.matching.common.repository; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.MemberMatchingRoomChargingInfo; +import com.gachtaxi.domain.matching.common.entity.enums.PaymentStatus; +import com.gachtaxi.domain.members.entity.Members; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MemberMatchingRoomChargingInfoRepository extends JpaRepository { + + List findByMatchingRoomAndPaymentStatus(MatchingRoom matchingRoom, PaymentStatus paymentStatus); + Optional findByMembersAndMatchingRoom(Members members, MatchingRoom matchingRoom); + + int countByMatchingRoomAndPaymentStatus(MatchingRoom matchingRoom, PaymentStatus paymentStatus); + + boolean existsByMembersAndMatchingRoom(Members members, MatchingRoom matchingRoom); +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/repository/RouteRepository.java b/src/main/java/com/gachtaxi/domain/matching/common/repository/RouteRepository.java new file mode 100644 index 00000000..83a45fdc --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/repository/RouteRepository.java @@ -0,0 +1,9 @@ +package com.gachtaxi.domain.matching.common.repository; + +import com.gachtaxi.domain.matching.common.entity.Route; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RouteRepository extends JpaRepository { +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/service/AutoMatchingService.java b/src/main/java/com/gachtaxi/domain/matching/common/service/AutoMatchingService.java new file mode 100644 index 00000000..ce124e1f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/service/AutoMatchingService.java @@ -0,0 +1,81 @@ +package com.gachtaxi.domain.matching.common.service; + +import com.gachtaxi.domain.matching.algorithm.dto.FindRoomResult; +import com.gachtaxi.domain.matching.algorithm.service.MatchingAlgorithmService; +import com.gachtaxi.domain.matching.common.dto.enums.AutoMatchingStatus; +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingCancelledRequest; +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingPostRequest; +import com.gachtaxi.domain.matching.common.dto.response.AutoMatchingPostResponse; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import com.gachtaxi.domain.matching.event.MatchingEventFactory; +import com.gachtaxi.domain.matching.event.service.kafka.AutoMatchingProducer; +import com.gachtaxi.domain.matching.event.service.sse.SseService; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoMatchingService { + + private final SseService sseService; + private final MatchingAlgorithmService matchingAlgorithmService; + private final MatchingEventFactory matchingEventFactory; + private final AutoMatchingProducer autoMatchingProducer; + + public SseEmitter handleSubscribe(Long userId) { + return this.sseService.subscribe(userId); + } + + public boolean isSseSubscribed(Long memberId) { + return this.sseService.isSubscribed(memberId); + } + + public AutoMatchingPostResponse handlerAutoRequestMatching( + Long memberId, + AutoMatchingPostRequest autoMatchingPostRequest + ) { + List criteria = autoMatchingPostRequest.getCriteria(); + + String[] startCoordinates = autoMatchingPostRequest.startPoint().split(","); + double startLongitude = Double.parseDouble(startCoordinates[0]); + double startLatitude = Double.parseDouble(startCoordinates[1]); + + String[] destinationCoordinates = autoMatchingPostRequest.destinationPoint().split(","); + double destinationLongitude = Double.parseDouble(destinationCoordinates[0]); + double destinationLatitude = Double.parseDouble(destinationCoordinates[1]); + + Optional optionalRoom = + this.matchingAlgorithmService.findRoom(memberId, startLongitude, startLatitude, destinationLongitude, destinationLatitude, criteria); + + optionalRoom + .ifPresentOrElse( + roomResult -> this.sendMatchMemberJoinedEvent(memberId, roomResult), + () -> this.sendMatchRoomCreatedEvent(memberId, autoMatchingPostRequest) + ); + + return AutoMatchingPostResponse.of(AutoMatchingStatus.REQUESTED); + } + + private void sendMatchRoomCreatedEvent(Long memberId, + AutoMatchingPostRequest autoMatchingPostRequest) { + this.autoMatchingProducer.sendEvent(this.matchingEventFactory.createMatchRoomCreatedEvent(memberId, autoMatchingPostRequest)); + } + + private void sendMatchMemberJoinedEvent(Long memberId, FindRoomResult roomResult) { + Long roomId = roomResult.roomId(); + this.autoMatchingProducer.sendEvent(this.matchingEventFactory.createMatchMemberJoinedEvent(roomId, memberId, roomResult.chattingRoomId())); + } + + public AutoMatchingPostResponse handlerAutoCancelMatching(Long memberId, + AutoMatchingCancelledRequest autoMatchingCancelledRequest) { + + this.autoMatchingProducer.sendEvent(this.matchingEventFactory.createMatchMemberCancelledEvent(autoMatchingCancelledRequest.roomId(), memberId)); + + return AutoMatchingPostResponse.of(AutoMatchingStatus.CANCELLED); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/service/ManualMatchingService.java b/src/main/java/com/gachtaxi/domain/matching/common/service/ManualMatchingService.java new file mode 100644 index 00000000..ceade367 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/service/ManualMatchingService.java @@ -0,0 +1,238 @@ +package com.gachtaxi.domain.matching.common.service; + +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.repository.ChattingRoomRepository; +import com.gachtaxi.domain.matching.common.dto.request.ManualMatchingRequest; +import com.gachtaxi.domain.matching.common.dto.response.MatchingRoomResponse; +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.MemberMatchingRoomChargingInfo; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomStatus; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingRoomType; +import com.gachtaxi.domain.matching.common.entity.enums.PaymentStatus; +import com.gachtaxi.domain.matching.common.exception.DuplicatedMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.NotEqualStartAndDestinationException; +import com.gachtaxi.domain.matching.common.exception.PageNotFoundException; +import com.gachtaxi.domain.matching.common.exception.RoomMasterCantJoinException; +import com.gachtaxi.domain.matching.common.exception.MemberAlreadyJoinedException; +import com.gachtaxi.domain.matching.common.exception.MemberAlreadyLeftMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.MemberNotInMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.NoSuchMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.NotActiveMatchingRoomException; +import com.gachtaxi.domain.matching.common.repository.MatchingRoomRepository; +import com.gachtaxi.domain.matching.common.repository.MemberMatchingRoomChargingInfoRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.exception.BlacklistedUserCannotJoinException; +import com.gachtaxi.domain.members.service.BlacklistService; +import com.gachtaxi.domain.members.service.MemberService; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ManualMatchingService { + + private final MemberService memberService; + private final MatchingRoomService matchingRoomService; + private final MatchingInvitationService matchingInvitationService; + private final BlacklistService blacklistService; + private final MatchingRoomRepository matchingRoomRepository; + private final MemberMatchingRoomChargingInfoRepository memberMatchingRoomChargingInfoRepository; + private final ChattingRoomRepository chattingRoomRepository; + + /* + μˆ˜λ™ 맀칭 λ°© 생성 + */ + @Transactional + public Long createManualMatchingRoom(Long userId, ManualMatchingRequest request) { + Members roomMaster = memberService.findById(userId); + + if (matchingRoomRepository.existsByMemberInMatchingRoom(roomMaster)) { + throw new DuplicatedMatchingRoomException(); + } + + if (request.getDeparture().equals(request.getDestination())) { + throw new NotEqualStartAndDestinationException(); + } + + ChattingRoom chattingRoom = ChattingRoom.builder() + .build(); + chattingRoomRepository.save(chattingRoom); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + LocalDateTime departureTime = LocalDateTime.parse(request.getDeparture(), formatter); + + MatchingRoom matchingRoom = MatchingRoom.manualOf( + roomMaster, + request.getDeparture(), + request.getDestination(), + request.description(), + 4, + request.getTotalCharge(), + departureTime, + chattingRoom.getId() + ); + + MatchingRoom savedMatchingRoom = matchingRoomRepository.save(matchingRoom); + + matchingInvitationService.sendMatchingInvitation(roomMaster, request.getFriendNicknames(), savedMatchingRoom.getId()); + + matchingRoomService.saveMatchingRoomTagInfoForManual(savedMatchingRoom, request.getCriteria()); + matchingRoomService.saveRoomMasterChargingInfoForManual(savedMatchingRoom, roomMaster); + + return savedMatchingRoom.getId(); + } + + /* + μˆ˜λ™ 맀칭방 μ°Έμ—¬ + */ + @Transactional + public void joinManualMatchingRoom(Long userId, Long roomId) { + Members user = memberService.findById(userId); + + MatchingRoom matchingRoom = this.matchingRoomRepository.findById(roomId) + .orElseThrow(NoSuchMatchingRoomException::new); + + if (!matchingRoom.isActive()) { + throw new NotActiveMatchingRoomException(); + } + + if (user.isRoomMaster(matchingRoom)) { + throw new RoomMasterCantJoinException(); + } + + if (this.memberMatchingRoomChargingInfoRepository.existsByMembersAndMatchingRoom(user, matchingRoom)) { + throw new MemberAlreadyJoinedException(); + } + + boolean isBlacklisted = blacklistService.isUserBlacklistedInRoom(user, matchingRoom); + if (isBlacklisted) { + throw new BlacklistedUserCannotJoinException(); + } + + Optional joinedInPast = this.memberMatchingRoomChargingInfoRepository + .findByMembersAndMatchingRoom(user, matchingRoom); + + MemberMatchingRoomChargingInfo memberInfo; + if (joinedInPast.isPresent()) { + memberInfo = joinedInPast.get().joinMatchingRoom(); + } else { + memberInfo = MemberMatchingRoomChargingInfo.notPayedOf(matchingRoom, user); + } + + this.memberMatchingRoomChargingInfoRepository.save(memberInfo); + + List existMembers = this.memberMatchingRoomChargingInfoRepository + .findByMatchingRoomAndPaymentStatus(matchingRoom, PaymentStatus.NOT_PAYED); + + int distributedCharge = (int) Math.ceil((double) matchingRoom.getTotalCharge() / (existMembers.size() + 1)); + + matchingRoomService.updateExistMembersChargeForManual(existMembers, distributedCharge); + + int nowMemberCount = existMembers.size() + 1; + + if (matchingRoom.isFull(nowMemberCount)) { + matchingRoom.completeMatchingRoom(); + this.matchingRoomRepository.save(matchingRoom); + } + } + /* + todo μˆ˜λ™ 맀칭 β†’ μžλ™ 맀칭 μ „ν™˜ : μΆ”ν›„ κ³ λ„ν™”μ‹œ, 10뢄전에 μœ μ €μ—κ²Œ μ•Œλ¦Όμ„ μ£Όκ³  μžλ™ 맀칭으둜 μ „ν™˜ + */ + @Transactional + public void convertToAutoMatching(Long roomId) { + MatchingRoom matchingRoom = this.matchingRoomRepository.findById(roomId) + .orElseThrow(NoSuchMatchingRoomException::new); + + if (!matchingRoom.isActive()) { + throw new NotActiveMatchingRoomException(); + } + + if (LocalDateTime.now().isAfter(matchingRoom.getDepartureTime().minusMinutes(10))) { + + int currentMembers = this.memberMatchingRoomChargingInfoRepository + .countByMatchingRoomAndPaymentStatus(matchingRoom, PaymentStatus.NOT_PAYED); + + if (matchingRoom.isAutoConvertible(currentMembers)) { + matchingRoom.convertToAutoMatching(); + matchingRoomRepository.save(matchingRoom); + } + } + } + + /* + λ°©μž₯ μ·¨μ†Œ + λ°© μ‚­μ œ + */ + @Transactional + public void leaveManualMatchingRoom(Long userId, Long roomId) { + Members user = this.memberService.findById(userId); + MatchingRoom matchingRoom = this.matchingRoomRepository.findById(roomId) + .orElseThrow(NoSuchMatchingRoomException::new); + + MemberMatchingRoomChargingInfo memberMatchingRoomChargingInfo = + this.memberMatchingRoomChargingInfoRepository.findByMembersAndMatchingRoom(user, matchingRoom) + .orElseThrow(MemberNotInMatchingRoomException::new); + + if (memberMatchingRoomChargingInfo.isAlreadyLeft()) { + throw new MemberAlreadyLeftMatchingRoomException(); + } + + memberMatchingRoomChargingInfo.leftMatchingRoom(); + this.memberMatchingRoomChargingInfoRepository.save(memberMatchingRoomChargingInfo); + + if (user.isRoomMaster(matchingRoom)) { + List remainingMembers = + this.memberMatchingRoomChargingInfoRepository.findByMatchingRoomAndPaymentStatus(matchingRoom, PaymentStatus.NOT_PAYED); + + if (remainingMembers.isEmpty()) { + matchingRoom.cancelMatchingRoom(); + this.matchingRoomRepository.save(matchingRoom); + } else { + Members newRoomMaster = remainingMembers.get(0).getMembers(); + matchingRoom.changeRoomMaster(newRoomMaster); + this.matchingRoomRepository.save(matchingRoom); + } + } + } + /* + μˆ˜λ™ 맀칭 λ°© 리슀트 쑰회 + */ + @Transactional + public Page getManualMatchingList(int pageNumber, int pageSize) { + if (pageNumber < 0) { + throw new PageNotFoundException(); + } + + Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); + Page rooms = matchingRoomRepository.findByMatchingRoomTypeAndMatchingRoomStatus( + MatchingRoomType.MANUAL, MatchingRoomStatus.ACTIVE, pageable); + + return rooms.map(MatchingRoomResponse::from); + } + /* + λ‚˜μ˜ 맀칭방 리슀트 쑰회 + */ + @Transactional + public Page getMyMatchingList(Long userId, int pageNumber, int pageSize) { + if (pageNumber < 0) { + throw new PageNotFoundException(); + } + + Members user = memberService.findById(userId); + Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); + Page rooms = matchingRoomRepository.findByMemberInMatchingRoom(user, pageable); + + return rooms.map(MatchingRoomResponse::from); + } +} + diff --git a/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingInvitationService.java b/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingInvitationService.java new file mode 100644 index 00000000..67397809 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingInvitationService.java @@ -0,0 +1,100 @@ +package com.gachtaxi.domain.matching.common.service; + +import static com.gachtaxi.domain.notification.entity.enums.NotificationType.MATCH_INVITE; +import com.gachtaxi.domain.matching.common.dto.request.ManualMatchingInviteReplyRequest; +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.MemberMatchingRoomChargingInfo; +import com.gachtaxi.domain.matching.common.entity.enums.MatchingInviteStatus; +import com.gachtaxi.domain.matching.common.exception.AlreadyInMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.MatchingRoomAlreadyFullException; +import com.gachtaxi.domain.matching.common.exception.NoSuchInvitationException; +import com.gachtaxi.domain.matching.common.exception.NoSuchMatchingRoomException; +import com.gachtaxi.domain.matching.common.repository.MatchingRoomRepository; +import com.gachtaxi.domain.matching.common.repository.MemberMatchingRoomChargingInfoRepository; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.repository.MemberRepository; +import com.gachtaxi.domain.members.service.MemberService; +import com.gachtaxi.domain.notification.entity.Notification; +import com.gachtaxi.domain.notification.entity.enums.NotificationType; +import com.gachtaxi.domain.notification.entity.payload.MatchingInvitePayload; +import com.gachtaxi.domain.notification.repository.NotificationRepository; +import com.gachtaxi.domain.notification.service.NotificationService; +import jakarta.transaction.Transactional; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Transactional +public class MatchingInvitationService { + private final NotificationService notificationService; + private final MemberService memberService; + private final MemberRepository memberRepository; + private final MatchingRoomRepository matchingRoomRepository; + private final MemberMatchingRoomChargingInfoRepository memberMatchingRoomChargingInfoRepository; + private final NotificationRepository notificationRepository; + + /* + μˆ˜λ™ λ§€μΉ­μ‹œ 친ꡬ μ΄ˆλŒ€ + */ + public static final String MATCHING_INVITE_TITLE = "μˆ˜λ™ 맀칭 μ΄ˆλŒ€"; + public static final String MATCHING_INVITE_CONTENT = "%s λ‹˜μ΄ μˆ˜λ™ 맀칭 μ΄ˆλŒ€λ₯Ό λ³΄λƒˆμŠ΅λ‹ˆλ‹€."; + + public void sendMatchingInvitation(Members sender, List friendNicknames, Long matchingRoomId) { + if (friendNicknames == null || friendNicknames.isEmpty()) { + return; + } + + List friends = memberRepository.findByNicknameIn(friendNicknames); + + for (Members friend : friends) { + notificationService.sendWithPush( + friend, + MATCH_INVITE, + MATCHING_INVITE_TITLE, + String.format(MATCHING_INVITE_CONTENT, sender.getNickname()), + MatchingInvitePayload.from(sender.getNickname(), matchingRoomId) + ); + } + } + + /* + μˆ˜λ™ λ§€μΉ­μ‹œ 친ꡬ μ΄ˆλŒ€ 수락 + */ + @Transactional + public void acceptInvitation(Long userId, ManualMatchingInviteReplyRequest request) { + Members member = memberService.findById(userId); + MatchingRoom matchingRoom = matchingRoomRepository.findById(request.matchingRoomId()) + .orElseThrow(NoSuchMatchingRoomException::new); + + Notification notification = notificationService.find(request.notificationId()); + + MatchingInvitePayload payload = (MatchingInvitePayload) notification.getPayload(); + if (!payload.getMatchingRoomId().equals(request.matchingRoomId())) { + throw new NoSuchInvitationException(); + } + + if (request.status() == MatchingInviteStatus.REJECT) { + notificationRepository.delete(notification); + return; + } + + notificationRepository.save(notification); + + if (notificationRepository.countByReceiverIdAndType(userId, NotificationType.MATCH_INVITE) == 0) { + throw new NoSuchInvitationException(); + } + + if (matchingRoom.getCurrentMemberCount() >= matchingRoom.getCapacity()) { + throw new MatchingRoomAlreadyFullException(); + } + + if (matchingRoomRepository.existsByMemberInMatchingRoom(member)) { + throw new AlreadyInMatchingRoomException(); + } + + MemberMatchingRoomChargingInfo memberInfo = MemberMatchingRoomChargingInfo.notPayedOf(matchingRoom, member); + memberMatchingRoomChargingInfoRepository.save(memberInfo); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingRoomService.java b/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingRoomService.java new file mode 100644 index 00000000..d8365707 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/common/service/MatchingRoomService.java @@ -0,0 +1,212 @@ +package com.gachtaxi.domain.matching.common.service; + +import com.gachtaxi.domain.chat.entity.ChattingRoom; +import com.gachtaxi.domain.chat.service.ChattingRoomService; +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.matching.common.entity.MatchingRoomTagInfo; +import com.gachtaxi.domain.matching.common.entity.MemberMatchingRoomChargingInfo; +import com.gachtaxi.domain.matching.common.entity.Route; +import com.gachtaxi.domain.matching.common.entity.enums.PaymentStatus; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import com.gachtaxi.domain.matching.common.exception.MemberAlreadyLeftMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.MemberNotInMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.NoSuchMatchingRoomException; +import com.gachtaxi.domain.matching.common.exception.NotActiveMatchingRoomException; +import com.gachtaxi.domain.matching.common.repository.MatchingRoomRepository; +import com.gachtaxi.domain.matching.common.repository.MatchingRoomTagInfoRepository; +import com.gachtaxi.domain.matching.common.repository.MemberMatchingRoomChargingInfoRepository; +import com.gachtaxi.domain.matching.common.repository.RouteRepository; +import com.gachtaxi.domain.matching.event.MatchingEventFactory; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberJoinedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCompletedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCreatedEvent; +import com.gachtaxi.domain.matching.event.service.kafka.AutoMatchingProducer; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.service.MemberService; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class MatchingRoomService { + + // service + private final MemberService memberService; + private final AutoMatchingProducer autoMatchingProducer; + private final ChattingRoomService chattingRoomService; + + // repository + private final MatchingRoomRepository matchingRoomRepository; + private final MatchingRoomTagInfoRepository matchingRoomTagInfoRepository; + private final RouteRepository routeRepository; + private final MemberMatchingRoomChargingInfoRepository memberMatchingRoomChargingInfoRepository; + + // event factory + private final MatchingEventFactory matchingEventFactory; + + public MatchRoomCreatedEvent createMatchingRoom(MatchRoomCreatedEvent matchRoomCreatedEvent) { + Members members = this.memberService.findById(matchRoomCreatedEvent.roomMasterId()); + + Route route = this.saveRoute(matchRoomCreatedEvent); + + ChattingRoom chattingRoom = this.chattingRoomService.create(); + + MatchingRoom matchingRoom = MatchingRoom.activeOf(matchRoomCreatedEvent, members, route, chattingRoom); + + this.saveMatchingRoomTagInfo(matchingRoom, matchRoomCreatedEvent.criteria()); + this.saveRoomMasterChargingInfo(matchingRoom, members); + + MatchingRoom savedMatchingRoom = this.matchingRoomRepository.save(matchingRoom); + + return MatchRoomCreatedEvent.of(matchRoomCreatedEvent, savedMatchingRoom.getId(), savedMatchingRoom.getChattingRoomId()); + } + + private Route saveRoute(MatchRoomCreatedEvent matchRoomCreatedEvent) { + String[] startCoordinates = matchRoomCreatedEvent.startPoint().split(","); + double startLatitude = Double.parseDouble(startCoordinates[0]); + double startLongitude = Double.parseDouble(startCoordinates[1]); + + String[] endCoordinates = matchRoomCreatedEvent.destinationPoint().split(","); + double endLatitude = Double.parseDouble(endCoordinates[0]); + double endLongitude = Double.parseDouble(endCoordinates[1]); + + Route route = Route.builder() + .startLongitude(startLongitude) + .startLatitude(startLatitude) + .startLocationName(matchRoomCreatedEvent.startName()) + .endLongitude(endLongitude) + .endLatitude(endLatitude) + .endLocationName(matchRoomCreatedEvent.destinationName()) + .build(); + return this.routeRepository.save(route); + } + + private void saveMatchingRoomTagInfo(MatchingRoom matchingRoom, List tags) { + tags.forEach(tag -> this.matchingRoomTagInfoRepository.save(MatchingRoomTagInfo.of(matchingRoom, tag))); + } + public void saveMatchingRoomTagInfoForManual(MatchingRoom matchingRoom, List tags) { + tags.forEach(tag -> this.matchingRoomTagInfoRepository.save(MatchingRoomTagInfo.of(matchingRoom, tag))); + } + private void saveRoomMasterChargingInfo(MatchingRoom matchingRoom, Members members) { + this.memberMatchingRoomChargingInfoRepository.save(MemberMatchingRoomChargingInfo.notPayedOf(matchingRoom, members)); + } + public void saveRoomMasterChargingInfoForManual(MatchingRoom matchingRoom, Members members) { + this.memberMatchingRoomChargingInfoRepository.save(MemberMatchingRoomChargingInfo.notPayedOf(matchingRoom, members)); + } + public void joinMemberToMatchingRoom(MatchMemberJoinedEvent matchMemberJoinedEvent) { + Members members = this.memberService.findById(matchMemberJoinedEvent.memberId()); + MatchingRoom matchingRoom = this.matchingRoomRepository.findById(matchMemberJoinedEvent.roomId()).orElseThrow(NoSuchMatchingRoomException::new); + + if (!matchingRoom.isActive()) { + throw new NotActiveMatchingRoomException(); + } + + MemberMatchingRoomChargingInfo requestedMembersInfo = null; + + Optional joinedInPast = this.alreadyJoinedInPast(members, matchingRoom); + + if (joinedInPast.isPresent()) { + requestedMembersInfo = joinedInPast.get().joinMatchingRoom(); + } else { + requestedMembersInfo = MemberMatchingRoomChargingInfo.notPayedOf(matchingRoom, members); + } + this.memberMatchingRoomChargingInfoRepository.save(requestedMembersInfo); + + List existMembers = this.memberMatchingRoomChargingInfoRepository.findByMatchingRoomAndPaymentStatus(matchingRoom, PaymentStatus.NOT_PAYED); + + int distributedCharge = (int) Math.ceil((double) matchingRoom.getTotalCharge() / (existMembers.size() + 1)); + + this.updateExistMembersCharge(existMembers, distributedCharge); + + int nowMemberCount = existMembers.size() + 1; + + if (matchingRoom.isFull(nowMemberCount)) { + this.autoMatchingProducer.sendEvent(this.matchingEventFactory.createMatchRoomCompletedEvent(matchingRoom.getId())); + } + } + + private Optional alreadyJoinedInPast(Members members, MatchingRoom matchingRoom) { + return this.memberMatchingRoomChargingInfoRepository.findByMembersAndMatchingRoom(members, matchingRoom); + } + + private void updateExistMembersCharge(List existMembers, int charge) { + for (MemberMatchingRoomChargingInfo memberMatchingRoomChargingInfo : existMembers) { + memberMatchingRoomChargingInfo.setCharge(charge); + } + this.memberMatchingRoomChargingInfoRepository.saveAll(existMembers); + } + public void updateExistMembersChargeForManual(List existMembers, int charge) { + for (MemberMatchingRoomChargingInfo memberMatchingRoomChargingInfo : existMembers) { + memberMatchingRoomChargingInfo.setCharge(charge); + } + this.memberMatchingRoomChargingInfoRepository.saveAll(existMembers); + } + public void leaveMemberFromMatchingRoom(MatchMemberCancelledEvent matchMemberCancelledEvent) { + Members members = this.memberService.findById(matchMemberCancelledEvent.memberId()); + MatchingRoom matchingRoom = this.matchingRoomRepository.findById(matchMemberCancelledEvent.roomId()).orElseThrow(NoSuchMatchingRoomException::new); + + MemberMatchingRoomChargingInfo memberMatchingRoomChargingInfo = + this.memberMatchingRoomChargingInfoRepository.findByMembersAndMatchingRoom(members, matchingRoom) + .orElseThrow(MemberNotInMatchingRoomException::new); + + if (memberMatchingRoomChargingInfo.isAlreadyLeft()) { + throw new MemberAlreadyLeftMatchingRoomException(); + } + + memberMatchingRoomChargingInfo.leftMatchingRoom(); + + this.memberMatchingRoomChargingInfoRepository.save(memberMatchingRoomChargingInfo); + + if (members.isRoomMaster(matchingRoom)) { + this.findNextRoomMaster(matchingRoom, members) + .ifPresentOrElse( + nextRoomMaster -> matchingRoom.changeRoomMaster(nextRoomMaster), + () -> this.autoMatchingProducer.sendEvent(this.matchingEventFactory.createMatchRoomCancelledEvent(matchingRoom.getId())) + ); + } + } + + private Optional findNextRoomMaster(MatchingRoom matchingRoom, Members members) { + List existMembers = + this.memberMatchingRoomChargingInfoRepository.findByMatchingRoomAndPaymentStatus(matchingRoom, PaymentStatus.NOT_PAYED); + + return existMembers.stream() + .map(MemberMatchingRoomChargingInfo::getMembers) + .filter(member -> !member.equals(members)) + .findFirst(); + } + + public void cancelMatchingRoom(MatchRoomCancelledEvent matchRoomCancelledEvent) { + MatchingRoom matchingRoom = this.getMatchingRoomById(matchRoomCancelledEvent.roomId()); + + if (!matchingRoom.isActive()) { + throw new NotActiveMatchingRoomException(); + } + + matchingRoom.cancelMatchingRoom(); + this.matchingRoomRepository.save(matchingRoom); + } + + public void completeMatchingRoom(MatchRoomCompletedEvent matchRoomCompletedEvent) { + MatchingRoom matchingRoom = this.getMatchingRoomById(matchRoomCompletedEvent.roomId()); + + if (!matchingRoom.isActive()) { + throw new NotActiveMatchingRoomException(); + } + + matchingRoom.completeMatchingRoom(); + this.matchingRoomRepository.save(matchingRoom); + } + + private MatchingRoom getMatchingRoomById(Long roomId) { + return this.matchingRoomRepository.findById(roomId).orElseThrow(NoSuchMatchingRoomException::new); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/MatchingEventFactory.java b/src/main/java/com/gachtaxi/domain/matching/event/MatchingEventFactory.java new file mode 100644 index 00000000..b23ec787 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/MatchingEventFactory.java @@ -0,0 +1,55 @@ +package com.gachtaxi.domain.matching.event; + +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingPostRequest; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberJoinedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCompletedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCreatedEvent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class MatchingEventFactory { + + @Value("${gachtaxi.kafka.topics.match-member-cancelled}") + private String matchMemberCancelledTopic; + + @Value("${gachtaxi.kafka.topics.match-member-joined}") + private String matchMemberJoinedTopic; + + @Value("${gachtaxi.kafka.topics.match-room-cancelled}") + private String matchRoomCancelledTopic; + + @Value("${gachtaxi.kafka.topics.match-room-completed}") + private String matchRoomCompletedTopic; + + @Value("${gachtaxi.kafka.topics.match-room-created}") + private String matchRoomCreatedTopic; + + @Value("${gachtaxi.matching.auto-matching-max-capacity}") + private int autoMaxCapacity; + + @Value("${gachtaxi.matching.auto-matching-description}") + private String autoDescription; + + public MatchMemberCancelledEvent createMatchMemberCancelledEvent(Long roomId, Long memberId) { + return MatchMemberCancelledEvent.of(roomId, memberId, this.matchMemberCancelledTopic); + } + + public MatchMemberJoinedEvent createMatchMemberJoinedEvent(Long roomId, Long memberId, Long chattingRoomId) { + return MatchMemberJoinedEvent.of(roomId, memberId, this.matchMemberJoinedTopic, chattingRoomId); + } + + public MatchRoomCancelledEvent createMatchRoomCancelledEvent(Long roomId) { + return MatchRoomCancelledEvent.of(roomId, this.matchRoomCancelledTopic); + } + + public MatchRoomCompletedEvent createMatchRoomCompletedEvent(Long roomId) { + return MatchRoomCompletedEvent.of(roomId, this.matchRoomCompletedTopic); + } + + public MatchRoomCreatedEvent createMatchRoomCreatedEvent(Long memberId, AutoMatchingPostRequest autoMatchingPostRequest) { + return MatchRoomCreatedEvent.of(memberId, autoMatchingPostRequest, this.autoMaxCapacity, this.autoDescription, this.matchRoomCreatedTopic); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberCancelledEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberCancelledEvent.java new file mode 100644 index 00000000..32922887 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberCancelledEvent.java @@ -0,0 +1,37 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.beans.factory.annotation.Value; + +@Builder(access = AccessLevel.PRIVATE) +public record MatchMemberCancelledEvent( + Long roomId, + Long memberId, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime canceledAt, + + String topic +) implements MatchingEvent{ + + @Override + public Object getKey() { + return String.valueOf(this.roomId); + } + + @Override + public String getTopic() { + return this.topic; + } + + public static MatchMemberCancelledEvent of(Long roomId, Long memberId, String topic) { + return MatchMemberCancelledEvent.builder() + .roomId(roomId) + .memberId(memberId) + .topic(topic) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberJoinedEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberJoinedEvent.java new file mode 100644 index 00000000..a727ed34 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchMemberJoinedEvent.java @@ -0,0 +1,39 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.beans.factory.annotation.Value; + +@Builder(access = AccessLevel.PRIVATE) +public record MatchMemberJoinedEvent( + Long roomId, + Long memberId, + Long chattingRoomId, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime joinedAt, + + String topic +) implements MatchingEvent{ + + @Override + public Object getKey() { + return String.valueOf(this.roomId); + } + + @Override + public String getTopic() { + return this.topic; + } + + public static MatchMemberJoinedEvent of(Long roomId, Long memberId, String topic, Long chattingRoomId) { + return MatchMemberJoinedEvent.builder() + .roomId(roomId) + .memberId(memberId) + .topic(topic) + .chattingRoomId(chattingRoomId) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCancelledEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCancelledEvent.java new file mode 100644 index 00000000..b6870b74 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCancelledEvent.java @@ -0,0 +1,30 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.beans.factory.annotation.Value; + +@Builder(access = AccessLevel.PRIVATE) +public record MatchRoomCancelledEvent( + Long roomId, + + String topic +) implements MatchingEvent{ + + @Override + public Object getKey() { + return String.valueOf(this.roomId); + } + + @Override + public String getTopic() { + return this.topic; + } + + public static MatchRoomCancelledEvent of(Long roomId, String topic) { + return MatchRoomCancelledEvent.builder() + .roomId(roomId) + .topic(topic) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCompletedEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCompletedEvent.java new file mode 100644 index 00000000..80669452 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCompletedEvent.java @@ -0,0 +1,30 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.beans.factory.annotation.Value; + +@Builder(access = AccessLevel.PRIVATE) +public record MatchRoomCompletedEvent( + Long roomId, + + String topic +) implements MatchingEvent{ + + @Override + public Object getKey() { + return String.valueOf(this.roomId); + } + + @Override + public String getTopic() { + return this.topic; + } + + public static MatchRoomCompletedEvent of(Long roomId, String topic) { + return MatchRoomCompletedEvent.builder() + .roomId(roomId) + .topic(topic) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCreatedEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCreatedEvent.java new file mode 100644 index 00000000..8d09c9d5 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchRoomCreatedEvent.java @@ -0,0 +1,85 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.gachtaxi.domain.matching.common.dto.request.AutoMatchingPostRequest; +import com.gachtaxi.domain.matching.common.entity.enums.Tags; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; + +@Builder(access = AccessLevel.PRIVATE) +public record MatchRoomCreatedEvent( + Long roomMasterId, + Integer maxCapacity, + String title, + String description, + String startPoint, + String startName, + String destinationPoint, + String destinationName, + List criteria, + Integer expectedTotalCharge, + Long roomId, + Long chattingRoomId, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + String topic +) implements MatchingEvent{ + + @Override + public Object getKey() { + return null; + } + + @Override + public String getTopic() { + return this.topic; + } + + public static MatchRoomCreatedEvent of(MatchRoomCreatedEvent event, Long roomId, Long chattingRoomId) { + return MatchRoomCreatedEvent.builder() + .roomMasterId(event.roomMasterId()) + .maxCapacity(event.maxCapacity()) + .title(event.title()) + .description(event.description()) + .startPoint(event.startPoint()) + .startName(event.startName()) + .destinationPoint(event.destinationPoint()) + .destinationName(event.destinationName()) + .criteria(event.criteria()) + .expectedTotalCharge(event.expectedTotalCharge()) + .roomId(roomId) + .createdAt(event.createdAt()) + .topic(event.topic()) + .chattingRoomId(chattingRoomId) + .build(); + } + + public static MatchRoomCreatedEvent of( + Long roomMasterId, + AutoMatchingPostRequest autoMatchingPostRequest, + int maxCapacity, + String description, + String topic + ) { + return MatchRoomCreatedEvent.builder() + .roomMasterId(roomMasterId) + .startPoint(autoMatchingPostRequest.startPoint()) + .startName(autoMatchingPostRequest.startName()) + .destinationPoint(autoMatchingPostRequest.destinationPoint()) + .destinationName(autoMatchingPostRequest.destinationName()) + .maxCapacity(maxCapacity) + .title(UUID.randomUUID().toString()) + .description(description) + .expectedTotalCharge(autoMatchingPostRequest.expectedTotalCharge()) + .criteria(autoMatchingPostRequest.getCriteria()) + .topic(topic) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchingEvent.java b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchingEvent.java new file mode 100644 index 00000000..a2b0ae4a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/dto/kafka_topic/MatchingEvent.java @@ -0,0 +1,7 @@ +package com.gachtaxi.domain.matching.event.dto.kafka_topic; + +public interface MatchingEvent { + + Object getKey(); + String getTopic(); +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingConsumer.java b/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingConsumer.java new file mode 100644 index 00000000..3ff71aca --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingConsumer.java @@ -0,0 +1,135 @@ +package com.gachtaxi.domain.matching.event.service.kafka; + +import com.gachtaxi.domain.matching.common.service.MatchingRoomService; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberJoinedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCompletedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCreatedEvent; +import com.gachtaxi.domain.matching.event.service.sse.SseService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoMatchingConsumer { + + private final SseService sseService; + private final MatchingRoomService matchingRoomService; + + /** + * λ°© 생성 이벀트 ꡬ독 + */ + @KafkaListener( + topics = "${gachtaxi.kafka.topics.match-room-created}", + containerFactory = "matchRoomCreatedEventListenerFactory" + ) + public void onMatchRoomCreated(MatchRoomCreatedEvent event, Acknowledgment ack) { + try { + log.info("[KAFKA CONSUMER] Received MatchRoomCreatedEvent: {}", event); + + this.sseService.sendToClient( + event.roomMasterId(), + "MATCH_ROOM_CREATED", + this.matchingRoomService.createMatchingRoom(event) + ); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[KAFKA CONSUMER] Error processing MatchRoomCreatedEvent", e); + this.sseService.sendToClient(event.roomMasterId(), "MATCH_ROOM_CREATED", e.getMessage()); + } + } + + /** + * λ°© 멀버 μ°Έκ°€ 이벀트 ꡬ독 + */ + @KafkaListener( + topics = "${gachtaxi.kafka.topics.match-member-joined}", + containerFactory = "matchMemberJoinedEventListenerFactory" + ) + public void onMatchMemberJoined(MatchMemberJoinedEvent event, Acknowledgment ack) { + try { + log.info("[KAFKA CONSUMER] Received MatchMemberJoinedEvent: {}", event); + + this.matchingRoomService.joinMemberToMatchingRoom(event); + + this.sseService.broadcast("MATCH_MEMBER_JOINED", event); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[KAFKA CONSUMER] Error processing MatchMemberJoinedEvent", e); + this.sseService.sendToClient(event.memberId(), "MATCH_MEMBER_JOINED", e.getMessage()); + } + } + + /** + * λ°© 멀버 μ·¨μ†Œ 이벀트 ꡬ독 + */ + @KafkaListener( + topics = "${gachtaxi.kafka.topics.match-member-cancelled}", + containerFactory = "matchMemberCancelledEventListenerFactory" + ) + public void onMatchMemberLeft(MatchMemberCancelledEvent event, Acknowledgment ack) { + try { + log.info("[KAFKA CONSUMER] Received MatchMemberLeftEvent: {}", event); + + this.matchingRoomService.leaveMemberFromMatchingRoom(event); + + this.sseService.broadcast("MATCH_MEMBER_LEFT", event); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[KAFKA CONSUMER] Error processing MatchMemberLeftEvent", e); + this.sseService.sendToClient(event.memberId(), "MATCH_MEMBER_LEFT", e.getMessage()); + } + } + + /** + * λ°© μ·¨μ†Œ 이벀트 ꡬ독 + */ + @KafkaListener( + topics = "${gachtaxi.kafka.topics.match-room-cancelled}", + containerFactory = "matchRoomCancelledEventListenerFactory" + ) + public void onMatchRoomCancelled(MatchRoomCancelledEvent event, Acknowledgment ack) { + try { + log.info("[KAFKA CONSUMER] Received MatchRoomCancelledEvent: {}", event); + + this.matchingRoomService.cancelMatchingRoom(event); + + this.sseService.broadcast("MATCH_ROOM_CANCELLED", event); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[KAFKA CONSUMER] Error processing MatchRoomCancelledEvent", e); + this.sseService.sendToClient(event.roomId(), "MATCH_ROOM_CANCELLED", e.getMessage()); + } + } + + /** + * λ°© μ™„λ£Œ 이벀트 ꡬ독 + */ + @KafkaListener( + topics = "${gachtaxi.kafka.topics.match-room-completed}", + containerFactory = "matchRoomCompletedEventListenerFactory" + ) + public void onMatchingRoomCompleted(MatchRoomCompletedEvent event, Acknowledgment ack) { + try { + log.info("[KAFKA CONSUMER] Received MatchingRoomCompletedEvent: {}", event); + + this.matchingRoomService.completeMatchingRoom(event); + + this.sseService.broadcast("MATCH_ROOM_COMPLETED", event); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[KAFKA CONSUMER] Error processing MatchingRoomCompletedEvent", e); + this.sseService.sendToClient(event.roomId(), "MATCH_ROOM_COMPLETED", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingProducer.java b/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingProducer.java new file mode 100644 index 00000000..037c85a6 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/service/kafka/AutoMatchingProducer.java @@ -0,0 +1,49 @@ +package com.gachtaxi.domain.matching.event.service.kafka; + +import static com.gachtaxi.global.auth.jwt.util.kafka.KafkaBeanSuffix.KAFKA_TEMPLATE_SUFFIX; + +import com.gachtaxi.domain.matching.common.exception.NotDefinedKafkaTemplateException; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchingEvent; +import com.gachtaxi.global.auth.jwt.util.KafkaBeanUtils; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AutoMatchingProducer { + + private final ApplicationContext applicationContext; + + public void sendEvent(MatchingEvent matchingEvent) { + String topic = matchingEvent.getTopic(); + Object key = matchingEvent.getKey(); + + try { + KafkaTemplate kafkaTemplate = this.applicationContext.getBean( + KafkaBeanUtils.getBeanName(topic, KAFKA_TEMPLATE_SUFFIX), KafkaTemplate.class); + CompletableFuture future = kafkaTemplate.send(matchingEvent.getTopic(), matchingEvent.getKey(), matchingEvent); + + future.thenAccept(result -> { + if (result instanceof RecordMetadata metadata) { + log.info("[KAFKA PRODUCER] Success sending MatchRoomCreatedEvent: " + + "topic={}, partition={}, offset={}, key={}", + metadata.topic(), metadata.partition(), metadata.offset(), key + ); + } + } + ).exceptionally(ex -> { + log.error("[KAFKA PRODUCER] Failed to send MatchRoomCreatedEvent key={}", key, ex); + return null; + }); + } catch (BeansException beansException) { + throw new NotDefinedKafkaTemplateException(); + } + } +} diff --git a/src/main/java/com/gachtaxi/domain/matching/event/service/sse/SseService.java b/src/main/java/com/gachtaxi/domain/matching/event/service/sse/SseService.java new file mode 100644 index 00000000..3f767b23 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/matching/event/service/sse/SseService.java @@ -0,0 +1,79 @@ +package com.gachtaxi.domain.matching.event.service.sse; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * μ‚¬μš©μžλ³„ SSE Emitter 관리 + */ +@Slf4j +@Service +public class SseService { + + private final Map emitterMap = new ConcurrentHashMap<>(); + + /** + * SSE ꡬ독 + */ + public SseEmitter subscribe(Long memberId) { + SseEmitter emitter = new SseEmitter(10 * 60 * 1000L); // 10λΆ„ + emitter.onCompletion(() -> emitterMap.remove(memberId)); + emitter.onTimeout(() -> emitterMap.remove(memberId)); + this.emitterMap.put(memberId, emitter); + + Map initMessage = new ConcurrentHashMap<>( + Map.of("message", "member %s Connection established".formatted(memberId)) + ); + + try { + emitter.send(SseEmitter.event() + .name("init") + .data(initMessage)); + } catch (IOException e) { + emitter.completeWithError(e); + } + + return emitter; + } + + /** + * userIdκ°€ ν˜„μž¬ SSEλ₯Ό ꡬ독 쀑인지 확인 + */ + public boolean isSubscribed(Long memberId) { + return this.emitterMap.containsKey(memberId); + } + + /** + * νŠΉμ • μ‚¬μš©μžμ—κ²Œ 이벀트 전솑 + */ + public void sendToClient(Long memberId, String eventName, Object data) { + SseEmitter emitter = this.emitterMap.get(memberId); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name(eventName) + .data(data)); + } catch (IOException e) { + this.emitterMap.remove(memberId); + } + } + } + + /** + * λͺ¨λ“  μ‚¬μš©μžμ—κ²Œ λΈŒλ‘œλ“œμΊμŠ€νŠΈ + */ + public void broadcast(String eventName, Object data) { + for (Map.Entry entry : emitterMap.entrySet()) { + try { + entry.getValue().send(SseEmitter.event().name(eventName).data(data)); + } catch (IOException e) { + this.emitterMap.remove(entry.getKey()); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/domain/members/controller/AccountController.java b/src/main/java/com/gachtaxi/domain/members/controller/AccountController.java new file mode 100644 index 00000000..997350c8 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/AccountController.java @@ -0,0 +1,43 @@ +package com.gachtaxi.domain.members.controller; + +import static com.gachtaxi.domain.members.controller.ResponseMessage.ACCOUNT_GET_SUCCESS; +import static com.gachtaxi.domain.members.controller.ResponseMessage.ACCOUNT_UPDATE_SUCCESS; +import static org.springframework.http.HttpStatus.OK; + +import com.gachtaxi.domain.members.dto.request.AccountPostRequest; +import com.gachtaxi.domain.members.dto.response.AccountGetResponse; +import com.gachtaxi.domain.members.dto.response.AccountPostResponse; +import com.gachtaxi.domain.members.service.AccountService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/accounts") +@RequiredArgsConstructor +public class AccountController { + + private final AccountService accountService; + + @GetMapping + public ApiResponse getAccount( + @CurrentMemberId Long memberId + ) { + return ApiResponse.response(OK, ACCOUNT_GET_SUCCESS.getMessage(), this.accountService.getAccount(memberId)); + } + + @PostMapping + public ApiResponse updateAccount( + @CurrentMemberId Long memberId, + @RequestBody @Valid AccountPostRequest accountPostRequest + ) { + return ApiResponse.response(OK, ACCOUNT_UPDATE_SUCCESS.getMessage(), + this.accountService.updateAccount(memberId, accountPostRequest.accountNumber())); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/controller/AdminMemberController.java b/src/main/java/com/gachtaxi/domain/members/controller/AdminMemberController.java new file mode 100644 index 00000000..82037081 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/AdminMemberController.java @@ -0,0 +1,30 @@ +package com.gachtaxi.domain.members.controller; + +import com.gachtaxi.global.common.mail.dto.request.NewTemplateRequestDto; +import com.gachtaxi.global.common.mail.service.SesClientTemplateService; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.gachtaxi.global.common.mail.message.ResponseMessage.EMAIL_TEMPLATE_CREATE_SUCCESS; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminMemberController { + + private final SesClientTemplateService sesClientTemplateService; + + @PostMapping("/email/template") + @Operation(summary = "이메일 ν…œν”Œλ¦Ώμ„ μƒμ„±ν•˜λŠ” API μž…λ‹ˆλ‹€. μƒμ„±ν•œ ν…œν”Œλ¦Ώ λͺ…을 ν™˜κ²½λ³€μˆ˜μ— μ μš©ν•΄μ£Όμ„Έμš”.") + public ApiResponse createTemplate(@RequestBody @Valid NewTemplateRequestDto dto){ + sesClientTemplateService.createTemplate(dto); + return ApiResponse.response(HttpStatus.OK, EMAIL_TEMPLATE_CREATE_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/controller/AuthController.java b/src/main/java/com/gachtaxi/domain/members/controller/AuthController.java new file mode 100644 index 00000000..6962b435 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/AuthController.java @@ -0,0 +1,161 @@ +package com.gachtaxi.domain.members.controller; + +import com.gachtaxi.domain.members.dto.request.InactiveMemberAuthCodeRequestDto; +import com.gachtaxi.domain.members.dto.request.MemberAgreementRequestDto; +import com.gachtaxi.domain.members.dto.request.MemberSupplmentRequestDto; +import com.gachtaxi.domain.members.dto.request.MemberTokenDto; +import com.gachtaxi.domain.members.dto.response.LoginDto; +import com.gachtaxi.domain.members.dto.response.MemberLoginResponseDto; +import com.gachtaxi.domain.members.dto.response.MemberResponseDto; +import com.gachtaxi.domain.members.service.AuthService; +import com.gachtaxi.domain.members.service.MemberService; +import com.gachtaxi.global.auth.google.dto.GoogleAuthCode; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.auth.jwt.dto.JwtTokenDto; +import com.gachtaxi.global.auth.jwt.service.JwtService; +import com.gachtaxi.global.auth.jwt.util.CookieUtil; +import com.gachtaxi.global.common.mail.dto.request.EmailAddressDto; +import com.gachtaxi.global.common.mail.service.EmailService; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.gachtaxi.domain.members.controller.ResponseMessage.*; +import static com.gachtaxi.global.auth.jwt.util.JwtProvider.ACCESS_TOKEN_SUBJECT; +import static com.gachtaxi.global.auth.jwt.util.JwtProvider.REFRESH_TOKEN_SUBJECT; +import static com.gachtaxi.global.auth.kakao.dto.KaKaoDTO.KakaoAuthCode; +import static com.gachtaxi.global.common.mail.message.ResponseMessage.*; +import static org.springframework.http.HttpStatus.OK; + +@RequestMapping("/auth") +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final EmailService emailService; + private final CookieUtil cookieUtil; + private final AuthService authService; + private final JwtService jwtService; + private final MemberService memberService; + + @PostMapping("/login/kakao") + @Operation(summary = "인가 μ½”λ“œλ₯Ό 전달받아, 카카였 μ†Œμ…œ λ‘œκ·ΈμΈμ„ μ§„ν–‰ν•©λ‹ˆλ‹€.") + public ApiResponse kakaoLogin( + @RequestBody @Valid KakaoAuthCode kakaoAuthCode + , HttpServletResponse response) + { + LoginDto loginDto = authService.kakaoLogin(kakaoAuthCode.authCode()); + response.setHeader(ACCESS_TOKEN_SUBJECT, loginDto.jwtTokenDto().accessToken()); + + if (loginDto.isTemporaryUser()) { // μž„μ‹œ μœ μ € + return ApiResponse.response(OK, UN_REGISTER.getMessage(), MemberLoginResponseDto.from()); + } + + cookieUtil.setCookie(REFRESH_TOKEN_SUBJECT, loginDto.jwtTokenDto().refreshToken(), response); + return ApiResponse.response( + OK, + LOGIN_SUCCESS.getMessage(), + MemberLoginResponseDto.from(loginDto.memberResponseDto()) + ); + } + + @PostMapping("/login/google") + @Operation(summary = "인가 μ½”λ“œλ₯Ό 전달받아, ꡬ글 μ†Œμ…œ λ‘œκ·ΈμΈμ„ μ§„ν–‰ν•©λ‹ˆλ‹€.") + public ApiResponse googleLogin( + @RequestBody @Valid GoogleAuthCode googleAuthCode + , HttpServletResponse response) + { + LoginDto loginDto = authService.googleLogin(googleAuthCode.authCode()); + response.setHeader(ACCESS_TOKEN_SUBJECT, loginDto.jwtTokenDto().accessToken()); + + if (loginDto.isTemporaryUser()) { // μž„μ‹œ μœ μ € + return ApiResponse.response(HttpStatus.OK, UN_REGISTER.getMessage(), MemberLoginResponseDto.from()); + } + + cookieUtil.setCookie(REFRESH_TOKEN_SUBJECT, loginDto.jwtTokenDto().refreshToken(), response); + return ApiResponse.response( + OK, + LOGIN_SUCCESS.getMessage(), + MemberLoginResponseDto.from(loginDto.memberResponseDto()) + ); } + + @PostMapping("/refresh") + @Operation(summary = "RefreshToken으둜 AccessTokenκ³Ό RefreshToken을 μž¬λ°œκΈ‰ ν•˜λŠ” API μž…λ‹ˆλ‹€.") + public ApiResponse reissueRefreshToken( + @CookieValue(value = REFRESH_TOKEN_SUBJECT) String refreshToken, + HttpServletResponse response + ) { + + JwtTokenDto jwtTokenDto = jwtService.reissueJwtToken(refreshToken); + responseToken(jwtTokenDto, response); + + return ApiResponse.response(OK, REFRESH_TOKEN_REISSUE.getMessage()); + } + + @PostMapping("/code/mail") + @Operation(summary = "이메일 인증 μ½”λ“œλ₯Ό λ³΄λ‚΄λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse sendEmail( + @RequestBody @Valid EmailAddressDto emailDto, + @CurrentMemberId Long userId + ) { + + emailService.sendEmail(emailDto.email()); + + return ApiResponse.response(OK, EMAIL_SEND_SUCCESS.getMessage()); + } + + @PatchMapping("/code/mail") + @Operation(summary = "μ‚¬μš©μžκ°€ μž…λ ₯ν•œ 인증 μ½”λ“œλ₯Ό 검증 ν›„ 이메일 정보λ₯Ό μ—…λ°μ΄νŠΈν•˜λŠ” API μž…λ‹ˆλ‹€.") + public ApiResponse checkAuthCodeAndUpdateEmail( + @RequestBody @Valid InactiveMemberAuthCodeRequestDto dto, + @CurrentMemberId Long userId + ) { + + emailService.checkEmailAuthCode(dto.email(), dto.authCode()); + memberService.updateMemberEmail(dto.email(), userId); + + return ApiResponse.response(OK, EMAIL_AUTHENTICATION_SUCESS.getMessage()); + } + + @PatchMapping("/agreement") + @Operation(summary = "μ•½κ΄€ λ™μ˜ 정보λ₯Ό μ—…λ°μ΄νŠΈν•˜λŠ” API μž…λ‹ˆλ‹€.") + public ApiResponse updateUserAgreement( + @RequestBody MemberAgreementRequestDto dto, + @CurrentMemberId Long userId + ){ + memberService.updateMemberAgreement(dto, userId); + return ApiResponse.response(OK, AGREEEMENT_UPDATE_SUCCESS.getMessage()); + } + + @PatchMapping("/supplement") + @Operation(summary = "μ‚¬μš©μž μΆ”κ°€ 정보 μ—…λ°μ΄νŠΈν•˜λŠ” API μž…λ‹ˆλ‹€. (ν”„λ‘œν•„, λ‹‰λ„€μž„, μ‹€λͺ…, ν•™λ²ˆ, 성별,)") + public ApiResponse updateMemberSupplement( + @RequestBody MemberSupplmentRequestDto dto, + @CurrentMemberId Long userId, + HttpServletResponse response + ){ + MemberResponseDto memberDto = memberService.updateMemberSupplement(dto, userId); + JwtTokenDto jwtTokenDto = jwtService + .generateJwtToken(MemberTokenDto.from(memberDto)); + responseToken(jwtTokenDto, response); + + return ApiResponse.response( + OK, + SUPPLEMENT_UPDATE_SUCCESS.getMessage(), + MemberLoginResponseDto.from(memberDto) + ); + } + + /* + * refactoring + * */ + + private void responseToken(JwtTokenDto jwtTokenDto, HttpServletResponse response) { + response.setHeader(ACCESS_TOKEN_SUBJECT, jwtTokenDto.accessToken()); + cookieUtil.setCookie(REFRESH_TOKEN_SUBJECT, jwtTokenDto.refreshToken(), response); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/controller/BlacklistController.java b/src/main/java/com/gachtaxi/domain/members/controller/BlacklistController.java new file mode 100644 index 00000000..84c3e26c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/BlacklistController.java @@ -0,0 +1,65 @@ +package com.gachtaxi.domain.members.controller; + +import static com.gachtaxi.domain.members.controller.ResponseMessage.BLACKLIST_DELETE_SUCCESS; +import static com.gachtaxi.domain.members.controller.ResponseMessage.BLACKLIST_FIND_ALL_SUCCESS; +import static com.gachtaxi.domain.members.controller.ResponseMessage.BLACKLIST_SAVE_SUCCESS; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +import com.gachtaxi.domain.members.dto.response.BlacklistGetResponse; +import com.gachtaxi.domain.members.dto.response.BlacklistPostResponse; +import com.gachtaxi.domain.members.service.BlacklistService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "BLACKLIST") +@RestController +@RequestMapping("/api/blacklists") +@RequiredArgsConstructor +public class BlacklistController { + + private final BlacklistService blacklistService; + + @Operation(summary = "λΈ”λž™λ¦¬μŠ€νŠΈ 등둝 API") + @PostMapping + public ApiResponse postBlacklist( + @CurrentMemberId Long memberId, + @RequestParam Long receiverId) { + + return ApiResponse.response(CREATED, BLACKLIST_SAVE_SUCCESS.getMessage(), + this.blacklistService.save(memberId, receiverId)); + } + + @Operation(summary = "λΈ”λž™λ¦¬μŠ€νŠΈ μ‚­μ œ API") + @DeleteMapping("/{receiverId}") + public ApiResponse deleteBlacklist( + @CurrentMemberId Long memberId, + @PathVariable Long receiverId) { + this.blacklistService.delete(memberId, receiverId); + + return ApiResponse.response(OK, BLACKLIST_DELETE_SUCCESS.getMessage()); + } + + @Operation(summary = "λΈ”λž™λ¦¬μŠ€νŠΈ 쑰회 API") + @GetMapping + public ApiResponse getAllBlacklist( + @CurrentMemberId Long requesterId, + @RequestParam int pageNum, + @RequestParam int pageSize + ) { + BlacklistGetResponse blacklistPage = this.blacklistService.findBlacklistPage(requesterId, + pageNum, pageSize); + + return ApiResponse.response(OK, BLACKLIST_FIND_ALL_SUCCESS.getMessage(), blacklistPage); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/controller/MemberController.java b/src/main/java/com/gachtaxi/domain/members/controller/MemberController.java new file mode 100644 index 00000000..c29107af --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/MemberController.java @@ -0,0 +1,59 @@ +package com.gachtaxi.domain.members.controller; + +import com.gachtaxi.domain.members.dto.request.FcmTokenRequest; +import com.gachtaxi.domain.members.dto.request.MemberInfoRequestDto; +import com.gachtaxi.domain.members.dto.response.MemberResponseDto; +import com.gachtaxi.domain.members.service.MemberDeleteService; +import com.gachtaxi.domain.members.service.MemberService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.gachtaxi.domain.members.controller.ResponseMessage.*; +import static org.springframework.http.HttpStatus.OK; + +@Tag(name = "MEMBER") +@RequestMapping("/api/members") +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + private final MemberDeleteService memberDeleteService; + + @PatchMapping("/firebase") + @Operation(summary = "fcm 토큰을 μ €μž₯ν•˜κΈ° μœ„ν•œ APIμž…λ‹ˆλ‹€. 맀 둜그인 ν˜Ήμ€ 토큰 λ¦¬ν”„λ ˆμ‹œκ°€ λ°œμƒν•  λ•Œ μ €μž₯ν•΄μ£Όμ„Έμš”") + public ApiResponse save(@CurrentMemberId Long memberId, + @RequestBody @Valid FcmTokenRequest request) { + memberService.updateFcmToken(memberId, request); + + return ApiResponse.response(OK, FCM_TOKEN_UPDATE_SUCCESS.getMessage()); + } + + @GetMapping("/info") + public ApiResponse memberInfoDetails(@CurrentMemberId Long currentId) { + MemberResponseDto response = memberService.getMember(currentId); + return ApiResponse.response(OK, MEMBER_INFO_RESPONSE.getMessage(), response); + } + + @PatchMapping("/info") + public ApiResponse memberInfoModify( + @CurrentMemberId Long currentId, + @RequestBody MemberInfoRequestDto dto + ) { + MemberResponseDto response = memberService.updateMemberInfo(currentId, dto); + return ApiResponse.response(OK, MEMBER_INFO_UPDATE.getMessage(), response); + } + + @DeleteMapping + @Operation(summary = "νšŒμ› νƒˆν‡΄ APIμž…λ‹ˆλ‹€. κ΄€λ ¨ 정보가 λͺ¨λ‘ μ‚­μ œλ©λ‹ˆλ‹€.") + public ApiResponse delete(@CurrentMemberId Long currentId) { + memberDeleteService.softDelete(currentId); + + return ApiResponse.response(OK, MEMBER_DELETE_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/domain/members/controller/ResponseMessage.java new file mode 100644 index 00000000..05b68984 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/controller/ResponseMessage.java @@ -0,0 +1,32 @@ +package com.gachtaxi.domain.members.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + // MemberController + REGISTER_SUCCESS("νšŒμ›κ°€μž…μ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + MEMBER_INFO_UPDATE("μœ μ € 정보λ₯Ό μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€!"), + MEMBER_INFO_RESPONSE("μœ μ € 정보λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€."), + FCM_TOKEN_UPDATE_SUCCESS("FCM 토큰 μ—…λ°μ΄νŠΈμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + MEMBER_DELETE_SUCCESS("νšŒμ› νƒˆν‡΄μ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + + // AuthController + ALREADY_SIGN_UP("이미 κ°€μž…λœ 이메일 μž…λ‹ˆλ‹€!"), + REFRESH_TOKEN_REISSUE("토큰 μž¬λ°œκΈ‰μ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + LOGIN_SUCCESS("둜그인 성곡에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + UN_REGISTER("νšŒμ›κ°€μž…μ„ μ§„ν–‰ν•΄μ£Όμ„Έμš”"), + + // BlacklistController + BLACKLIST_SAVE_SUCCESS("λΈ”λž™λ¦¬μŠ€νŠΈ 등둝에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + BLACKLIST_DELETE_SUCCESS("λΈ”λž™λ¦¬μŠ€νŠΈ μ‚­μ œμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + BLACKLIST_FIND_ALL_SUCCESS("λΈ”λž™λ¦¬μŠ€νŠΈ μ‘°νšŒμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + + // AccountController + ACCOUNT_GET_SUCCESS("κ³„μ’Œ 정보λ₯Ό μ„±κ³΅μ μœΌλ‘œ κ°€μ Έμ™”μŠ΅λ‹ˆλ‹€."), + ACCOUNT_UPDATE_SUCCESS("κ³„μ’Œ 정보λ₯Ό μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/AccountPostRequest.java b/src/main/java/com/gachtaxi/domain/members/dto/request/AccountPostRequest.java new file mode 100644 index 00000000..36ea2628 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/AccountPostRequest.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.members.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AccountPostRequest( + @NotBlank + String accountNumber +) { + +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/FcmTokenRequest.java b/src/main/java/com/gachtaxi/domain/members/dto/request/FcmTokenRequest.java new file mode 100644 index 00000000..a36ce098 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/FcmTokenRequest.java @@ -0,0 +1,8 @@ +package com.gachtaxi.domain.members.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record FcmTokenRequest( + @NotBlank String fcmToken +) { +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberAuthCodeRequestDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberAuthCodeRequestDto.java new file mode 100644 index 00000000..c024b74c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberAuthCodeRequestDto.java @@ -0,0 +1,11 @@ +package com.gachtaxi.domain.members.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record InactiveMemberAuthCodeRequestDto( + @NotBlank @Email(message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") + String email, + @NotBlank + String authCode +) { } diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberDto.java new file mode 100644 index 00000000..c1aa1137 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/InactiveMemberDto.java @@ -0,0 +1,20 @@ +package com.gachtaxi.domain.members.dto.request; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.entity.enums.Role; +import lombok.Builder; + +@Builder +public record InactiveMemberDto( + Long userId, + String email, + Role role +) { + public static InactiveMemberDto of(Members tmpMember) { + return InactiveMemberDto.builder() + .userId(tmpMember.getId()) + .email(tmpMember.getEmail()) + .role(tmpMember.getRole()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/MemberAgreementRequestDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberAgreementRequestDto.java new file mode 100644 index 00000000..9e577b06 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberAgreementRequestDto.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.members.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record MemberAgreementRequestDto( + @NotNull boolean termsAgreement, + @NotNull boolean privacyAgreement, + @NotNull boolean marketingAgreement +) { +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/MemberInfoRequestDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberInfoRequestDto.java new file mode 100644 index 00000000..d0bea53f --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberInfoRequestDto.java @@ -0,0 +1,9 @@ +package com.gachtaxi.domain.members.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record MemberInfoRequestDto( + @NotNull String profilePicture, + @NotNull String nickName +) { +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/MemberSupplmentRequestDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberSupplmentRequestDto.java new file mode 100644 index 00000000..3d4736d3 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberSupplmentRequestDto.java @@ -0,0 +1,14 @@ +package com.gachtaxi.domain.members.dto.request; + +import com.gachtaxi.domain.members.entity.enums.Gender; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MemberSupplmentRequestDto( + String profilePicture, + @NotBlank String nickname, + @NotBlank String realName, + @NotNull Long studentNumber, + @NotNull Gender gender +) { +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/MemberTokenDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberTokenDto.java new file mode 100644 index 00000000..b8822ec4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/MemberTokenDto.java @@ -0,0 +1,36 @@ +package com.gachtaxi.domain.members.dto.request; + +import com.gachtaxi.domain.members.dto.response.MemberResponseDto; +import com.gachtaxi.domain.members.entity.Members; +import lombok.Builder; + +@Builder +public record MemberTokenDto( + Long id, + String email, + String role +){ + public static MemberTokenDto from(Members members){ + return MemberTokenDto.builder() + .id(members.getId()) + .email(members.getEmail()) + .role(members.getRole().name()) + .build(); + } + + public static MemberTokenDto of(Long id, String email, String role){ + return MemberTokenDto.builder() + .id(id) + .email(email) + .role(role) + .build(); + } + + public static MemberTokenDto from(MemberResponseDto dto){ + return MemberTokenDto.builder() + .id(dto.userId()) + .email(dto.email()) + .role(dto.role()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/request/UserSignUpRequestDto.java b/src/main/java/com/gachtaxi/domain/members/dto/request/UserSignUpRequestDto.java new file mode 100644 index 00000000..22cb6d8d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/request/UserSignUpRequestDto.java @@ -0,0 +1,20 @@ +package com.gachtaxi.domain.members.dto.request; + + +import com.gachtaxi.domain.members.entity.enums.Gender; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record UserSignUpRequestDto( + @NotBlank String email, + @NotBlank String nickName, + @NotBlank String realName, + @NotNull Long studentNumber, + @NotNull Gender gender, + @NotNull Boolean termsAgreement, + @NotNull Boolean privacyAgreement, + @NotNull Boolean marketingAgreement, + @NotNull Boolean twoFactorAuthentication, + Long kakaoId, + Long googleId +){} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/AccountGetResponse.java b/src/main/java/com/gachtaxi/domain/members/dto/response/AccountGetResponse.java new file mode 100644 index 00000000..c3267825 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/AccountGetResponse.java @@ -0,0 +1,32 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.entity.Members; +import lombok.Builder; + +@Builder +public record AccountGetResponse( + Long userId, + Long studentNumber, + String nickName, + String realName, + String profilePicture, + String email, + String role, + String gender, + String accountNumber +) { + + public static AccountGetResponse of(Members members) { + return AccountGetResponse.builder() + .userId(members.getId()) + .studentNumber(members.getStudentNumber()) + .nickName(members.getNickname()) + .realName(members.getRealName()) + .profilePicture(members.getProfilePicture()) + .email(members.getEmail()) + .role(members.getRole().name()) + .gender(members.getGender().name()) + .accountNumber(members.getAccountNumber()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/AccountPostResponse.java b/src/main/java/com/gachtaxi/domain/members/dto/response/AccountPostResponse.java new file mode 100644 index 00000000..cce58df5 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/AccountPostResponse.java @@ -0,0 +1,32 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.entity.Members; +import lombok.Builder; + +@Builder +public record AccountPostResponse( + Long userId, + Long studentNumber, + String nickName, + String realName, + String profilePicture, + String email, + String role, + String gender, + String accountNumber +) { + + public static AccountPostResponse of(Members members) { + return AccountPostResponse.builder() + .userId(members.getId()) + .studentNumber(members.getStudentNumber()) + .nickName(members.getNickname()) + .realName(members.getRealName()) + .profilePicture(members.getProfilePicture()) + .email(members.getEmail()) + .role(members.getRole().name()) + .gender(members.getGender().name()) + .accountNumber(members.getAccountNumber()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistGetResponse.java b/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistGetResponse.java new file mode 100644 index 00000000..6c88d37a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistGetResponse.java @@ -0,0 +1,57 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.entity.Blacklists; +import java.util.List; +import lombok.Builder; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +@Builder +public record BlacklistGetResponse( + List blacklists, + BlacklistPageable pageable +) { + public static BlacklistGetResponse of(Slice blacklistsPage) { + List responseList = blacklistsPage.stream() + .map(BlacklistInfo::of) + .toList(); + + return BlacklistGetResponse.builder() + .blacklists(responseList) + .pageable(BlacklistPageable.of(blacklistsPage)) + .build(); + } + + record BlacklistInfo( + Long receiverId, + String receiverNickname, + String receiverProfilePicture, + String gender + ) { + public static BlacklistInfo of(Blacklists blacklists) { + return new BlacklistInfo( + blacklists.getReceiver().getId(), + blacklists.getReceiver().getNickname(), + blacklists.getReceiver().getGender().name(), + blacklists.getReceiverProfilePicture() + ); + } + } + + record BlacklistPageable( + Integer pageNumber, + Integer pageSize, + Integer numberOfElements, + Boolean last + ) { + + public static BlacklistPageable of (Slice blacklistsPage) { + return new BlacklistPageable( + blacklistsPage.getNumber(), + blacklistsPage.getSize(), + blacklistsPage.getNumberOfElements(), + blacklistsPage.isLast() + ); + } + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistPostResponse.java b/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistPostResponse.java new file mode 100644 index 00000000..dc9c3d78 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/BlacklistPostResponse.java @@ -0,0 +1,17 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.entity.Blacklists; + +public record BlacklistPostResponse( + Long requesterId, + Long receiverId, + Long blacklistId +) { + public static BlacklistPostResponse of (Blacklists blacklists) { + return new BlacklistPostResponse( + blacklists.getRequester().getId(), + blacklists.getReceiver().getId(), + blacklists.getId() + ); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/InactiveMemberResponseDto.java b/src/main/java/com/gachtaxi/domain/members/dto/response/InactiveMemberResponseDto.java new file mode 100644 index 00000000..f0ffa988 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/InactiveMemberResponseDto.java @@ -0,0 +1,15 @@ +package com.gachtaxi.domain.members.dto.response; + +import lombok.Builder; + +@Builder +public record InactiveMemberResponseDto( + Long userId +) { + public static InactiveMemberResponseDto from(Long userId) { + return InactiveMemberResponseDto.builder() + .userId(userId) + .build(); + } + +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/LoginDto.java b/src/main/java/com/gachtaxi/domain/members/dto/response/LoginDto.java new file mode 100644 index 00000000..3acc10e5 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/LoginDto.java @@ -0,0 +1,27 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.global.auth.jwt.dto.JwtTokenDto; +import lombok.Builder; + +@Builder +public record LoginDto( + JwtTokenDto jwtTokenDto, + MemberResponseDto memberResponseDto +) { + public static LoginDto of(JwtTokenDto jwtTokenDto, MemberResponseDto memberResponseDto) { + return LoginDto.builder() + .jwtTokenDto(jwtTokenDto) + .memberResponseDto(memberResponseDto) + .build(); + } + + public static LoginDto from(JwtTokenDto jwtTokenDto) { + return LoginDto.builder() + .jwtTokenDto(jwtTokenDto) + .build(); + } + + public boolean isTemporaryUser(){ + return this.memberResponseDto == null; + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/MemberLoginResponseDto.java b/src/main/java/com/gachtaxi/domain/members/dto/response/MemberLoginResponseDto.java new file mode 100644 index 00000000..53b7c759 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/MemberLoginResponseDto.java @@ -0,0 +1,24 @@ +package com.gachtaxi.domain.members.dto.response; + +import lombok.Builder; + +import static com.gachtaxi.domain.members.controller.ResponseMessage.*; + +@Builder +public record MemberLoginResponseDto( + String status, + MemberResponseDto memberResponseDto +) { + public static MemberLoginResponseDto from(MemberResponseDto memberResponseDto) { + return MemberLoginResponseDto.builder() + .status(LOGIN_SUCCESS.name()) + .memberResponseDto(memberResponseDto) + .build(); + } + + public static MemberLoginResponseDto from() { + return MemberLoginResponseDto.builder() + .status(UN_REGISTER.name()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/MemberResponseDto.java b/src/main/java/com/gachtaxi/domain/members/dto/response/MemberResponseDto.java new file mode 100644 index 00000000..e63beb90 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/MemberResponseDto.java @@ -0,0 +1,32 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.entity.enums.Gender; +import lombok.Builder; + +@Builder +public record MemberResponseDto( + Long userId, + Long studentNumber, + String nickName, + String realName, + String profilePicture, + String email, + String role, + Gender gender, + String accountNumber +) { + public static MemberResponseDto from(Members members) { + return MemberResponseDto.builder() + .userId(members.getId()) + .studentNumber(members.getStudentNumber()) + .nickName(members.getNickname()) + .realName(members.getRealName()) + .profilePicture(members.getProfilePicture()) + .email(members.getEmail()) + .role(members.getRole().name()) + .gender(members.getGender()) + .accountNumber(members.getAccountNumber()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/dto/response/OauthResponse.java b/src/main/java/com/gachtaxi/domain/members/dto/response/OauthResponse.java new file mode 100644 index 00000000..10d49f45 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/dto/response/OauthResponse.java @@ -0,0 +1,11 @@ +package com.gachtaxi.domain.members.dto.response; + +import com.gachtaxi.domain.members.controller.ResponseMessage; + +public record OauthResponse( + String status +) { + public static OauthResponse from(ResponseMessage responseMessage) { + return new OauthResponse(responseMessage.name()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/entity/Blacklists.java b/src/main/java/com/gachtaxi/domain/members/entity/Blacklists.java new file mode 100644 index 00000000..ade33cec --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/entity/Blacklists.java @@ -0,0 +1,41 @@ +package com.gachtaxi.domain.members.entity; + +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@Entity +@SuperBuilder +@Table(name="blacklists", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "blacklist_member_id"}) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Blacklists extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + private Members requester; + + @ManyToOne(fetch = FetchType.LAZY) + private Members receiver; + + public String getReceiverProfilePicture() { + return receiver.getProfilePicture(); + } + + public static Blacklists create(Members requester, Members receiver) { + return Blacklists.builder() + .requester(requester) + .receiver(receiver) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/entity/Members.java b/src/main/java/com/gachtaxi/domain/members/entity/Members.java new file mode 100644 index 00000000..87b5e330 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/entity/Members.java @@ -0,0 +1,183 @@ +package com.gachtaxi.domain.members.entity; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.members.dto.request.MemberAgreementRequestDto; +import com.gachtaxi.domain.members.dto.request.MemberInfoRequestDto; +import com.gachtaxi.domain.members.dto.request.MemberSupplmentRequestDto; +import com.gachtaxi.domain.members.entity.enums.Gender; +import com.gachtaxi.domain.members.entity.enums.Role; +import com.gachtaxi.domain.members.entity.enums.UserStatus; +import com.gachtaxi.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.ColumnDefault; + +import java.util.Objects; + +@Getter +@Entity +@SuperBuilder +@Table(name="members") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Members extends BaseEntity { + + @Column(name = "email", unique = true) + private String email; + + @Column(name = "profile_picture") + private String profilePicture; + + @Column(name = "nickname", unique = true) + private String nickname; + + @Column(name = "real_name") + private String realName; + + @Column(name = "student_number", unique = true) + private Long studentNumber; + + @Column(name = "phone_number", unique = true) // ν”Όκ·Έλ§ˆ μ°Έκ³ , 일단 null ν—ˆμš© + private String phoneNumber; + + @Column(name = "account_number", unique = true) + @Setter + private String accountNumber; + + @Column(name = "kakao_id", unique = true) + private Long kakaoId; + + @Column(name = "google_id", unique = true) + private String googleId; + + @Enumerated(EnumType.STRING) + @Builder.Default + private Role role = Role.MEMBER; + + @Enumerated(EnumType.STRING) + private Gender gender; + + @Enumerated(EnumType.STRING) + @Builder.Default + private UserStatus status = UserStatus.INACTIVE; + + // 이용 μ•½κ΄€ λ™μ˜ + @Column(name = "terms_agreement") + @ColumnDefault("false") + @Builder.Default + private Boolean termsAgreement = false; + + // κ°œμΈμ •λ³΄ μˆ˜μ§‘ λ™μ˜ + @Column(name = "privacy_agreement") + @ColumnDefault("false") + @Builder.Default + private Boolean privacyAgreement = false; + + // κ΄‘κ³ μ„± 정보 μˆ˜μ‹  λ™μ˜ + @Column(name = "marketing_agreement") + @ColumnDefault("false") + @Builder.Default + private Boolean marketingAgreement = false; + + // 2μ°¨ 인증 (μ „ν™”λ²ˆν˜Έ) + @Column(name = "two_factor_authentication") + @ColumnDefault("false") + @Builder.Default + private Boolean twoFactorAuthentication = false; + + @Column(name = "fcm_token") + private String fcmToken; + + /* + * μΆ”κ°€ν•  사항 + * blackList + * notification + * friend_info + * */ + + public boolean hasKakaoId(){ + return kakaoId != null; + } + + public boolean hasGoogleId(){ + return googleId != null; + } + + public void updateEmail(String email) { + this.email = email; + } + + public void updateKakaoId(Long kakaoId) { + this.kakaoId = kakaoId; + } + + public void updateGoogleId(String googleId) { + this.googleId = googleId; + } + + public void updateMemberInfo(MemberInfoRequestDto dto) { + this.profilePicture = dto.profilePicture(); + this.nickname = dto.nickName(); + } + + public void updateAgreement(MemberAgreementRequestDto dto) { + this.termsAgreement = dto.termsAgreement(); + this.privacyAgreement = dto.privacyAgreement(); + this.marketingAgreement = dto.marketingAgreement(); + } + + public void updateToken(String fcmToken) { + this.fcmToken = fcmToken; + } + + public void updateSupplment(MemberSupplmentRequestDto dto) { + this.profilePicture = dto.profilePicture(); + this.nickname = dto.nickname(); + this.realName = dto.realName(); + this.studentNumber = dto.studentNumber(); + this.gender = dto.gender(); + this.status = UserStatus.ACTIVE; + } + + public static Members ofKakaoId(Long kakaoId){ + return Members.builder() + .kakaoId(kakaoId) + .build(); + } + + public boolean isRoomMaster(MatchingRoom matchingRoom){ + return this.equals(matchingRoom.getRoomMaster()); + } + + public void delete() { + this.status = UserStatus.DELETED; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Members members = (Members) o; + return Objects.equals(studentNumber, members.studentNumber) && Objects.equals( + kakaoId, members.kakaoId); + } + + @Override + public int hashCode() { + return Objects.hash(studentNumber, kakaoId); + } + + public static Members ofGoogleId(String googleId){ + return Members.builder() + .googleId(googleId) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/entity/enums/Gender.java b/src/main/java/com/gachtaxi/domain/members/entity/enums/Gender.java new file mode 100644 index 00000000..ce48e9c6 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/entity/enums/Gender.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.members.entity.enums; + +public enum Gender { + MALE, FEMALE +} diff --git a/src/main/java/com/gachtaxi/domain/members/entity/enums/Role.java b/src/main/java/com/gachtaxi/domain/members/entity/enums/Role.java new file mode 100644 index 00000000..671beb79 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/entity/enums/Role.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.members.entity.enums; + +public enum Role { + MEMBER, ADMIN +} diff --git a/src/main/java/com/gachtaxi/domain/members/entity/enums/UserStatus.java b/src/main/java/com/gachtaxi/domain/members/entity/enums/UserStatus.java new file mode 100644 index 00000000..2b7a5070 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/entity/enums/UserStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.members.entity.enums; + +public enum UserStatus { + ACTIVE, INACTIVE, DELETED +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/BlacklistAlreadyExistsException.java b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistAlreadyExistsException.java new file mode 100644 index 00000000..030a8bbf --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistAlreadyExistsException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.members.exception; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.BLACKLIST_ALREADY_EXISTS; +import static org.springframework.http.HttpStatus.CONFLICT; + +import com.gachtaxi.global.common.exception.BaseException; + +public class BlacklistAlreadyExistsException extends BaseException { + + public BlacklistAlreadyExistsException() { + super(CONFLICT, BLACKLIST_ALREADY_EXISTS.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/BlacklistNotFoundException.java b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistNotFoundException.java new file mode 100644 index 00000000..fa103615 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistNotFoundException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.members.exception; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.BLACKLIST_NOT_FOUND; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import com.gachtaxi.global.common.exception.BaseException; + +public class BlacklistNotFoundException extends BaseException { + + public BlacklistNotFoundException() { + super(NOT_FOUND, BLACKLIST_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/BlacklistRequesterEqualsReceiverException.java b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistRequesterEqualsReceiverException.java new file mode 100644 index 00000000..c58d44c4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistRequesterEqualsReceiverException.java @@ -0,0 +1,14 @@ +package com.gachtaxi.domain.members.exception; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.BLACKLIST_REQUESTER_EQUALS_RECEIVER; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import com.gachtaxi.global.common.exception.BaseException; + +public class BlacklistRequesterEqualsReceiverException extends BaseException { + + public BlacklistRequesterEqualsReceiverException() { + super(BAD_REQUEST, BLACKLIST_REQUESTER_EQUALS_RECEIVER.getMessage()); + } + +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/BlacklistedUserCannotJoinException.java b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistedUserCannotJoinException.java new file mode 100644 index 00000000..c6a12815 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/BlacklistedUserCannotJoinException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.BLACKLISTED_USER_CANNOT_JOIN; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +import com.gachtaxi.global.common.exception.BaseException; + +public class BlacklistedUserCannotJoinException extends BaseException { + public BlacklistedUserCannotJoinException() { + super(FORBIDDEN, BLACKLISTED_USER_CANNOT_JOIN.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedEmailException.java b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedEmailException.java new file mode 100644 index 00000000..ce6dff3c --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedEmailException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.DUPLICATED_EMAIL; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class DuplicatedEmailException extends BaseException { + public DuplicatedEmailException() { + super(BAD_REQUEST, DUPLICATED_EMAIL.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedNickNameException.java b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedNickNameException.java new file mode 100644 index 00000000..f7c294f8 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedNickNameException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.DUPLICATED_NICKNAME; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class DuplicatedNickNameException extends BaseException { + public DuplicatedNickNameException() { + super(BAD_REQUEST, DUPLICATED_NICKNAME.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedStudentNumberException.java b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedStudentNumberException.java new file mode 100644 index 00000000..5b66e79d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/DuplicatedStudentNumberException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.DUPLICATED_STUDENT_NUMBER; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class DuplicatedStudentNumberException extends BaseException { + public DuplicatedStudentNumberException() { + super(BAD_REQUEST, DUPLICATED_STUDENT_NUMBER.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/EmailFormInvalidException.java b/src/main/java/com/gachtaxi/domain/members/exception/EmailFormInvalidException.java new file mode 100644 index 00000000..e79e02c0 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/EmailFormInvalidException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.EMAIL_FROM_INVALID; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class EmailFormInvalidException extends BaseException { + public EmailFormInvalidException() { + super(BAD_REQUEST, EMAIL_FROM_INVALID.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/ErrorMessage.java b/src/main/java/com/gachtaxi/domain/members/exception/ErrorMessage.java new file mode 100644 index 00000000..51432554 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/ErrorMessage.java @@ -0,0 +1,24 @@ +package com.gachtaxi.domain.members.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + // Member + DUPLICATED_NICKNAME("μ€‘λ³΅λ˜λŠ” λ‹‰λ„€μž„μž…λ‹ˆλ‹€."), + DUPLICATED_STUDENT_NUMBER("이미 κ°€μž…λœ ν•™λ²ˆμž…λ‹ˆλ‹€."), + MEMBER_NOT_FOUND("νšŒμ›μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + DUPLICATED_EMAIL("이미 κ°€μž…λœ μ΄λ©”μΌμ΄μ—μš”!"), + EMAIL_FROM_INVALID("κ°€μ²œλŒ€ν•™κ΅ 이메일이 μ•„λ‹ˆμ—μš”!"), + MEMBER_ID_NOT_FOUND("ν† ν°μ—μ„œ νšŒμ› idλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + + // Blacklist + BLACKLIST_ALREADY_EXISTS("이미 λ“±λ‘λœ λΈ”λž™λ¦¬μŠ€νŠΈμž…λ‹ˆλ‹€."), + BLACKLIST_NOT_FOUND("λΈ”λž™λ¦¬μŠ€νŠΈλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + BLACKLIST_REQUESTER_EQUALS_RECEIVER("본인을 λΈ”λž™λ¦¬μŠ€νŠΈμ— μΆ”κ°€ν•  수 μ—†μŠ΅λ‹ˆλ‹€."), + BLACKLISTED_USER_CANNOT_JOIN("λΈ”λž™λ¦¬μŠ€νŠΈλ‘œ λ“±λ‘λœ μ‚¬μš©μžμ˜ 방은 μž…μž₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/MemberIdNotFoundException.java b/src/main/java/com/gachtaxi/domain/members/exception/MemberIdNotFoundException.java new file mode 100644 index 00000000..3808ed39 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/MemberIdNotFoundException.java @@ -0,0 +1,16 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class MemberIdNotFoundException extends BaseException { + public MemberIdNotFoundException() { + super(NOT_FOUND, MEMBER_ID_NOT_FOUND.getMessage()); + } + + public static void throwException() { + throw new MemberIdNotFoundException(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/exception/MemberNotFoundException.java b/src/main/java/com/gachtaxi/domain/members/exception/MemberNotFoundException.java new file mode 100644 index 00000000..dcee1eb3 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/exception/MemberNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.members.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.members.exception.ErrorMessage.MEMBER_NOT_FOUND; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class MemberNotFoundException extends BaseException { + public MemberNotFoundException() { + super(BAD_REQUEST, MEMBER_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/repository/BlacklistsRepository.java b/src/main/java/com/gachtaxi/domain/members/repository/BlacklistsRepository.java new file mode 100644 index 00000000..85474d97 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/repository/BlacklistsRepository.java @@ -0,0 +1,18 @@ +package com.gachtaxi.domain.members.repository; + +import com.gachtaxi.domain.members.entity.Blacklists; +import com.gachtaxi.domain.members.entity.Members; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BlacklistsRepository extends JpaRepository { + + Optional findByRequesterAndReceiver(Members requester, Members receiver); + boolean existsByRequesterAndReceiver(Members requester, Members receiver); + Slice findAllByRequester(Members requester, Pageable pageable); +} diff --git a/src/main/java/com/gachtaxi/domain/members/repository/MemberRepository.java b/src/main/java/com/gachtaxi/domain/members/repository/MemberRepository.java new file mode 100644 index 00000000..f6e82124 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/repository/MemberRepository.java @@ -0,0 +1,31 @@ +package com.gachtaxi.domain.members.repository; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.entity.enums.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findByIdAndStatus(Long id, UserStatus status); + + Optional findByStudentNumber(Long studentNumber); + + Optional findByKakaoId(Long kakaoId); + + Optional findByGoogleId(String googleId); + + Optional findByNickname(String nickname); + + Optional findByNicknameAndStatus(String nickname, UserStatus status); + + Optional findByEmailAndStatus(String email, UserStatus status); + + List findByNicknameIn(List nicknames); +} diff --git a/src/main/java/com/gachtaxi/domain/members/service/AccountService.java b/src/main/java/com/gachtaxi/domain/members/service/AccountService.java new file mode 100644 index 00000000..05adf170 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/service/AccountService.java @@ -0,0 +1,35 @@ +package com.gachtaxi.domain.members.service; + +import com.gachtaxi.domain.members.dto.response.AccountGetResponse; +import com.gachtaxi.domain.members.dto.response.AccountPostResponse; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.repository.MemberRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.checkerframework.checker.units.qual.A; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AccountService { + + private final MemberService memberService; + + private final MemberRepository memberRepository; + + public AccountGetResponse getAccount(Long memberId) { + Members members = this.memberService.findById(memberId); + + return AccountGetResponse.of(members); + } + + @Transactional + public AccountPostResponse updateAccount(Long memberId, String accountNumber) { + Members member = this.memberService.findById(memberId); + + member.setAccountNumber(accountNumber); + this.memberRepository.save(member); + + return AccountPostResponse.of(member); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/service/AuthService.java b/src/main/java/com/gachtaxi/domain/members/service/AuthService.java new file mode 100644 index 00000000..8eb341fb --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/service/AuthService.java @@ -0,0 +1,96 @@ +package com.gachtaxi.domain.members.service; + +import com.gachtaxi.domain.members.dto.request.InactiveMemberDto; +import com.gachtaxi.domain.members.dto.request.MemberTokenDto; +import com.gachtaxi.domain.members.dto.response.LoginDto; +import com.gachtaxi.domain.members.dto.response.MemberResponseDto; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.global.auth.google.dto.GoogleTokenResponse; +import com.gachtaxi.global.auth.google.dto.GoogleUserInfoResponse; +import com.gachtaxi.global.auth.google.utils.GoogleUtils; +import com.gachtaxi.global.auth.jwt.service.JwtService; +import com.gachtaxi.global.auth.kakao.util.KakaoUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +import static com.gachtaxi.domain.members.entity.enums.UserStatus.INACTIVE; +import static com.gachtaxi.global.auth.kakao.dto.KaKaoDTO.KakaoAccessToken; +import static com.gachtaxi.global.auth.kakao.dto.KaKaoDTO.KakaoUserInfoResponse; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final KakaoUtil kakaoUtil; + private final GoogleUtils googleUtils; + private final JwtService jwtService; + private final MemberService memberService; + + public LoginDto kakaoLogin(String authCode) { + KakaoUserInfoResponse userInfo = getKakaoUserInfoResponse(authCode); + Long kakaoId = userInfo.id(); + + Optional optionalMember = memberService.findByKakaoId(kakaoId); + if(optionalMember.isEmpty()) { + return LoginDto.from( + jwtService.generateTmpAccessToken(memberService.saveTmpKakaoMember(kakaoId)) + ); + } + + Members members = optionalMember.get(); + if(members.getStatus() == INACTIVE){ + return LoginDto.from( + jwtService.generateTmpAccessToken(InactiveMemberDto.of(optionalMember.get())) + ); + } + + return LoginDto.of( + jwtService.generateJwtToken(MemberTokenDto.from(members)), + MemberResponseDto.from(members) + ); + } + + public LoginDto googleLogin(String authCode) { + GoogleUserInfoResponse userInfo = getGoogleUserInfoResponse(authCode); + String googleId = userInfo.id(); + + Optional optionalMember = memberService.findByGoogleId(googleId); + if(optionalMember.isEmpty()) { + return LoginDto.from( + jwtService.generateTmpAccessToken(memberService.saveTmpGoogleMember(googleId)) + ); + } + + Members members = optionalMember.get(); + if(members.getStatus() == INACTIVE){ + return LoginDto.from( + jwtService.generateTmpAccessToken(InactiveMemberDto.of(optionalMember.get())) + ); + } + + return LoginDto.of( + jwtService.generateJwtToken(MemberTokenDto.from(members)), + MemberResponseDto.from(members) + ); + } + + + /* + * refactoring + * */ + + private KakaoUserInfoResponse getKakaoUserInfoResponse(String authCode) { + KakaoAccessToken kakaoAccessToken = kakaoUtil.reqeustKakaoToken(authCode); + KakaoUserInfoResponse userInfo = kakaoUtil.requestKakaoProfile(kakaoAccessToken.access_token()); + return userInfo; + } + + private GoogleUserInfoResponse getGoogleUserInfoResponse(String authCode) { + GoogleTokenResponse googleAccessToken = googleUtils.reqeustGoogleToken(authCode); + GoogleUserInfoResponse userInfo = googleUtils.requestGoogleProfile(googleAccessToken.access_token()); + return userInfo; + } + +} diff --git a/src/main/java/com/gachtaxi/domain/members/service/BlacklistService.java b/src/main/java/com/gachtaxi/domain/members/service/BlacklistService.java new file mode 100644 index 00000000..a70cc84e --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/service/BlacklistService.java @@ -0,0 +1,85 @@ +package com.gachtaxi.domain.members.service; + +import com.gachtaxi.domain.matching.common.entity.MatchingRoom; +import com.gachtaxi.domain.members.dto.response.BlacklistGetResponse; +import com.gachtaxi.domain.members.dto.response.BlacklistPostResponse; +import com.gachtaxi.domain.members.entity.Blacklists; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.exception.BlacklistAlreadyExistsException; +import com.gachtaxi.domain.members.exception.BlacklistNotFoundException; +import com.gachtaxi.domain.members.exception.BlacklistRequesterEqualsReceiverException; +import com.gachtaxi.domain.members.repository.BlacklistsRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BlacklistService { + + private final BlacklistsRepository blacklistsRepository; + private final MemberService memberService; + + @Transactional + public BlacklistPostResponse save(Long requesterId, Long receiverId) { + Members requester = this.memberService.findById(requesterId); + Members receiver = this.memberService.findById(receiverId); + + this.checkRequesterAndReceiver(requester, receiver); + + if (this.blacklistsRepository.existsByRequesterAndReceiver(requester, receiver)) { + throw new BlacklistAlreadyExistsException(); + } + + Blacklists blacklists = this.blacklistsRepository.save(Blacklists.create(requester, receiver)); + return BlacklistPostResponse.of(blacklists); + } + + @Transactional + public void delete(Long requesterId, Long receiverId) { + Members requester = this.memberService.findById(requesterId); + Members receiver = this.memberService.findById(receiverId); + + this.checkRequesterAndReceiver(requester, receiver); + + Blacklists blacklists = this.blacklistsRepository.findByRequesterAndReceiver(requester, + receiver) + .orElseThrow(BlacklistNotFoundException::new); + + this.blacklistsRepository.delete(blacklists); + } + + public BlacklistGetResponse findBlacklistPage(Long requesterId, int pageNum, int pageSize) { + Members requester = this.memberService.findById(requesterId); + + Pageable pageRequest = PageRequest.of(pageNum, pageSize, Sort.by(Direction.ASC, "receiver.nickname")); + + Slice blacklistsPage = this.blacklistsRepository.findAllByRequester(requester, pageRequest); + + return BlacklistGetResponse.of(blacklistsPage); + } + + public boolean isBlacklistInMatchingRoom(Members requester, MatchingRoom matchingRoom) { + boolean existBlacklist = matchingRoom.getMemberMatchingRoomChargingInfo().stream() + .anyMatch(memberMatchingRoomChargingInfo -> this.blacklistsRepository.existsByRequesterAndReceiver( + requester, memberMatchingRoomChargingInfo.getMembers())); + + return existBlacklist; + } + + private void checkRequesterAndReceiver(Members requester, Members receiver) { + if (requester.equals(receiver)) { + throw new BlacklistRequesterEqualsReceiverException(); + } + } + + public boolean isUserBlacklistedInRoom(Members requester, MatchingRoom matchingRoom) { + return matchingRoom.getMemberMatchingRoomChargingInfo().stream() + .anyMatch(info -> this.blacklistsRepository.existsByRequesterAndReceiver(requester, info.getMembers())); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/service/MemberDeleteService.java b/src/main/java/com/gachtaxi/domain/members/service/MemberDeleteService.java new file mode 100644 index 00000000..24e09287 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/service/MemberDeleteService.java @@ -0,0 +1,26 @@ +package com.gachtaxi.domain.members.service; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.exception.MemberNotFoundException; +import com.gachtaxi.domain.members.repository.MemberRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberDeleteService { + + private final MemberRepository memberRepository; + + /* + todo μ±„νŒ… λ©”μ‹œμ§€ μˆ˜μ • PR 머지 되면 (μ•Œμˆ˜μ—†μŒ)으둜 λ°”κΎΈκ³  프사 μ‚­μ œν•˜κΈ° + */ + @Transactional + public void softDelete(Long memberId) { + Members member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + member.delete(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/members/service/MemberService.java b/src/main/java/com/gachtaxi/domain/members/service/MemberService.java new file mode 100644 index 00000000..4605a030 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/members/service/MemberService.java @@ -0,0 +1,125 @@ +package com.gachtaxi.domain.members.service; + +import com.gachtaxi.domain.chat.repository.ChattingMessageMongoRepository; +import com.gachtaxi.domain.members.dto.request.*; +import com.gachtaxi.domain.members.dto.response.MemberResponseDto; +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.members.exception.DuplicatedNickNameException; +import com.gachtaxi.domain.members.exception.DuplicatedStudentNumberException; +import com.gachtaxi.domain.members.exception.MemberNotFoundException; +import com.gachtaxi.domain.members.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static com.gachtaxi.domain.members.entity.enums.UserStatus.ACTIVE; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final ChattingMessageMongoRepository chattingMessageMongoRepository; + + @Transactional + public InactiveMemberDto saveTmpKakaoMember(Long kakaoId){ + Members tmpMember = Members.ofKakaoId(kakaoId); + memberRepository.save(tmpMember); + return InactiveMemberDto.of(tmpMember); + } + + public MemberResponseDto getMember(Long currentId){ + Members members = findById(currentId); + return MemberResponseDto.from(members); + } + + @Transactional + public MemberResponseDto updateMemberInfo(Long currentId, MemberInfoRequestDto dto){ + Members member = findById(currentId); + member.updateMemberInfo(dto); + + chattingMessageMongoRepository.updateMemberInfo(member); + + return MemberResponseDto.from(member); + } + + @Transactional + public InactiveMemberDto saveTmpGoogleMember(String googleId){ + Members tmpMember = Members.ofGoogleId(googleId); + memberRepository.save(tmpMember); + return InactiveMemberDto.of(tmpMember); + } + + @Transactional + public void updateMemberEmail(String email, Long userId) { + Members members = findTempUserById(userId); + members.updateEmail(email); + } + + @Transactional + public void updateMemberAgreement(MemberAgreementRequestDto dto, Long userId) { + Members members = findTempUserById(userId); + members.updateAgreement(dto); + } + + @Transactional + public MemberResponseDto updateMemberSupplement(MemberSupplmentRequestDto dto, Long userId) { + checkDuplicatedNickName(dto.nickname()); + checkDuplicatedStudentNumber(dto.studentNumber()); + + Members members = findTempUserById(userId); + members.updateSupplment(dto); + + return MemberResponseDto.from(members); + } + + public Optional findByKakaoId(Long kakaoId) {return memberRepository.findByKakaoId(kakaoId);} + + public Optional findByGoogleId(String googleId) {return memberRepository.findByGoogleId(googleId);} + + @Transactional + public void updateFcmToken(Long userId, FcmTokenRequest request) { + Members member = findById(userId); + + member.updateToken(request.fcmToken()); + } + + /* + * refactor + * */ + + public Members findById(Long id) { + return memberRepository.findByIdAndStatus(id, ACTIVE) + .orElseThrow(MemberNotFoundException::new); + } + + public Members findTempUserById(Long id) { + return memberRepository.findById(id) + .orElseThrow(MemberNotFoundException::new); + } + + public Members findActiveByEmail(String email) { + return memberRepository.findByEmailAndStatus(email, ACTIVE) + .orElseThrow(MemberNotFoundException::new); + } + + public Members findByNickname(String nickname) { + return memberRepository.findByNicknameAndStatus(nickname, ACTIVE) + .orElseThrow(MemberNotFoundException::new); + } + + private void checkDuplicatedStudentNumber(Long studentNumber) { + memberRepository.findByStudentNumber(studentNumber).ifPresent(m -> { + throw new DuplicatedStudentNumberException(); + }); + } + + private void checkDuplicatedNickName(String nickName) { + memberRepository.findByNickname(nickName).ifPresent(m -> { + throw new DuplicatedNickNameException(); + }); + } + +} diff --git a/src/main/java/com/gachtaxi/domain/notification/controller/NotificationController.java b/src/main/java/com/gachtaxi/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..a31bb2e0 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/controller/NotificationController.java @@ -0,0 +1,53 @@ +package com.gachtaxi.domain.notification.controller; + +import com.gachtaxi.domain.notification.dto.response.NotificationInfoResponse; +import com.gachtaxi.domain.notification.dto.response.NotificationListResponse; +import com.gachtaxi.domain.notification.service.NotificationService; +import com.gachtaxi.global.auth.jwt.annotation.CurrentMemberId; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.gachtaxi.domain.notification.controller.ResponseMessage.NOTIFICATION_DELETE_SUCCESS; +import static com.gachtaxi.domain.notification.controller.ResponseMessage.NOTIFICATION_GET_SUCCESS; +import static org.springframework.http.HttpStatus.OK; + +@Tag(name = "NOTIFICATION") +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + @Operation(summary = "전체 μ•Œλ¦Όμ„ μ‘°νšŒν•˜λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse getNotifications(@CurrentMemberId Long memberId, + @RequestParam int pageNum, + @RequestParam int pageSize) { + NotificationListResponse response = notificationService.getNotifications(memberId, pageNum, pageSize); + + return ApiResponse.response(OK, NOTIFICATION_GET_SUCCESS.getMessage(), response); + } + + @GetMapping("/unread") + @Operation(summary = "ν™ˆμ—μ„œ μ•Œλ¦Ό κ°œμˆ˜μ™€ μ—¬λΆ€λ₯Ό ν™•μΈν•˜λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse getInfo(@CurrentMemberId Long memberId) { + NotificationInfoResponse response = notificationService.getInfo(memberId); + + return ApiResponse.response(OK, NOTIFICATION_GET_SUCCESS.getMessage(), response); + } + + @DeleteMapping("/{notificationId}") + @Operation(summary = "μ•Œλ¦Όμ„ κ°œλ³„ μ‚­μ œν•˜λŠ” APIμž…λ‹ˆλ‹€.") + public ApiResponse delete(@CurrentMemberId Long memberId, + @PathVariable String notificationId) { + notificationService.delete(memberId, notificationId); + + return ApiResponse.response(OK, NOTIFICATION_DELETE_SUCCESS.getMessage()); + + } + +} diff --git a/src/main/java/com/gachtaxi/domain/notification/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/domain/notification/controller/ResponseMessage.java new file mode 100644 index 00000000..c6b7d720 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/controller/ResponseMessage.java @@ -0,0 +1,14 @@ +package com.gachtaxi.domain.notification.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + NOTIFICATION_GET_SUCCESS("μ•Œλ¦Ό μ‘°νšŒμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + NOTIFICATION_DELETE_SUCCESS("μ•Œλ¦Ό μ‚­μ œμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationInfoResponse.java b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationInfoResponse.java new file mode 100644 index 00000000..b59a9ead --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationInfoResponse.java @@ -0,0 +1,10 @@ +package com.gachtaxi.domain.notification.dto.response; + +public record NotificationInfoResponse( + int unreadCount, + boolean hasUnreadNotifications +) { + public static NotificationInfoResponse of(int unreadCount, boolean hasUnreadNotifications) { + return new NotificationInfoResponse(unreadCount, hasUnreadNotifications); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationListResponse.java new file mode 100644 index 00000000..d1d251f2 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationListResponse.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.notification.dto.response; + +import java.util.List; + +public record NotificationListResponse( + List response, + NotificationPageableResponse pageable +) { + public static NotificationListResponse of(List response, NotificationPageableResponse pageable) { + return new NotificationListResponse(response, pageable); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationPageableResponse.java b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationPageableResponse.java new file mode 100644 index 00000000..088310f7 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationPageableResponse.java @@ -0,0 +1,22 @@ +package com.gachtaxi.domain.notification.dto.response; + +import com.gachtaxi.domain.notification.entity.Notification; +import lombok.Builder; +import org.springframework.data.domain.Slice; + +@Builder +public record NotificationPageableResponse( + int pageNumber, + int pageSize, + int numberOfElements, + boolean last +) { + public static NotificationPageableResponse from(Slice slice) { + return NotificationPageableResponse.builder() + .pageNumber(slice.getNumber()) + .pageSize(slice.getSize()) + .numberOfElements(slice.getNumberOfElements()) + .last(slice.isLast()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 00000000..b0ed55c4 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,29 @@ +package com.gachtaxi.domain.notification.dto.response; + +import com.gachtaxi.domain.notification.entity.Notification; +import com.gachtaxi.domain.notification.entity.enums.NotificationType; +import com.gachtaxi.domain.notification.entity.payload.NotificationPayload; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record NotificationResponse( + String notificationId, + long receiverId, + NotificationType type, + String content, + NotificationPayload payload, + LocalDateTime createdAt +) { + public static NotificationResponse from(Notification notification) { + return NotificationResponse.builder() + .notificationId(notification.getId()) + .receiverId(notification.getReceiverId()) + .type(notification.getType()) + .content(notification.getContent()) + .payload(notification.getPayload()) + .createdAt(notification.getCreateDate()) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/Notification.java b/src/main/java/com/gachtaxi/domain/notification/entity/Notification.java new file mode 100644 index 00000000..c78fe8ee --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/Notification.java @@ -0,0 +1,68 @@ +package com.gachtaxi.domain.notification.entity; + +import com.gachtaxi.domain.notification.entity.enums.NotificationStatus; +import com.gachtaxi.domain.notification.entity.enums.NotificationType; +import com.gachtaxi.domain.notification.entity.payload.NotificationPayload; +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +import static com.gachtaxi.domain.notification.entity.enums.NotificationStatus.*; + +@Getter +@Document(collection = "notifications") +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification { + + @Id + private String id; + + private Long receiverId; + + @Enumerated(EnumType.STRING) + private NotificationType type; + + @Column(columnDefinition = "text") + private String content; + + private NotificationPayload payload; + + @Builder.Default + @Enumerated(EnumType.STRING) + private NotificationStatus status = UNREAD; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createDate; + + private LocalDateTime readAt; + + public void read() { + this.status = READ; + this.readAt = LocalDateTime.now(); + } + + public void failToSend() { + this.status = UNSENT; + } + + public static Notification of(Long receiverId, NotificationType type, String content, NotificationPayload payload) { + return Notification.builder() + .receiverId(receiverId) + .type(type) + .content(content) + .payload(payload) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationStatus.java b/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationStatus.java new file mode 100644 index 00000000..7b7be866 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.notification.entity.enums; + +public enum NotificationStatus { + UNREAD, READ, UNSENT +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationType.java b/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationType.java new file mode 100644 index 00000000..22c00063 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/enums/NotificationType.java @@ -0,0 +1,5 @@ +package com.gachtaxi.domain.notification.entity.enums; + +public enum NotificationType { + MATCH_START, MATCH_FINISH, FRIEND_REQUEST, MATCH_INVITE +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/payload/FriendRequestPayload.java b/src/main/java/com/gachtaxi/domain/notification/entity/payload/FriendRequestPayload.java new file mode 100644 index 00000000..235a9540 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/payload/FriendRequestPayload.java @@ -0,0 +1,23 @@ +package com.gachtaxi.domain.notification.entity.payload; + +import com.gachtaxi.domain.friend.entity.enums.FriendStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FriendRequestPayload extends NotificationPayload { + + @Builder.Default + private FriendStatus status = FriendStatus.PENDING; + + private Long senderId; + + public static FriendRequestPayload from(Long senderId) { + return FriendRequestPayload.builder().senderId(senderId).build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingInvitePayload.java b/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingInvitePayload.java new file mode 100644 index 00000000..09a9cffc --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingInvitePayload.java @@ -0,0 +1,21 @@ +package com.gachtaxi.domain.notification.entity.payload; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MatchingInvitePayload extends NotificationPayload { + private String senderNickname; + private Long matchingRoomId; + + public static MatchingInvitePayload from(String senderNickname, Long matchingRoomId) { + return MatchingInvitePayload.builder() + .senderNickname(senderNickname) + .matchingRoomId(matchingRoomId) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingPayload.java b/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingPayload.java new file mode 100644 index 00000000..d8b24ff7 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/payload/MatchingPayload.java @@ -0,0 +1,22 @@ +package com.gachtaxi.domain.notification.entity.payload; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MatchingPayload extends NotificationPayload { + private String startLocationName; + + private String endLocationName; + + public static MatchingPayload of(String startLocationName, String endLocationName) { + return MatchingPayload.builder() + .startLocationName(startLocationName) + .endLocationName(endLocationName) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/entity/payload/NotificationPayload.java b/src/main/java/com/gachtaxi/domain/notification/entity/payload/NotificationPayload.java new file mode 100644 index 00000000..cf2e9a4e --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/entity/payload/NotificationPayload.java @@ -0,0 +1,11 @@ +package com.gachtaxi.domain.notification.entity.payload; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NotificationPayload { + +} diff --git a/src/main/java/com/gachtaxi/domain/notification/exception/ErrorMessage.java b/src/main/java/com/gachtaxi/domain/notification/exception/ErrorMessage.java new file mode 100644 index 00000000..d5cbeb65 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/exception/ErrorMessage.java @@ -0,0 +1,16 @@ +package com.gachtaxi.domain.notification.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + NOTIFICATION_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ•Œλ¦Όμž…λ‹ˆλ‹€."), + FCM_TOKEN_NOT_FOUND(" Fcm 토큰이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€"), + INVALID_FCM_TOKEN("Fcm 토큰 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + INVALID_MEMBER_MATCH("μ•Œλ¦Όμ„ μ‚­μ œν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/domain/notification/exception/FcmTokenNotFoundException.java b/src/main/java/com/gachtaxi/domain/notification/exception/FcmTokenNotFoundException.java new file mode 100644 index 00000000..d6b517ef --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/exception/FcmTokenNotFoundException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.domain.notification.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.notification.exception.ErrorMessage.FCM_TOKEN_NOT_FOUND; +import static org.springframework.http.HttpStatus.valueOf; + + +public class FcmTokenNotFoundException extends BaseException { + public FcmTokenNotFoundException(int statusCode, String statusMessage) { + super(valueOf(statusCode), statusMessage + FCM_TOKEN_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/exception/InvalidFcmTokenException.java b/src/main/java/com/gachtaxi/domain/notification/exception/InvalidFcmTokenException.java new file mode 100644 index 00000000..a4ced63d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/exception/InvalidFcmTokenException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.notification.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.notification.exception.ErrorMessage.INVALID_FCM_TOKEN; +import static org.springframework.http.HttpStatus.valueOf; + +public class InvalidFcmTokenException extends BaseException { + public InvalidFcmTokenException(int statusCode, String statusMessage) { + super(valueOf(statusCode), statusMessage + INVALID_FCM_TOKEN.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/exception/MemberNotMatchException.java b/src/main/java/com/gachtaxi/domain/notification/exception/MemberNotMatchException.java new file mode 100644 index 00000000..c8133c26 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/exception/MemberNotMatchException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.notification.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.notification.exception.ErrorMessage.INVALID_MEMBER_MATCH; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +public class MemberNotMatchException extends BaseException { + public MemberNotMatchException() { + super(FORBIDDEN, INVALID_MEMBER_MATCH.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/exception/NotificationNotFoundException.java b/src/main/java/com/gachtaxi/domain/notification/exception/NotificationNotFoundException.java new file mode 100644 index 00000000..2e98e79d --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/exception/NotificationNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.domain.notification.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.domain.notification.exception.ErrorMessage.NOTIFICATION_NOT_FOUND; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class NotificationNotFoundException extends BaseException { + public NotificationNotFoundException() { + super(NOT_FOUND, NOTIFICATION_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/repository/NotificationRepository.java b/src/main/java/com/gachtaxi/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..d96d4daa --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,17 @@ +package com.gachtaxi.domain.notification.repository; + +import com.gachtaxi.domain.notification.entity.Notification; +import com.gachtaxi.domain.notification.entity.enums.NotificationStatus; +import com.gachtaxi.domain.notification.entity.enums.NotificationType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface NotificationRepository extends MongoRepository { + + Integer countAllByReceiverIdAndStatus(Long receiverId, NotificationStatus status); + + Slice findAllByReceiverId(Long receiverId, Pageable pageable); + + int countByReceiverIdAndType(Long receiverId, NotificationType type); +} diff --git a/src/main/java/com/gachtaxi/domain/notification/service/FcmService.java b/src/main/java/com/gachtaxi/domain/notification/service/FcmService.java new file mode 100644 index 00000000..d1837357 --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/service/FcmService.java @@ -0,0 +1,42 @@ +package com.gachtaxi.domain.notification.service; + +import com.gachtaxi.domain.notification.exception.FcmTokenNotFoundException; +import com.gachtaxi.domain.notification.exception.InvalidFcmTokenException; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FcmService { + + public void sendNotification(String targetToken, String title, String body) { + try{ + Message message = Message.builder() + .setToken(targetToken) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + } catch (FirebaseMessagingException e) { + handleException(e); + } + } + + private void handleException(FirebaseMessagingException exception) { + int statusCode = exception.getHttpResponse().getStatusCode(); + String errorCode = exception.getErrorCode().toString(); + + if (statusCode == 404) { + throw new FcmTokenNotFoundException(statusCode, errorCode); + } else if (statusCode == 400) { + throw new InvalidFcmTokenException(statusCode, errorCode); + } + } +} diff --git a/src/main/java/com/gachtaxi/domain/notification/service/NotificationService.java b/src/main/java/com/gachtaxi/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..51d4088a --- /dev/null +++ b/src/main/java/com/gachtaxi/domain/notification/service/NotificationService.java @@ -0,0 +1,96 @@ +package com.gachtaxi.domain.notification.service; + +import com.gachtaxi.domain.members.entity.Members; +import com.gachtaxi.domain.notification.dto.response.NotificationInfoResponse; +import com.gachtaxi.domain.notification.dto.response.NotificationListResponse; +import com.gachtaxi.domain.notification.dto.response.NotificationPageableResponse; +import com.gachtaxi.domain.notification.dto.response.NotificationResponse; +import com.gachtaxi.domain.notification.entity.Notification; +import com.gachtaxi.domain.notification.entity.enums.NotificationType; +import com.gachtaxi.domain.notification.entity.payload.NotificationPayload; +import com.gachtaxi.domain.notification.exception.MemberNotMatchException; +import com.gachtaxi.domain.notification.exception.NotificationNotFoundException; +import com.gachtaxi.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.gachtaxi.domain.notification.entity.enums.NotificationStatus.UNREAD; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final FcmService fcmService; + + public NotificationListResponse getNotifications(Long receiverId, int pageNum, int pageSize) { + Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "createDate")); + + Slice notifications = notificationRepository.findAllByReceiverId(receiverId, pageable); + + notifications.stream() + .filter(notification -> notification.getStatus() == UNREAD) + .forEach(Notification::read); + + notificationRepository.saveAll(notifications); + + List responses = notifications.stream() + .map(NotificationResponse::from) + .toList(); + + NotificationPageableResponse pageableResponse = NotificationPageableResponse.from(notifications); + + return NotificationListResponse.of(responses, pageableResponse); + } + + public NotificationInfoResponse getInfo(Long receiverId) { + Integer count = notificationRepository.countAllByReceiverIdAndStatus(receiverId, UNREAD); + + if (count > 0) { + return NotificationInfoResponse.of(count, true); + } + + return NotificationInfoResponse.of(count, false); + } + + public void sendWithPush(Members receiver, NotificationType type, String title, String content, NotificationPayload payload) { + Notification notification = Notification.of(receiver.getId(), type, content, payload); + + notificationRepository.save(notification); + // todo : μ•±μœΌλ‘œ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ ν›„ 주석 ν•΄μ œ +// fcmService.sendNotification(receiver.getFcmToken(), title, notification.getContent()); + } + + public void sendWithOutPush(Members receiver, NotificationType type, String content, NotificationPayload payload) { + Notification notification = Notification.of(receiver.getId(), type, content, payload); + + notificationRepository.save(notification); + } + + public void delete(Long receiverId, String notificationId) { + validateMember(receiverId, notificationId); + + notificationRepository.deleteById(notificationId); + } + + public Notification find(String notificationId) { + return notificationRepository.findById(notificationId) + .orElseThrow(NotificationNotFoundException::new); + } + + private void validateMember(long receiverId, String notificationId) { + Notification notification = find(notificationId); + + if (!notification.getReceiverId().equals(receiverId)) { + throw new MemberNotMatchException(); + } + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/enums/OauthLoginStatus.java b/src/main/java/com/gachtaxi/global/auth/enums/OauthLoginStatus.java new file mode 100644 index 00000000..2bcc6a1c --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/enums/OauthLoginStatus.java @@ -0,0 +1,11 @@ +package com.gachtaxi.global.auth.enums; + +/* +* μ•„λž˜ 두 μƒνƒœλ₯Ό λ‚˜νƒ€λ‚΄λŠ” enum +* 이미 νšŒμ›κ°€μž…ν•œ μ‚¬μš©μž (둜그인 처리) +* νšŒμ›κ°€μž… μ•ˆλœ μ‚¬μš©μž (νšŒμ›κ°€μž… 처리) +* */ + +public enum OauthLoginStatus { + LOGIN, UN_REGISTER +} diff --git a/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleAuthCode.java b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleAuthCode.java new file mode 100644 index 00000000..7dbb199b --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleAuthCode.java @@ -0,0 +1,8 @@ +package com.gachtaxi.global.auth.google.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GoogleAuthCode( + @NotBlank String authCode +) { +} diff --git a/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleTokenResponse.java b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleTokenResponse.java new file mode 100644 index 00000000..07a95209 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleTokenResponse.java @@ -0,0 +1,10 @@ +package com.gachtaxi.global.auth.google.dto; + +public record GoogleTokenResponse( + String access_token, + String expires_in, + String scope, + String token_type, + String id_token +) { +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleUserInfoResponse.java b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleUserInfoResponse.java new file mode 100644 index 00000000..54c2c76f --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/google/dto/GoogleUserInfoResponse.java @@ -0,0 +1,7 @@ +package com.gachtaxi.global.auth.google.dto; + +public record GoogleUserInfoResponse( + String id, + String email +) { +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/auth/google/utils/GoogleUtils.java b/src/main/java/com/gachtaxi/global/auth/google/utils/GoogleUtils.java new file mode 100644 index 00000000..d2d17114 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/google/utils/GoogleUtils.java @@ -0,0 +1,59 @@ +package com.gachtaxi.global.auth.google.utils; + +import com.gachtaxi.global.auth.google.dto.GoogleTokenResponse; +import com.gachtaxi.global.auth.google.dto.GoogleUserInfoResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Component +public class GoogleUtils { + + @Value("${gachtaxi.auth.google.client}") + private String googleClient; + + @Value("${gachtaxi.auth.google.client-secret}") + private String clientSecret; + + @Value("${gachtaxi.auth.google.redirect}") + private String googleRedirect; + + @Value("${gachtaxi.auth.google.token_uri}") + private String GoogleTokenUri; + + @Value("${gachtaxi.auth.google.user_profile}") + private String GoogleProfileUri; + + private final RestClient restClient = RestClient.create(); + + public GoogleTokenResponse reqeustGoogleToken(String authCode){ + String decodeCode = URLDecoder.decode(authCode, StandardCharsets.UTF_8); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", googleClient); + params.add("client_secret", clientSecret); + params.add("redirect_uri", googleRedirect); + params.add("code", decodeCode); + + return restClient.post() + .uri(GoogleTokenUri) + .body(params) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(GoogleTokenResponse.class); + } + + public GoogleUserInfoResponse requestGoogleProfile(String Token){ + return restClient.get() + .uri(GoogleProfileUri) + .header("Authorization", "Bearer " + Token) + .retrieve() + .body(GoogleUserInfoResponse.class); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/annotation/CurrentMemberId.java b/src/main/java/com/gachtaxi/global/auth/jwt/annotation/CurrentMemberId.java new file mode 100644 index 00000000..31804b4e --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/annotation/CurrentMemberId.java @@ -0,0 +1,19 @@ +package com.gachtaxi.global.auth.jwt.annotation; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? T(com.gachtaxi.domain.members.exception.MemberIdNotFoundException).throwException() : id") +public @interface CurrentMemberId { + /* + * AuthenticationPrincipal의 id ν•„λ“œλ₯Ό λ°˜ν™˜ + * 즉, JwtUserDetails의 id ν•„λ“œλ₯Ό λ°˜ν™˜ + * JwtUserDetails의 idλŠ” Userid + * */ +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAccessDeniedHandler.java b/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..88fe671a --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.gachtaxi.global.auth.jwt.authentication; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachtaxi.global.common.response.ApiResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_FORBIDDEN; + +@Slf4j +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final static String LOG_FORMAT = "ExceptionClass: {}, Message: {}"; + private final static String CONTENT_TYPE = "application/json"; + private final static String CHAR_ENCODING = "UTF-8"; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, org.springframework.security.access.AccessDeniedException accessDeniedException) throws IOException, ServletException { + setResponse(response); + log.error(LOG_FORMAT, accessDeniedException.getClass().getSimpleName(), accessDeniedException.getMessage()); + } + + private void setResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(CONTENT_TYPE); + response.setCharacterEncoding(CHAR_ENCODING); + + String body = new ObjectMapper().writeValueAsString(ApiResponse.response(HttpStatus.FORBIDDEN, JWT_TOKEN_FORBIDDEN.getMessage())); + response.getWriter().write(body); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAuthenticationEntryPoint.java b/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..92218e51 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/authentication/CustomAuthenticationEntryPoint.java @@ -0,0 +1,47 @@ +package com.gachtaxi.global.auth.jwt.authentication; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage; +import com.gachtaxi.global.common.response.ApiResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final static String LOG_FORMAT = "ExceptionClass: {}, Message: {}"; + private final static String JWT_ERROR = "jwtError"; + private final static String CONTENT_TYPE = "application/json"; + private final static String CHAR_ENCODING = "UTF-8"; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + JwtErrorMessage jwtError = (JwtErrorMessage) request.getAttribute(JWT_ERROR); + + if (jwtError != null) { + setResponse(response, jwtError.getMessage()); + log.error(LOG_FORMAT, jwtError, jwtError.getMessage()); + } else { + setResponse(response, authException.getMessage()); + log.error(LOG_FORMAT, authException.getClass().getSimpleName(), authException.getMessage()); + } + } + + private void setResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(CONTENT_TYPE); + response.setCharacterEncoding(CHAR_ENCODING); + + String body = new ObjectMapper().writeValueAsString(ApiResponse.response(HttpStatus.UNAUTHORIZED, message)); + response.getWriter().write(body); + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/dto/JwtTokenDto.java b/src/main/java/com/gachtaxi/global/auth/jwt/dto/JwtTokenDto.java new file mode 100644 index 00000000..703dfd25 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/dto/JwtTokenDto.java @@ -0,0 +1,25 @@ +package com.gachtaxi.global.auth.jwt.dto; + +import lombok.Builder; + +@Builder +public record JwtTokenDto( + String accessToken, + String refreshToken +){ + public static JwtTokenDto of(String accessToken, String refreshToken) { + return JwtTokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken).build(); + } + + public static JwtTokenDto of(String accessToken) { + return JwtTokenDto.builder() + .accessToken(accessToken) + .build(); + } + + public boolean isTemporaryUser(){ + return this.refreshToken == null || this.refreshToken.isEmpty(); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/CookieNotFoundException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/CookieNotFoundException.java new file mode 100644 index 00000000..1f5525cb --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/CookieNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.COOKIE_NOT_FOUND; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class CookieNotFoundException extends BaseException { + public CookieNotFoundException() { + super(BAD_REQUEST, COOKIE_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/JwtErrorMessage.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/JwtErrorMessage.java new file mode 100644 index 00000000..478e4491 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/JwtErrorMessage.java @@ -0,0 +1,21 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtErrorMessage { + COOKIE_NOT_FOUND("헀더에 RefreshToken이 μ—†μŠ΅λ‹ˆλ‹€."), + REDIS_NOT_FOUND("Redis μ—μ„œ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + + USER_NOT_FOUND_EMAIL("ν•΄λ‹Ή μ΄λ©”μΌμ˜ μœ μ €λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"), + JWT_TOKEN_FORBIDDEN("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + JWT_TOKEN_NOT_FOUND("토큰을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"), + JWT_TOKEN_EXPIRED("만료된 ν† ν°μž…λ‹ˆλ‹€."), + JWT_TOKEN_INVALID("μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰 μž…λ‹ˆλ‹€."), + JWT_TOKEN_UN_VALID("μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰 μž…λ‹ˆλ‹€."), + JWT_TOKEN_NOT_EXIST("헀더에 인증 토큰이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€"); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/RefreshTokenNotFoundException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/RefreshTokenNotFoundException.java new file mode 100644 index 00000000..252b1c9a --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.REDIS_NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class RefreshTokenNotFoundException extends BaseException { + public RefreshTokenNotFoundException() { + super(UNAUTHORIZED, REDIS_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenExpiredException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenExpiredException.java new file mode 100644 index 00000000..82dfcdf7 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenExpiredException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_EXPIRED; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class TokenExpiredException extends BaseException { + public TokenExpiredException() { + super(UNAUTHORIZED, JWT_TOKEN_EXPIRED.getMessage()); + } +} + diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenInvalidException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenInvalidException.java new file mode 100644 index 00000000..d0ae23bd --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenInvalidException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_INVALID; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class TokenInvalidException extends BaseException { + public TokenInvalidException() { + super(UNAUTHORIZED, JWT_TOKEN_INVALID.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenNotExistException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenNotExistException.java new file mode 100644 index 00000000..0957c5d6 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/TokenNotExistException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_NOT_EXIST; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class TokenNotExistException extends BaseException { + public TokenNotExistException () { + super(UNAUTHORIZED, JWT_TOKEN_NOT_EXIST.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/exception/UserEmailNotFoundException.java b/src/main/java/com/gachtaxi/global/auth/jwt/exception/UserEmailNotFoundException.java new file mode 100644 index 00000000..a90ff0fe --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/exception/UserEmailNotFoundException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.auth.jwt.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.USER_NOT_FOUND_EMAIL; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class UserEmailNotFoundException extends BaseException { + public UserEmailNotFoundException() { + super(UNAUTHORIZED, USER_NOT_FOUND_EMAIL.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/gachtaxi/global/auth/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..28b68644 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.gachtaxi.global.auth.jwt.filter; + +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_EXPIRED; +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_INVALID; +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.JWT_TOKEN_NOT_FOUND; + +import com.gachtaxi.global.auth.jwt.user.JwtUserDetails; +import com.gachtaxi.global.auth.jwt.util.JwtExtractor; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtExtractor jwtExtractor; + + private final static String JWT_ERROR = "jwtError"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Optional token = jwtExtractor.extractJwtToken(request); + + if (token.isEmpty()) { + request.setAttribute(JWT_ERROR, JWT_TOKEN_NOT_FOUND); + filterChain.doFilter(request, response); + return; + } + + String accessToken = token.get(); + + if (!jwtExtractor.validateJwtToken(accessToken)) { + request.setAttribute(JWT_ERROR, JWT_TOKEN_INVALID); + filterChain.doFilter(request, response); + return; + } + + if (jwtExtractor.isExpired(accessToken)) { + request.setAttribute(JWT_ERROR, JWT_TOKEN_EXPIRED); + filterChain.doFilter(request, response); + return; + } + + saveAuthentcation(accessToken); + + filterChain.doFilter(request, response); + } + + private void saveAuthentcation(String token) { + Long id = jwtExtractor.getId(token); + String email = jwtExtractor.getEmail(token); + String role = jwtExtractor.getRole(token); + + UserDetails userDetails = JwtUserDetails.of(id, email, role); + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/service/JwtService.java b/src/main/java/com/gachtaxi/global/auth/jwt/service/JwtService.java new file mode 100644 index 00000000..39ecc36f --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/service/JwtService.java @@ -0,0 +1,68 @@ +package com.gachtaxi.global.auth.jwt.service; + +import com.gachtaxi.domain.members.dto.request.InactiveMemberDto; +import com.gachtaxi.domain.members.dto.request.MemberTokenDto; +import com.gachtaxi.global.auth.jwt.dto.JwtTokenDto; +import com.gachtaxi.global.auth.jwt.exception.CookieNotFoundException; +import com.gachtaxi.global.auth.jwt.exception.TokenExpiredException; +import com.gachtaxi.global.auth.jwt.exception.TokenInvalidException; +import com.gachtaxi.global.auth.jwt.util.JwtExtractor; +import com.gachtaxi.global.auth.jwt.util.JwtProvider; +import com.gachtaxi.global.common.redis.RedisUtil; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +public class JwtService { + + private final RedisUtil redisUtil; + private final JwtProvider jwtProvider; + private final JwtExtractor jwtExtractor; + + // JwtToken 생성 + Redis μ €μž₯ + public JwtTokenDto generateJwtToken(MemberTokenDto dto) { + String accessToken = jwtProvider.generateAccessToken(dto.id(), dto.email(), dto.role()); + String refreshToken = jwtProvider.generateRefreshToken(dto.id(), dto.email(), dto.role()); + + redisUtil.setRefreshToken(dto.id(), refreshToken); + return JwtTokenDto.of(accessToken, refreshToken); + } + + public JwtTokenDto generateTmpAccessToken(InactiveMemberDto inactiveMemberDto) { + String tmpAccessToken = jwtProvider.generateTmpAccessToken(inactiveMemberDto.userId(), inactiveMemberDto.role().name()); + return JwtTokenDto.of(tmpAccessToken); + } + + public JwtTokenDto reissueJwtToken(String refreshToken) { + if(jwtExtractor.isExpired(refreshToken)){ + throw new TokenExpiredException(); + } + + Long userId = jwtExtractor.getId(refreshToken); + String redisToken = (String) redisUtil.getRefreshToken(userId); + if(!redisToken.equals(refreshToken)) { + throw new TokenInvalidException(); + } + + String email = jwtExtractor.getEmail(refreshToken); + String role = jwtExtractor.getRole(refreshToken); + return generateJwtToken(MemberTokenDto.of(userId, email, role)); + } + + /* + * refactoring + * */ + + private static Cookie[] getCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + throw new CookieNotFoundException(); + } + return cookies; + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetails.java b/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetails.java new file mode 100644 index 00000000..9fe10a04 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetails.java @@ -0,0 +1,24 @@ +package com.gachtaxi.global.auth.jwt.user; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; + +import java.util.Collections; +import java.util.List; + +@Getter +public class JwtUserDetails extends User { + + private final Long id; + + public JwtUserDetails(Long id, String email, List authorities) { + super(email, "", authorities); + this.id = id; + } + + public static JwtUserDetails of(Long id, String email, String role) { + return new JwtUserDetails(id, email, Collections.singletonList(new SimpleGrantedAuthority(role))); + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetailsService.java b/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetailsService.java new file mode 100644 index 00000000..6f986de1 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/user/JwtUserDetailsService.java @@ -0,0 +1,25 @@ +//package com.gachtaxi.global.auth.jwt.user; +// +//import com.gachtaxi.domain.members.entity.Members; +//import com.gachtaxi.domain.members.repository.MemberRepository; +//import com.gachtaxi.global.auth.jwt.exception.UserEmailNotFoundException; +//import lombok.RequiredArgsConstructor; +//import org.springframework.security.core.userdetails.UserDetails; +//import org.springframework.security.core.userdetails.UserDetailsService; +//import org.springframework.security.core.userdetails.UsernameNotFoundException; +//import org.springframework.stereotype.Service; +// +//@Service +//@RequiredArgsConstructor +//public class JwtUserDetailsService implements UserDetailsService { +// +// private final MemberRepository memberRepository; +// +// @Override +// public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { +// Members member = memberRepository.findByEmail(email) +// .orElseThrow(UserEmailNotFoundException::new); +// +// return JwtUserDetails.of(member); +// } +//} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/util/CookieUtil.java b/src/main/java/com/gachtaxi/global/auth/jwt/util/CookieUtil.java new file mode 100644 index 00000000..000fa269 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/util/CookieUtil.java @@ -0,0 +1,43 @@ +package com.gachtaxi.global.auth.jwt.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + @Value("${gachtaxi.auth.jwt.cookieMaxAge}") + private Long cookieMaxAge; + + @Value("${gachtaxi.auth.jwt.secureOption}") + private boolean secureOption; + + @Value("${gachtaxi.auth.jwt.cookiePathOption}") + private String cookiePathOption; + + public void setCookie( String name, String value, HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .maxAge(cookieMaxAge) + .path(cookiePathOption) + .secure(secureOption) //https 적용 μ‹œ true + .httpOnly(true) + .sameSite("None") + .build(); + + response.setHeader("Set-Cookie", cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "value") + .maxAge(0) + .path("/") + .secure(false) + .httpOnly(true) + .sameSite("None") + .build(); + + response.setHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtExtractor.java b/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtExtractor.java new file mode 100644 index 00000000..379c956a --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtExtractor.java @@ -0,0 +1,87 @@ +package com.gachtaxi.global.auth.jwt.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.Optional; + +import static com.gachtaxi.global.auth.jwt.util.JwtProvider.ACCESS_TOKEN_SUBJECT; + +// 토큰 μΆ”μΆœ 및 검증 +@Slf4j +@Component +public class JwtExtractor { + + private static final String BEARER = "Bearer "; + private static final String ID_CLAIM = "id"; + private static final String EMAIL_CLAIM = "email"; + private static final String ROLE_CLAIM = "role"; + + private final Key key; + + public JwtExtractor(@Value("${gachtaxi.auth.jwt.key}") String secretKey) { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); // ν‚€ λ³€ν™˜ + } + + public Optional extractJwtToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(ACCESS_TOKEN_SUBJECT)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + public Long getId(String token){ + return getIdFromToken(token, ID_CLAIM); + } + + public String getEmail(String token){ + return getClaimFromToken(token, EMAIL_CLAIM); + } + + public String getRole(String token) { + return getClaimFromToken(token, ROLE_CLAIM); + } + + public Boolean isExpired(String token) { + Claims claims = parseClaims(token); + return claims.getExpiration().before(new Date()); + } + + private String getClaimFromToken(String token, String claimName) { + Claims claims = parseClaims(token); + return claims.get(claimName, String.class); + } + + private Long getIdFromToken(String token, String claimName) { + Claims claims = parseClaims(token); + return claims.get(claimName, Long.class); + } + + private Claims parseClaims(String token) { + JwtParser parser = Jwts.parserBuilder() + .setSigningKey(key) + .build(); + Claims claims = parser.parseClaimsJws(token).getBody(); + return claims; + } + + public boolean validateJwtToken(String token) { + try { + JwtParser parser = Jwts.parserBuilder() + .setSigningKey(key) + .build(); + parser.parseClaimsJws(token).getBody(); + return true; + }catch (JwtException e){ + return false; + } + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtProvider.java b/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtProvider.java new file mode 100644 index 00000000..db574708 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/util/JwtProvider.java @@ -0,0 +1,73 @@ +package com.gachtaxi.global.auth.jwt.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +// 토큰 생성 +@Component +public class JwtProvider { + + public static final String ACCESS_TOKEN_SUBJECT = "Authorization"; + public static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String ID_CLAIM = "id"; + private static final String EMAIL_CLAIM = "email"; + private static final String ROLE_CLAIM = "role"; + private static final String ROLE_PREFIX = "ROLE_"; + private static final String DUMMY_EMAIL = "dummy_email"; + private final Key key; + + public JwtProvider(@Value("${gachtaxi.auth.jwt.key}") String secretKey) { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); // ν‚€ λ³€ν™˜ + } + + @Value("${gachtaxi.auth.jwt.accessTokenExpiration}") + private Long accessTokenExpiration; + + @Value("${gachtaxi.auth.jwt.tmpAccessTokenExpiration}") + private Long tmpAccessTokenExpiration; + + @Value("${gachtaxi.auth.jwt.refreshTokenExpiration}") + private Long refreshTokenExpiration; + + public String generateAccessToken(Long id, String email, String role) { + return Jwts.builder() + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(ROLE_CLAIM, ROLE_PREFIX+role) + .setSubject(ACCESS_TOKEN_SUBJECT) // μ‚¬μš©μž 정보(고유 μ‹λ³„μž) + .setIssuedAt(new Date()) // λ°œν–‰ μ‹œκ°„ + .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) // 만료 μ‹œκ°„ + .signWith(key, SignatureAlgorithm.HS256) // μ„œλͺ… μ•Œκ³ λ¦¬μ¦˜ + .compact(); // μ΅œμ’… λ¬Έμžμ—΄ 생성 + } + + public String generateTmpAccessToken(Long id, String role) { + return Jwts.builder() + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, DUMMY_EMAIL) + .claim(ROLE_CLAIM, ROLE_PREFIX+role) + .setSubject(ACCESS_TOKEN_SUBJECT) // μ‚¬μš©μž 정보(고유 μ‹λ³„μž) + .setIssuedAt(new Date()) // λ°œν–‰ μ‹œκ°„ + .setExpiration(new Date(System.currentTimeMillis() + tmpAccessTokenExpiration)) // 만료 μ‹œκ°„ + .signWith(key, SignatureAlgorithm.HS256) // μ„œλͺ… μ•Œκ³ λ¦¬μ¦˜ + .compact(); // μ΅œμ’… λ¬Έμžμ—΄ 생성 + } + + public String generateRefreshToken(Long id, String email, String role) { + return Jwts.builder() + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(ROLE_CLAIM, ROLE_PREFIX+role) + .setSubject(REFRESH_TOKEN_SUBJECT) // μ‚¬μš©μž 정보(고유 μ‹λ³„μž) + .setIssuedAt(new Date()) // λ°œν–‰ μ‹œκ°„ + .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiration)) // 만료 μ‹œκ°„ + .signWith(key, SignatureAlgorithm.HS256) // μ„œλͺ… μ•Œκ³ λ¦¬μ¦˜ + .compact(); // μ΅œμ’… λ¬Έμžμ—΄ 생성 + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/util/KafkaBeanUtils.java b/src/main/java/com/gachtaxi/global/auth/jwt/util/KafkaBeanUtils.java new file mode 100644 index 00000000..b31e8655 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/util/KafkaBeanUtils.java @@ -0,0 +1,25 @@ +package com.gachtaxi.global.auth.jwt.util; + +import java.util.StringTokenizer; + +public abstract class KafkaBeanUtils { + + public static String getBeanName(String topic, String suffix) { + StringTokenizer stringTokenizer = new StringTokenizer(topic, "-_"); + + StringBuilder beanNameBuilder = new StringBuilder(); + beanNameBuilder.append(stringTokenizer.nextToken()); + + while (stringTokenizer.hasMoreTokens()) { + beanNameBuilder.append(getFirstUpperString(stringTokenizer.nextToken())); + } + + beanNameBuilder.append(suffix); + + return beanNameBuilder.toString(); + } + + private static String getFirstUpperString(String str) { + return str.substring(0, 1).toUpperCase() + str.substring(1); + } +} diff --git a/src/main/java/com/gachtaxi/global/auth/jwt/util/kafka/KafkaBeanSuffix.java b/src/main/java/com/gachtaxi/global/auth/jwt/util/kafka/KafkaBeanSuffix.java new file mode 100644 index 00000000..b008743b --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/jwt/util/kafka/KafkaBeanSuffix.java @@ -0,0 +1,8 @@ +package com.gachtaxi.global.auth.jwt.util.kafka; + +public abstract class KafkaBeanSuffix { + + public static final String PRODUCER_FACTORY_SUFFIX = "ProducerFactory"; + public static final String NEW_TOPIC_SUFFIX = "Topic"; + public static final String KAFKA_TEMPLATE_SUFFIX = "KafkaTemplate"; +} diff --git a/src/main/java/com/gachtaxi/global/auth/kakao/dto/KaKaoDTO.java b/src/main/java/com/gachtaxi/global/auth/kakao/dto/KaKaoDTO.java new file mode 100644 index 00000000..91e9b79c --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/kakao/dto/KaKaoDTO.java @@ -0,0 +1,36 @@ +package com.gachtaxi.global.auth.kakao.dto; + +import jakarta.validation.constraints.NotBlank; + +public class KaKaoDTO { + + public record KakaoAuthCode( + @NotBlank String authCode + ){} + + public record KakaoAccessToken( + String access_token, + String token_type, + String refresh_token, + int expires_in, + String scope, + int refresh_token_expires_in + ) {} + + public record KakaoUserInfoResponse( + Long id, + KakaoAccount kakao_account + ) {} + + public record KakaoAccount( + Boolean is_email_valid, + Boolean is_email_verified, + String email, + Profile profile + ) {} + + public record Profile( + String nickname, + Boolean is_default_nickname + ) {} +} diff --git a/src/main/java/com/gachtaxi/global/auth/kakao/util/KakaoUtil.java b/src/main/java/com/gachtaxi/global/auth/kakao/util/KakaoUtil.java new file mode 100644 index 00000000..b1d0209e --- /dev/null +++ b/src/main/java/com/gachtaxi/global/auth/kakao/util/KakaoUtil.java @@ -0,0 +1,57 @@ +package com.gachtaxi.global.auth.kakao.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import static com.gachtaxi.global.auth.kakao.dto.KaKaoDTO.*; + +/* +* KakaoUtil은 μ™ΈλΆ€ API 톡신 및 카카였 κ΄€λ ¨ μž‘μ—…μ„ λ‹΄λ‹Ή(μ±…μž„)ν•œλ‹€. +* */ + +@Slf4j +@Component +public class KakaoUtil { + + @Value("${gachtaxi.auth.kakao.client}") + private String kakaoClient; + + @Value("${gachtaxi.auth.kakao.redirect}") + private String kakaoRedirect; + + @Value("${gachtaxi.auth.kakao.token_uri}") + private String kakaoTokenUri; + + @Value("${gachtaxi.auth.kakao.user_profile}") + private String kakaoUserProfileUri; + + private final RestClient restClient = RestClient.create(); + + public KakaoAccessToken reqeustKakaoToken(String authCode){ + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoClient); + params.add("redirect_url", kakaoRedirect); + params.add("code", authCode); + + return restClient.post() + .uri(kakaoTokenUri) + .body(params) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(KakaoAccessToken.class); + } + + public KakaoUserInfoResponse requestKakaoProfile(String Token){ + return restClient.get() + .uri(kakaoUserProfileUri) + .header("Authorization", "Bearer " + Token) + .retrieve() + .body(KakaoUserInfoResponse.class); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/entity/BaseEntity.java b/src/main/java/com/gachtaxi/global/common/entity/BaseEntity.java new file mode 100644 index 00000000..d6d8f889 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package com.gachtaxi.global.common.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@SuperBuilder +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createDate; + + @LastModifiedDate + private LocalDateTime updateDate; +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/common/exception/BaseException.java b/src/main/java/com/gachtaxi/global/common/exception/BaseException.java index 79f3b20f..4d069fa2 100644 --- a/src/main/java/com/gachtaxi/global/common/exception/BaseException.java +++ b/src/main/java/com/gachtaxi/global/common/exception/BaseException.java @@ -8,7 +8,7 @@ public abstract class BaseException extends RuntimeException { private final HttpStatus status; - public BaseException( final HttpStatus status, final String message) { + public BaseException(final HttpStatus status, final String message) { super(message); this.status = status; } diff --git a/src/main/java/com/gachtaxi/global/common/exception/handler/CustomMessageExceptionHandler.java b/src/main/java/com/gachtaxi/global/common/exception/handler/CustomMessageExceptionHandler.java new file mode 100644 index 00000000..08ff529b --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/exception/handler/CustomMessageExceptionHandler.java @@ -0,0 +1,22 @@ +package com.gachtaxi.global.common.exception.handler; + +import com.gachtaxi.global.common.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.web.bind.annotation.ControllerAdvice; + +@Slf4j +@ControllerAdvice +public class CustomMessageExceptionHandler { + + private static final String LOG_FORMAT = "Class: {}, Code : {}, Message : {}"; + + @MessageExceptionHandler(RuntimeException.class) + @SendToUser(destinations = "/queue/errors", broadcast = false) + public ApiResponse handleRuntimeException(RuntimeException e) { + log.warn(LOG_FORMAT, e.getClass().getSimpleName(), 500, e.getMessage()); + return ApiResponse.response(HttpStatus.BAD_REQUEST, e.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/gachtaxi/global/common/exception/handler/GlobalExceptionHandler.java index 48fb8391..56a78b89 100644 --- a/src/main/java/com/gachtaxi/global/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/gachtaxi/global/common/exception/handler/GlobalExceptionHandler.java @@ -8,12 +8,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestCookieException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.List; -import static org.springframework.http.HttpStatus.*; +import static com.gachtaxi.global.auth.jwt.exception.JwtErrorMessage.COOKIE_NOT_FOUND; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; @Slf4j @RestControllerAdvice @@ -28,8 +31,13 @@ public ResponseEntity> handleException(BaseException e) { return exceptionResponse(e, e.getStatus(), e.getMessage(), null); } + @ExceptionHandler(MissingRequestCookieException.class) + public ResponseEntity> handleException(MissingRequestCookieException e) { + return exceptionResponse(e, BAD_REQUEST, COOKIE_NOT_FOUND.getMessage(), null); + } + // BindException 처리 - @ExceptionHandler(BindException.class) + @ExceptionHandler({BindException.class}) public ResponseEntity>> handleException(MethodArgumentNotValidException e) { List validErrorResponses = e.getBindingResult().getFieldErrors().stream() .map(fieldError -> ValidErrorResponse.builder() diff --git a/src/main/java/com/gachtaxi/global/common/exception/handler/StompExceptionHandler.java b/src/main/java/com/gachtaxi/global/common/exception/handler/StompExceptionHandler.java new file mode 100644 index 00000000..3ec06fe3 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/exception/handler/StompExceptionHandler.java @@ -0,0 +1,56 @@ +package com.gachtaxi.global.common.exception.handler; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachtaxi.global.common.exception.BaseException; +import com.gachtaxi.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; + +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class StompExceptionHandler extends StompSubProtocolErrorHandler { + + private final ObjectMapper objectMapper; + + @Override + public Message handleClientMessageProcessingError(Message clientMessage, Throwable ex) { + Throwable cause = ex.getCause(); + + if (cause instanceof BaseException baseException) { + return sendErrorMessage(baseException); + } + + return super.handleClientMessageProcessingError(clientMessage, ex); + } + + private Message sendErrorMessage(BaseException ex) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR); + + accessor.setHeader("content-type", "application/json"); + accessor.setMessage(ex.getMessage()); + accessor.setLeaveMutable(true); + + ApiResponse response = ApiResponse.response(ex.getStatus(), ex.getMessage()); + + String payload; + + try { + payload = objectMapper.writeValueAsString(response); + } catch (JsonProcessingException e) { + payload = "{\"error\": \"" + ex.getMessage() + "\"}"; + } + + return MessageBuilder.createMessage( + payload.getBytes(StandardCharsets.UTF_8), + accessor.getMessageHeaders() + ); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/image/ImageUtil.java b/src/main/java/com/gachtaxi/global/common/image/ImageUtil.java new file mode 100644 index 00000000..523a72c3 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/image/ImageUtil.java @@ -0,0 +1,47 @@ +package com.gachtaxi.global.common.image; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class ImageUtil { + + private final S3Presigner s3Presigner; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String generateUrl(String fileName) { + String key = generateKey(fileName); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + PutObjectPresignRequest request = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(5)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedUrlRequest = s3Presigner.presignPutObject(request); + + return presignedUrlRequest.url().toString(); + } + + private String generateKey(String fileName) { + String key = UUID.randomUUID().toString(); + String extension = fileName.substring(fileName.lastIndexOf(".") + 1); + + return key + "." + extension; + } +} diff --git a/src/main/java/com/gachtaxi/global/common/image/controller/ImageController.java b/src/main/java/com/gachtaxi/global/common/image/controller/ImageController.java new file mode 100644 index 00000000..d3e6fc6b --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/image/controller/ImageController.java @@ -0,0 +1,31 @@ +package com.gachtaxi.global.common.image.controller; + +import com.gachtaxi.global.common.image.ImageUtil; +import com.gachtaxi.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static com.gachtaxi.global.common.image.controller.ResponseMessage.PRESIGNED_URL_GENERATE_SUCCESS; +import static org.springframework.http.HttpStatus.OK; + +@Tag(name = "IMAGE") +@RestController +@RequestMapping("/api/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageUtil imageUtil; + + @GetMapping() + @Operation(summary = "Presigned Url λ°˜ν™˜μ„ μœ„ν•œ μš”μ²­ API μž…λ‹ˆλ‹€.") + public ApiResponse getPutUrl(@RequestParam String fileName) { + String putUrl = imageUtil.generateUrl(fileName); + + return ApiResponse.response(OK, PRESIGNED_URL_GENERATE_SUCCESS.getMessage(), putUrl); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/image/controller/ResponseMessage.java b/src/main/java/com/gachtaxi/global/common/image/controller/ResponseMessage.java new file mode 100644 index 00000000..460bf485 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/image/controller/ResponseMessage.java @@ -0,0 +1,14 @@ +package com.gachtaxi.global.common.image.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + PRESIGNED_URL_GENERATE_SUCCESS("Presigned url λ°œκΈ‰μ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final String message; + +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/dto/request/EmailAddressDto.java b/src/main/java/com/gachtaxi/global/common/mail/dto/request/EmailAddressDto.java new file mode 100644 index 00000000..38ad3d54 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/dto/request/EmailAddressDto.java @@ -0,0 +1,10 @@ +package com.gachtaxi.global.common.mail.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailAddressDto( + @NotBlank @Email(message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") + String email +) { +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/dto/request/NewTemplateRequestDto.java b/src/main/java/com/gachtaxi/global/common/mail/dto/request/NewTemplateRequestDto.java new file mode 100644 index 00000000..1dface9c --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/dto/request/NewTemplateRequestDto.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.common.mail.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record NewTemplateRequestDto( + + @NotBlank String templateName, // ν…œν”Œλ¦Ώ 제λͺ© + @NotBlank String subject, // 메일 제λͺ© + @NotBlank String htmlBody, // html λ°”λ”” + @NotBlank String textBody // html μ „ν™˜ 였λ₯˜μ‹œ μ‚¬μš© + +) { } diff --git a/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeExpirationException.java b/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeExpirationException.java new file mode 100644 index 00000000..cfb802d8 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeExpirationException.java @@ -0,0 +1,12 @@ +package com.gachtaxi.global.common.mail.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.common.mail.message.ErrorMessage.AUTH_CODE_EXPIRED; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class AuthCodeExpirationException extends BaseException { + public AuthCodeExpirationException() { + super(BAD_REQUEST, AUTH_CODE_EXPIRED.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeNotMatchException.java b/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeNotMatchException.java new file mode 100644 index 00000000..446a0ef1 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/exception/AuthCodeNotMatchException.java @@ -0,0 +1,13 @@ +package com.gachtaxi.global.common.mail.exception; + +import com.gachtaxi.global.common.exception.BaseException; + +import static com.gachtaxi.global.common.mail.message.ErrorMessage.AUTH_CODE_NOT_MATCH; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + + +public class AuthCodeNotMatchException extends BaseException { + public AuthCodeNotMatchException() { + super(BAD_REQUEST, AUTH_CODE_NOT_MATCH.getMessage()); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/message/ErrorMessage.java b/src/main/java/com/gachtaxi/global/common/mail/message/ErrorMessage.java new file mode 100644 index 00000000..a638f1b9 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/message/ErrorMessage.java @@ -0,0 +1,15 @@ +package com.gachtaxi.global.common.mail.message; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + AUTH_CODE_EXPIRED("인증 μ½”λ“œκ°€ λ§Œλ£ŒλμŠ΅λ‹ˆλ‹€"), + AUTH_CODE_NOT_MATCH("인증 μ½”λ“œκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€"); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/message/ResponseMessage.java b/src/main/java/com/gachtaxi/global/common/mail/message/ResponseMessage.java new file mode 100644 index 00000000..468bd0b9 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/message/ResponseMessage.java @@ -0,0 +1,17 @@ +package com.gachtaxi.global.common.mail.message; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + EMAIL_SEND_SUCCESS("인증 번호λ₯Ό λ°œμ†‘ν–ˆμŠ΅λ‹ˆλ‹€! 이메일을 ν™•μΈν•˜μ„Έμš”!"), + EMAIL_AUTHENTICATION_SUCESS("이메일 인증에 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€!"), + AGREEEMENT_UPDATE_SUCCESS("μ•½κ΄€ λ™μ˜ 정보λ₯Ό μ—…λ°μ΄νŠΈ ν–ˆμŠ΅λ‹ˆλ‹€!"), + SUPPLEMENT_UPDATE_SUCCESS("μ‚¬μš©μž μΆ”κ°€ 정보λ₯Ό μ—…λ°μ΄νŠΈ ν–ˆμŠ΅λ‹ˆλ‹€!"), + EMAIL_TEMPLATE_CREATE_SUCCESS("이메일 ν…œν”Œλ¦Ώμ„ μƒμ„±ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final String message; +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/service/EmailService.java b/src/main/java/com/gachtaxi/global/common/mail/service/EmailService.java new file mode 100644 index 00000000..83af7252 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/service/EmailService.java @@ -0,0 +1,95 @@ +package com.gachtaxi.global.common.mail.service; + +import com.gachtaxi.domain.members.exception.DuplicatedEmailException; +import com.gachtaxi.domain.members.exception.EmailFormInvalidException; +import com.gachtaxi.domain.members.repository.MemberRepository; +import com.gachtaxi.global.common.mail.exception.AuthCodeNotMatchException; +import com.gachtaxi.global.common.redis.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.model.Destination; +import software.amazon.awssdk.services.ses.model.SendTemplatedEmailRequest; + +import java.security.SecureRandom; + +import static com.gachtaxi.domain.members.entity.enums.UserStatus.ACTIVE; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private static final String CODE_LENGTH = "%06d"; + private static final String CODE_FORMAT = "{\"code\":\"%s\"}"; + private static final String GACHON_EMAIL_FORM = "@gachon.ac.kr"; + private static final int BOUND = 888888; + private static final int OFFSET = 111111; + + private final SesClient sesClient; + private final RedisUtil redisUtil; + private final SecureRandom secureRandom = new SecureRandom(); + private final MemberRepository memberRepository; + + @Value("${aws.ses.templateName}") + private String emailTemplateName; + @Value("${aws.ses.from}") + private String senderEmail; + + public void sendEmail(String email) { + checkGachonEmail(email); + checkDuplicatedEmail(email); + + String code = generateCode(); + redisUtil.setEmailAuthCode(email, code); + + sendAuthCodeEmail(email, code); + log.info("\n Email: " + email + "\n Code: " + code + "\n 전달"); + } + + public void checkEmailAuthCode(String recipientEmail, String inputCode) { + String redisAuthCode = (String) redisUtil.getEmailAuthCode(recipientEmail); + + if(!redisAuthCode.equals(inputCode)) { + throw new AuthCodeNotMatchException(); + } + } + + /* + * refactoring + * */ + + private void checkGachonEmail(String email){ + if(!email.endsWith(GACHON_EMAIL_FORM)){ + throw new EmailFormInvalidException(); + } + } + + public void checkDuplicatedEmail(String email){ + memberRepository.findByEmailAndStatus(email, ACTIVE). + ifPresent(members -> { + throw new DuplicatedEmailException(); + }); + } + + private String generateCode() { + return String.format(CODE_LENGTH, secureRandom.nextInt(BOUND) + OFFSET); + } + + // @Async 비동기 처리?? + private void sendAuthCodeEmail(String recipientEmail, String code) { + String templateData = String.format(CODE_FORMAT, code); + + SendTemplatedEmailRequest request = SendTemplatedEmailRequest.builder() + .destination(Destination.builder().toAddresses(recipientEmail).build()) + .template(emailTemplateName) + .templateData(templateData) + .source(senderEmail) + .build(); + sesClient.sendTemplatedEmail(request); + } + + +} diff --git a/src/main/java/com/gachtaxi/global/common/mail/service/SesClientTemplateService.java b/src/main/java/com/gachtaxi/global/common/mail/service/SesClientTemplateService.java new file mode 100644 index 00000000..c50338c0 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/mail/service/SesClientTemplateService.java @@ -0,0 +1,30 @@ +package com.gachtaxi.global.common.mail.service; + +import com.gachtaxi.global.common.mail.dto.request.NewTemplateRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.model.CreateTemplateRequest; +import software.amazon.awssdk.services.ses.model.Template; + +@Service +@RequiredArgsConstructor +public class SesClientTemplateService { + + private final SesClient sesClient; + + public void createTemplate(NewTemplateRequestDto dto) { + Template template = Template.builder() + .templateName(dto.templateName()) + .subjectPart(dto.subject()) + .htmlPart(dto.htmlBody()) + .textPart(dto.textBody()) + .build(); + + CreateTemplateRequest request = CreateTemplateRequest.builder() + .template(template) + .build(); + + sesClient.createTemplate(request); + } +} diff --git a/src/main/java/com/gachtaxi/global/common/redis/RedisUtil.java b/src/main/java/com/gachtaxi/global/common/redis/RedisUtil.java new file mode 100644 index 00000000..04ee401b --- /dev/null +++ b/src/main/java/com/gachtaxi/global/common/redis/RedisUtil.java @@ -0,0 +1,78 @@ +package com.gachtaxi.global.common.redis; + +import com.gachtaxi.global.auth.jwt.exception.RefreshTokenNotFoundException; +import com.gachtaxi.global.common.mail.exception.AuthCodeExpirationException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final static String TOKEN_FORMAT = "refreshToken:%s"; + private final static String EMAIL_CODE_FORMAT = "emailAuthCode:%s"; + + private final RedisTemplate redisTemplate; + + @Value("${gachtaxi.auth.jwt.refreshTokenExpiration}") + private Long refreshTokenExpiration; + + @Value("${gachtaxi.auth.redis.emailAuthCodeExpiration}") + private Long emailAuthCodeExpiration; + + public void setRefreshToken(Long id, String value) { + String key = String.format(TOKEN_FORMAT, id); + redisTemplate.opsForValue().set(key, value, refreshTokenExpiration, TimeUnit.MILLISECONDS); + } + + public void setEmailAuthCode(String email, String value) { + String key = String.format(EMAIL_CODE_FORMAT, email); + redisTemplate.opsForValue().set(key, value, emailAuthCodeExpiration, TimeUnit.MILLISECONDS); + } + + public Object getRefreshToken(Long id){ + String key = String.format(TOKEN_FORMAT, id); + Object getObjecet = redisTemplate.opsForValue().get(key); + + if(getObjecet == null){ + throw new RefreshTokenNotFoundException(); + } + + return getObjecet; + } + + public Object getEmailAuthCode(String email){ + String key = String.format(EMAIL_CODE_FORMAT, email); + Object getObjecet = redisTemplate.opsForValue().get(key); + + if(getObjecet == null){ + throw new AuthCodeExpirationException(); + } + + return getObjecet; + } + + public boolean hasRefreshTokenKey(Long id){ + String key = String.format(TOKEN_FORMAT, id); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public boolean hasAuthCodeKey(Long email){ + String key = String.format(EMAIL_CODE_FORMAT, email); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public boolean deleteRefreshToken(Long id){ + String key = String.format(TOKEN_FORMAT, id); + return Boolean.TRUE.equals(redisTemplate.delete(key)); + } + + public boolean deleteAuthCode(Long email){ + String key = String.format(EMAIL_CODE_FORMAT, email); + return Boolean.TRUE.equals(redisTemplate.delete( key)); + } +} diff --git a/src/main/java/com/gachtaxi/global/config/AwsS3Config.java b/src/main/java/com/gachtaxi/global/config/AwsS3Config.java new file mode 100644 index 00000000..701f2c37 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/AwsS3Config.java @@ -0,0 +1,32 @@ +package com.gachtaxi.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secreteKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public S3Presigner s3Presigner() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secreteKey); + + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } +} diff --git a/src/main/java/com/gachtaxi/global/config/FirebaseConfig.java b/src/main/java/com/gachtaxi/global/config/FirebaseConfig.java new file mode 100644 index 00000000..bf7e9daf --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/FirebaseConfig.java @@ -0,0 +1,51 @@ +package com.gachtaxi.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +public class FirebaseConfig { + + @Value("${firebase.adminSdk}") + private String adminSdkPath; + + @PostConstruct + public void initialize() { + + try { + InputStream inputStream = getInputStream(adminSdkPath); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(inputStream)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + log.info("Firebase μ„€μ • μ™„λ£Œ"); + } + + } catch (IOException e) { + log.warn("Firebase μ„€μ • 쀑 μ˜ˆμ™Έ λ°œμƒ", e); + } + } + + private InputStream getInputStream(String path) throws IOException { + if (new File(path).exists()) { + return new FileInputStream(path); + } else { + return new ClassPathResource(path).getInputStream(); + } + } +} diff --git a/src/main/java/com/gachtaxi/global/config/MongoConfig.java b/src/main/java/com/gachtaxi/global/config/MongoConfig.java new file mode 100644 index 00000000..2a6dbc32 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/MongoConfig.java @@ -0,0 +1,19 @@ +package com.gachtaxi.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; + +@Configuration +public class MongoConfig { + + @Value("${spring.kafka.mongodb.uri}") + private String mongoUri; + + @Bean + public MongoTemplate mongoTemplate() { + return new MongoTemplate(new SimpleMongoClientDatabaseFactory(mongoUri)); + } +} diff --git a/src/main/java/com/gachtaxi/global/config/PermitUrlConfig.java b/src/main/java/com/gachtaxi/global/config/PermitUrlConfig.java new file mode 100644 index 00000000..a40c5347 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/PermitUrlConfig.java @@ -0,0 +1,36 @@ +package com.gachtaxi.global.config; + +import org.springframework.stereotype.Component; + +@Component +public class PermitUrlConfig { + + public String[] getPublicUrl(){ + return new String[]{ + "/auth/login/kakao", + "/auth/login/google", + "/auth/refresh", + "/api/members", + + "/swagger-ui/**", + "/v3/api-docs/**", + + "/ws/**" + }; + } + + public String[] getMemberUrl(){ + return new String[]{ + "/auth/code/**", + "/api/friends/**" + }; + } + + public String[] getAdminUrl(){ + return new String[]{ + "/api/admin/email/template", + }; + } + + +} diff --git a/src/main/java/com/gachtaxi/global/config/RedisConfig.java b/src/main/java/com/gachtaxi/global/config/RedisConfig.java index ff852595..295be35e 100644 --- a/src/main/java/com/gachtaxi/global/config/RedisConfig.java +++ b/src/main/java/com/gachtaxi/global/config/RedisConfig.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.gachtaxi.domain.chat.dto.request.ChatMessage; import com.gachtaxi.domain.chat.redis.RedisChatSubscriber; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,6 +14,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -47,6 +49,7 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean + @Qualifier("chatRedisTemplate") public RedisTemplate chatRedisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); @@ -73,4 +76,14 @@ public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnecti return container; } + + @Bean + @Qualifier("chatRoomRedisTemplate") + public RedisTemplate chatRoomRedisTemplate(RedisConnectionFactory factory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(factory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } } diff --git a/src/main/java/com/gachtaxi/global/config/SecurityConfig.java b/src/main/java/com/gachtaxi/global/config/SecurityConfig.java new file mode 100644 index 00000000..ddaf21c4 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.gachtaxi.global.config; + + +import com.gachtaxi.global.auth.jwt.authentication.CustomAccessDeniedHandler; +import com.gachtaxi.global.auth.jwt.authentication.CustomAuthenticationEntryPoint; +import com.gachtaxi.global.auth.jwt.filter.JwtAuthenticationFilter; +import com.gachtaxi.global.auth.jwt.util.JwtExtractor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +import static com.gachtaxi.domain.members.entity.enums.Role.*; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final PermitUrlConfig permitUrlConfig; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final JwtExtractor jwtExtractor; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable); + + http.authorizeHttpRequests((auth) -> auth + .requestMatchers(permitUrlConfig.getPublicUrl()).permitAll() + .requestMatchers(permitUrlConfig.getMemberUrl()).hasAnyRole(MEMBER.name(), ADMIN.name()) + .requestMatchers(permitUrlConfig.getAdminUrl()).hasRole(ADMIN.name()) + .anyRequest().authenticated()); + + http.exceptionHandling(e -> e + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler)); + http.addFilterBefore(new JwtAuthenticationFilter(jwtExtractor), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + // configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000")); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setExposedHeaders(Arrays.asList("Authorization", "Set-Cookie")); + configuration.setAllowCredentials(true); + + // /** λ“€μ–΄μ˜€λŠ” λͺ¨λ“  μœ ν˜•μ˜ URL νŒ¨ν„΄μ„ ν—ˆμš©. + UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource(); + urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", configuration); + return urlBasedCorsConfigurationSource; + } + +} diff --git a/src/main/java/com/gachtaxi/global/config/SesClientConfig.java b/src/main/java/com/gachtaxi/global/config/SesClientConfig.java new file mode 100644 index 00000000..b7274ab0 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/SesClientConfig.java @@ -0,0 +1,32 @@ +package com.gachtaxi.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ses.SesClient; + +@Configuration +public class SesClientConfig { + + @Value("${aws.ses.accessKey}") + private String accessKey; + + @Value("${aws.ses.secretKey}") + private String secretKey; + + @Bean + public SesClient sesClient() { + AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey)); + + return SesClient.builder() + .region(Region.AP_SOUTHEAST_2) + .credentialsProvider(credentialsProvider) + .build(); + } + +} diff --git a/src/main/java/com/gachtaxi/global/config/SwaggerConfig.java b/src/main/java/com/gachtaxi/global/config/SwaggerConfig.java index 33cd786a..db0584cd 100644 --- a/src/main/java/com/gachtaxi/global/config/SwaggerConfig.java +++ b/src/main/java/com/gachtaxi/global/config/SwaggerConfig.java @@ -1,25 +1,47 @@ package com.gachtaxi.global.config; import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import static com.gachtaxi.global.auth.jwt.util.JwtProvider.ACCESS_TOKEN_SUBJECT; + @Configuration public class SwaggerConfig { + private static final String SCHEMA_NAME = "bearerAuth"; + @Bean public OpenAPI openAPI() { + SecurityScheme accessSecurityScheme = getAccessSecurityScheme(); + SecurityRequirement securityRequirement = new SecurityRequirement().addList(SCHEMA_NAME); + return new OpenAPI() - .components(new Components()) + .addServersItem(new Server().url("/")) + .components(new Components() + .addSecuritySchemes(SCHEMA_NAME, accessSecurityScheme)) + .addSecurityItem(securityRequirement) .info(apiInfo()); } - private Info apiInfo(){ + private Info apiInfo() { return new Info() .title("GachTaxi API Specifications") .description("κ°€μΉ˜νƒμ‹œ REST API μŠ€μ›¨κ±° λͺ…μ„Έμ„œ") .version("1.0.0"); } + + private SecurityScheme getAccessSecurityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name(ACCESS_TOKEN_SUBJECT); + } } diff --git a/src/main/java/com/gachtaxi/global/config/WebSocketConfig.java b/src/main/java/com/gachtaxi/global/config/WebSocketConfig.java index 09f70d49..ead72f35 100644 --- a/src/main/java/com/gachtaxi/global/config/WebSocketConfig.java +++ b/src/main/java/com/gachtaxi/global/config/WebSocketConfig.java @@ -1,6 +1,10 @@ package com.gachtaxi.global.config; +import com.gachtaxi.domain.chat.stomp.CustomChannelInterceptor; +import com.gachtaxi.global.common.exception.handler.StompExceptionHandler; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; @@ -10,18 +14,29 @@ @Configuration @EnableWebSocket @EnableWebSocketMessageBroker +@RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final CustomChannelInterceptor customChannelInterceptor; + private final StompExceptionHandler stompExceptionHandler; + @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry + .setErrorHandler(stompExceptionHandler) .addEndpoint("/ws") - .setAllowedOriginPatterns("http://localhost:3000"); + .setAllowedOriginPatterns("http://localhost:3000", "https://*.amplifyapp.com", "https://gachtaxi.site") + .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/sub"); + registry.enableSimpleBroker("/sub", "/queue"); registry.setApplicationDestinationPrefixes("/pub"); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(customChannelInterceptor); + } } diff --git a/src/main/java/com/gachtaxi/global/config/kafka/DefaultKafkaProducerConfig.java b/src/main/java/com/gachtaxi/global/config/kafka/DefaultKafkaProducerConfig.java new file mode 100644 index 00000000..d2a79cbc --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/kafka/DefaultKafkaProducerConfig.java @@ -0,0 +1,42 @@ +package com.gachtaxi.global.config.kafka; + +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +@Configuration +public class DefaultKafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Primary + @Bean + public ProducerFactory producerFactory() { + Map configs = new HashMap<>(); + configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + configs.put(ProducerConfig.ACKS_CONFIG, "all"); + configs.put(ProducerConfig.RETRIES_CONFIG, 3); + + return new DefaultKafkaProducerFactory<>(configs); + } + + @Primary + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} \ No newline at end of file diff --git a/src/main/java/com/gachtaxi/global/config/kafka/KafkaBeanRegistrar.java b/src/main/java/com/gachtaxi/global/config/kafka/KafkaBeanRegistrar.java new file mode 100644 index 00000000..dc2b3472 --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/kafka/KafkaBeanRegistrar.java @@ -0,0 +1,102 @@ +package com.gachtaxi.global.config.kafka; + +import static com.gachtaxi.global.auth.jwt.util.kafka.KafkaBeanSuffix.KAFKA_TEMPLATE_SUFFIX; +import static com.gachtaxi.global.auth.jwt.util.kafka.KafkaBeanSuffix.NEW_TOPIC_SUFFIX; +import static com.gachtaxi.global.auth.jwt.util.kafka.KafkaBeanSuffix.PRODUCER_FACTORY_SUFFIX; + +import com.gachtaxi.global.auth.jwt.util.KafkaBeanUtils; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.stereotype.Component; + +@Component +public class KafkaBeanRegistrar implements BeanDefinitionRegistryPostProcessor, EnvironmentAware { + + private Environment environment; + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) + throws BeansException { + Map topics = Binder.get(environment) + .bind("gachtaxi.kafka.topics", Bindable.mapOf(String.class, String.class)) + .orElse(Collections.emptyMap()); + + for (String topic : topics.values()) { + this.registerProducerFactoryAndKafkaTemplate(topic, registry); + this.registerNewTopic(topic, registry); + } + } + + private void registerKafkaTemplate(String topic, String producerFactoryBeanName, BeanDefinitionRegistry registry) { + String kafkaTemplateBeanName = KafkaBeanUtils.getBeanName(topic, KAFKA_TEMPLATE_SUFFIX); + AbstractBeanDefinition kafkaTemplateBeanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(KafkaTemplate.class) + .addConstructorArgReference(producerFactoryBeanName) + .getBeanDefinition(); + + registry.registerBeanDefinition(kafkaTemplateBeanName, kafkaTemplateBeanDefinition); + } + + private void registerNewTopic(String topic, BeanDefinitionRegistry registry) { + short partitionCount = Short.valueOf(this.environment.getProperty("gachtaxi.kafka.partition-count")); + short replicationFactor = Short.valueOf(this.environment.getProperty("gachtaxi.kafka.replication-factor")); + + String topicBeanName = KafkaBeanUtils.getBeanName(topic, NEW_TOPIC_SUFFIX); + AbstractBeanDefinition newTopicBeanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(NewTopic.class) + .addConstructorArgValue(topic) + .addConstructorArgValue(partitionCount) + .addConstructorArgValue(replicationFactor) + .getBeanDefinition(); + + registry.registerBeanDefinition(topicBeanName, newTopicBeanDefinition); + } + + private void registerProducerFactoryAndKafkaTemplate(String topic, BeanDefinitionRegistry registry) { + String producerBeanName = KafkaBeanUtils.getBeanName(topic, PRODUCER_FACTORY_SUFFIX); + AbstractBeanDefinition producerBeanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(DefaultKafkaProducerFactory.class) + .addConstructorArgValue(this.getProducerOptions()) + .getBeanDefinition(); + + registry.registerBeanDefinition(producerBeanName, producerBeanDefinition); + + this.registerKafkaTemplate(topic, producerBeanName, registry); + } + + private Map getProducerOptions() { + String bootstrapServers = this.environment.getProperty("spring.kafka.bootstrap-servers"); + + Map configs = new HashMap<>(); + configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + configs.put(ProducerConfig.ACKS_CONFIG, "all"); + configs.put(ProducerConfig.RETRIES_CONFIG, 3); + + return configs; + } + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } +} diff --git a/src/main/java/com/gachtaxi/global/config/kafka/KafkaConsumerConfig.java b/src/main/java/com/gachtaxi/global/config/kafka/KafkaConsumerConfig.java new file mode 100644 index 00000000..25a925ea --- /dev/null +++ b/src/main/java/com/gachtaxi/global/config/kafka/KafkaConsumerConfig.java @@ -0,0 +1,153 @@ +package com.gachtaxi.global.config.kafka; + +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchMemberJoinedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCancelledEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCompletedEvent; +import com.gachtaxi.domain.matching.event.dto.kafka_topic.MatchRoomCreatedEvent; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + // MatchRoomCreatedEvent + @Bean + public ConsumerFactory matchRoomCreatedEventConsumerFactory() { + Map configs = new HashMap<>(); + configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + JsonDeserializer jsonDeserializer = + new JsonDeserializer<>(MatchRoomCreatedEvent.class); + jsonDeserializer.addTrustedPackages("com.gachtaxi.domain.matching.event.dto"); + + return new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), jsonDeserializer); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory matchRoomCreatedEventListenerFactory() { + ConcurrentKafkaListenerContainerFactory factory + = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(matchRoomCreatedEventConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } + + // MatchMemberJoinedEvent + @Bean + public ConsumerFactory matchMemberJoinedEventConsumerFactory() { + Map configs = new HashMap<>(); + configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + JsonDeserializer jsonDeserializer = + new JsonDeserializer<>(MatchMemberJoinedEvent.class); + jsonDeserializer.addTrustedPackages("com.gachtaxi.domain.matching.event.dto"); + + return new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), jsonDeserializer); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory matchMemberJoinedEventListenerFactory() { + ConcurrentKafkaListenerContainerFactory factory + = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(matchMemberJoinedEventConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } + + // MatchMemberCanceledEvent + @Bean + public ConsumerFactory matchMemberCancelledEventConsumerFactory() { + Map configs = new HashMap<>(); + configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + JsonDeserializer jsonDeserializer = + new JsonDeserializer<>(MatchMemberCancelledEvent.class); + jsonDeserializer.addTrustedPackages("com.gachtaxi.domain.matching.event.dto"); + + return new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), jsonDeserializer); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory matchMemberCancelledEventListenerFactory() { + ConcurrentKafkaListenerContainerFactory factory + = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(matchMemberCancelledEventConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } + + // MatchRoomCancelledEvent + @Bean + public ConsumerFactory matchRoomCancelledEventConsumerFactory() { + Map configs = new HashMap<>(); + configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + JsonDeserializer jsonDeserializer = + new JsonDeserializer<>(MatchRoomCancelledEvent.class); + jsonDeserializer.addTrustedPackages("com.gachtaxi.domain.matching.event.dto"); + + return new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), jsonDeserializer); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory matchRoomCancelledEventListenerFactory() { + ConcurrentKafkaListenerContainerFactory factory + = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(matchRoomCancelledEventConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } + + // MatchRoomCompleted + @Bean + public ConsumerFactory matchRoomCompletedEventConsumerFactory() { + Map configs = new HashMap<>(); + configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configs.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + JsonDeserializer jsonDeserializer = + new JsonDeserializer<>(MatchRoomCompletedEvent.class); + jsonDeserializer.addTrustedPackages("com.gachtaxi.domain.matching.event.dto"); + + return new DefaultKafkaConsumerFactory<>(configs, new StringDeserializer(), jsonDeserializer); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory matchRoomCompletedEventListenerFactory() { + ConcurrentKafkaListenerContainerFactory factory + = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(matchRoomCompletedEventConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a264e0fb..03fd3d6f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -11,15 +11,69 @@ spring: dialect: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: update -# data: -# redis: -# host: ${REDIS_HOST} -# port: ${REDIS_PORT} -# password: ${REDIS_PASSWORD} -# mongodb: -# uri: ${MONGODB_URI} + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + producer: + retries: ${KAFKA_PRODUCER_RETRIES} + acks: ${KAFKA_PRODUCER_ACKS} + properties: + enable.idempotence: ${KAFKA_PRODUCER_ENABLE_IDEMPOTENCE} + max.in.flight.requests.per.connection: ${KAFKA_PRODUCER_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION} + consumer: + group-id: ${KAFKA_CONSUMER_GROUP_ID} + auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET} + enable-auto-commit: ${KAFKA_CONSUMER_ENABLE_AUTO_COMMIT} + admin: + properties: + client.id: ${KAFKA_ADMIN_CLIENT_ID} + mongodb: + uri: ${MONGODB_URI} logging: level: org.springframework.messaging: debug - org.springframework.web.socket: debug \ No newline at end of file + org.springframework.web.socket: debug + +gachtaxi: + auth: + kakao: + client: ${KAKAO_CLIENT_API_KEY} + redirect: ${KAKAO_REDIRECT_URL} + token_uri: ${KAKAO_TOKEN_URI} + user_profile: ${KAKAO_USER_PROFILE} + google: + client: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect: ${GOOGLE_REDIRECT_URL} + token_uri: ${GOOGLE_TOKEN_URI} + user_profile: ${GOOGLE_USER_PROFILE} + jwt: + key: ${JWT_SECRET_KEY} + accessTokenExpiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refreshTokenExpiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + tmpAccessTokenExpiration: ${JWT_TMP_ACCESS_TOKEN_EXPIRATION} + cookieMaxAge: ${JWT_COOKIE_MAX_AGE} + secureOption: ${COOKIE_SECURE_OPTION} + cookiePathOption: ${COOKIE_PATH_OPTION} + redis: + emailAuthCodeExpiration: ${REDIS_EMAIL_AUTH_CODE_EXPIRATION} + kafka: + topics: + match-room-created: ${KAFKA_TOPIC_MATCH_ROOM_CREATED} + match-member-joined: ${KAFKA_TOPIC_MATCH_MEMBER_JOINED} + match-room-cancelled: ${KAFKA_TOPIC_MATCH_ROOM_CANCELLED} + match-member-cancelled: ${KAFKA_TOPIC_MATCH_MEMBER_CANCELLED} + match-room-completed: ${KAFKA_TOPIC_MATCH_ROOM_COMPLETED} + partition-count: ${KAFKA_PARTITION_COUNT} + replication-factor: ${KAFKA_REPLICATION_FACTOR} + matching: + auto-matching-max-capacity: ${AUTO_MATCHING_MAX_CAPACITY} + auto-matching-description: ${AUTO_MATCHING_DESCRIPTION} + +firebase: + adminSdk: ${FIREBASE_ADMIN_SDK_PATH} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 1fd1bdc7..c288c65c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -16,10 +16,65 @@ spring: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD} -# mongodb: -# uri: ${MONGODB_URI} + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + producer: + retries: ${KAFKA_PRODUCER_RETRIES} + acks: ${KAFKA_PRODUCER_ACKS} + properties: + enable.idempotence: ${KAFKA_PRODUCER_ENABLE_IDEMPOTENCE} + max.in.flight.requests.per.connection: ${KAFKA_PRODUCER_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION} + consumer: + group-id: ${KAFKA_CONSUMER_GROUP_ID} + auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET} + enable-auto-commit: ${KAFKA_CONSUMER_ENABLE_AUTO_COMMIT} + admin: + properties: + client.id: ${KAFKA_ADMIN_CLIENT_ID} + mongodb: + uri: ${MONGODB_URI} logging: level: org.springframework.messaging: debug - org.springframework.web.socket: debug \ No newline at end of file + org.springframework.web.socket: debug + +gachtaxi: + auth: + kakao: + client: ${KAKAO_CLIENT_API_KEY} + redirect: ${KAKAO_REDIRECT_URL} + token_uri: ${KAKAO_TOKEN_URI} + user_profile: ${KAKAO_USER_PROFILE} + google: + client: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect: ${GOOGLE_REDIRECT_URL} + token_uri: ${GOOGLE_TOKEN_URI} + user_profile: ${GOOGLE_USER_PROFILE} + jwt: + key: ${JWT_SECRET_KEY} + accessTokenExpiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refreshTokenExpiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + tmpAccessTokenExpiration: ${JWT_TMP_ACCESS_TOKEN_EXPIRATION} + cookieMaxAge: ${JWT_COOKIE_MAX_AGE} + secureOption: ${COOKIE_SECURE_OPTION} + cookiePathOption: ${COOKIE_PATH_OPTION} + redis: + emailAuthCodeExpiration: ${REDIS_EMAIL_AUTH_CODE_EXPIRATION} + kafka: + topics: + match-room-created: ${KAFKA_TOPIC_MATCH_ROOM_CREATED} + match-member-joined: ${KAFKA_TOPIC_MATCH_MEMBER_JOINED} + match-member-cancelled: ${KAFKA_TOPIC_MATCH_MEMBER_CANCELLED} + match-room-cancelled: ${KAFKA_TOPIC_MATCH_ROOM_CANCELLED} + match-room-completed: ${KAFKA_TOPIC_MATCH_ROOM_COMPLETED} + partition-count: ${KAFKA_PARTITION_COUNT} + replication-factor: ${KAFKA_REPLICATION_FACTOR} + matching: + auto-matching-max-capacity: ${AUTO_MATCHING_MAX_CAPACITY} + auto-matching-description: ${AUTO_MATCHING_DESCRIPTION} + + +firebase: + adminSdk: ${FIREBASE_ADMIN_SDK_PATH} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 76631a5f..a593b318 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -17,4 +17,7 @@ spring: # port: ${REDIS_PORT} # password: ${REDIS_PASSWORD} # mongodb: -# uri: ${MONGODB_URI} \ No newline at end of file +# uri: ${MONGODB_URI} + +firebase: + adminSdk: ${FIREBASE_ADMIN_SDK_PATH} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b7efa8b7..f1dddd41 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,4 +11,24 @@ springdoc: tags-sorter: alpha chat: - topic: ${CHAT_TOPIC} \ No newline at end of file + topic: ${CHAT_TOPIC} + +aws: + ses: + accessKey: ${AWS_SES_ACCESS_KEY} + secretKey: ${AWS_SES_SECRET_KEY} + from: ${AWS_SES_FROM} + templateName: ${AWS_SES_TEMPLATE_NAME} + +cloud: + aws: + s3: + bucket: ${S3_BUCKET} + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ap-northeast-2 + auto: false + stack: + auto: false \ No newline at end of file