diff --git a/.github/workflows/backend-CI.yml b/.github/workflows/backend-CI.yml index 4cd376ad..2c2e7565 100644 --- a/.github/workflows/backend-CI.yml +++ b/.github/workflows/backend-CI.yml @@ -2,14 +2,13 @@ name: Java CI with Gradle on: pull_request: - branches: [dev] + branches: [ dev ] permissions: contents: read jobs: build: - if: contains(github.event.pull_request.labels.*.name, 'Backend') runs-on: ubuntu-22.04 permissions: pull-requests: write @@ -20,7 +19,7 @@ jobs: with: fetch-depth: 3 token: ${{ secrets.SUBMODULE_TOKEN }} - submodules: true + submodules: true - run: git log --pretty=oneline - name: JDK 21을 설치합니다. @@ -34,3 +33,69 @@ jobs: - name: Gradle을 통해 빌드합니다. run: ./gradlew build + + - name: 성공 메시지 전송 + if: ${{ success() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "테스트 성공 ~ 파워 엉덩이 확인하자 ~ 🥰🥰 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 10478271, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] + + - name: 실패 메시지 전송 + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "테스트 실패... 달리 엉덩이 가져와 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 13458524, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] diff --git a/.github/workflows/backend-dev-CICD.yml b/.github/workflows/backend-dev-CICD.yml index 9243f3f4..07a4d7c0 100644 --- a/.github/workflows/backend-dev-CICD.yml +++ b/.github/workflows/backend-dev-CICD.yml @@ -2,7 +2,7 @@ name: Dev CI/CD on: push: - branches: [dev] + branches: [ dev ] permissions: contents: read @@ -38,6 +38,73 @@ jobs: run: cp -f build/libs/*.jar /home/ubuntu/deploy/ - name: 배포 스크립트 실행 - run: | + run: | cd /home/ubuntu/deploy sh deploy.sh + + - name: 성공 메시지 전송 + if: ${{ success() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "배포 성공 ~ 파워 엉덩이 확인하자 ~ 🥰🥰 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 10478271, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] + + - name: 실패 메시지 전송 + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "배포 실패... 달리 엉덩이 가져와 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 13458524, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] + diff --git a/.github/workflows/backend-prod-CICD.yml b/.github/workflows/backend-prod-CICD.yml index c9b425ad..2447d6fc 100644 --- a/.github/workflows/backend-prod-CICD.yml +++ b/.github/workflows/backend-prod-CICD.yml @@ -2,7 +2,7 @@ name: Prod CI/CD on: push: - branches: [prod] + branches: [ prod ] permissions: contents: read @@ -58,3 +58,70 @@ jobs: script: | cd ~/deploy sh deploy.sh + + - name: 성공 메시지 전송 + if: ${{ success() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "배포 성공 ~ 파워 엉덩이 확인하자 ~ 🥰🥰 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 10478271, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] + + - name: 실패 메시지 전송 + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.3.2 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} + DISCORD_USERNAME: Mafia-Together-BOT + DISCORD_AVATAR: https://i.namu.wiki/i/vMYV9DNseNCfKzaPIDshk7eG_7pAZm8BG6c2I_s6XXwyreDbpu3A1nDRUpbqvycQrRvgbSJeg15iXSSiGK5xyw.webp + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.pull_request.user.login }}", + "url": "https://github.com/pknu-wap/WAPP/blob/main/image/icon.png?raw=true", + "icon_url": "${{ github.event.pull_request.user.avatar_url }}" + }, + "title": "배포 실패... 달리 엉덩이 가져와 \n#${{ github.event.pull_request.number }} : ${{ github.event.pull_request.title }}", + "color": 13458524, + "description": "${{ github.event.pull_request.html_url }}", + "fields": [ + { + "name": "Base Branch", + "value": "${{ github.base_ref }}", + "inline": true + }, + { + "name": "Compare Branch", + "value": "${{ github.head_ref }}", + "inline": true + } + ] + } + ] + diff --git a/README.md b/README.md index abb9d6ef..3b255975 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ ## 기술 스택 --- -![Skill](https://github.com/user-attachments/assets/e5b18101-c81d-4c54-9001-e9d5772c4c5f) +![Skill](https://github.com/user-attachments/assets/4695beb1-2ff3-4649-b6ee-80c67900a2a9) ## 인프라 아키텍쳐 --- -![infra-architecture](https://github.com/user-attachments/assets/e7db379d-8218-47f1-995c-9fa7efd4ce38) +![infra-architecture](https://github.com/user-attachments/assets/ffc7e50e-a9b1-4168-9969-42f554551790) + ## 실시간 통신 --- diff --git a/backend-submodule b/backend-submodule index 7ebcff22..6ea3cb6a 160000 --- a/backend-submodule +++ b/backend-submodule @@ -1 +1 @@ -Subproject commit 7ebcff2298759892c7a1348841fce473feb761ea +Subproject commit 6ea3cb6a51cbd6978511c17024498ffb3dfb3d95 diff --git a/build.gradle b/build.gradle index 646983ca..06ff0c11 100644 --- a/build.gradle +++ b/build.gradle @@ -21,8 +21,19 @@ repositories { mavenCentral() } +ext { + set('springCloudVersion', "2021.0.4") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.37.0' testImplementation 'org.testcontainers:testcontainers' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -35,11 +46,10 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + testImplementation 'org.mockito:mockito-core:5.10.0' } tasks.named('test') { diff --git a/src/main/java/mafia/mafiatogether/MafiaTogetherApplication.java b/src/main/java/mafia/mafiatogether/MafiaTogetherApplication.java index 48aa2921..daa3d41d 100644 --- a/src/main/java/mafia/mafiatogether/MafiaTogetherApplication.java +++ b/src/main/java/mafia/mafiatogether/MafiaTogetherApplication.java @@ -9,4 +9,5 @@ public class MafiaTogetherApplication { public static void main(String[] args) { SpringApplication.run(MafiaTogetherApplication.class, args); } + } diff --git a/src/main/java/mafia/mafiatogether/chat/application/ChatService.java b/src/main/java/mafia/mafiatogether/chat/application/ChatService.java index 5b72570f..e9a2fa6b 100644 --- a/src/main/java/mafia/mafiatogether/chat/application/ChatService.java +++ b/src/main/java/mafia/mafiatogether/chat/application/ChatService.java @@ -1,51 +1,61 @@ package mafia.mafiatogether.chat.application; -import java.time.Clock; -import java.util.List; - import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.chat.application.dto.request.ChatRequest; import mafia.mafiatogether.chat.application.dto.response.ChatResponse; import mafia.mafiatogether.chat.domain.Chat; import mafia.mafiatogether.chat.domain.ChatRepository; import mafia.mafiatogether.chat.domain.Message; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.common.exception.GameException; -import mafia.mafiatogether.job.domain.PlayerJob; -import mafia.mafiatogether.job.domain.PlayerJobRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.function.Supplier; + @Service @RequiredArgsConstructor public class ChatService { - private final PlayerJobRepository playerJobRepository; private final ChatRepository chatRepository; @Transactional(readOnly = true) public List findAllChat(final String code, final String name) { - final PlayerJob playerJobs = playerJobRepository.findById(code) - .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); final Chat chat = chatRepository.findById(code) .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); - final boolean isMafia = playerJobs.isMafia(name); - return chat.getMessages().stream() + + return chat.getMessages() + .stream() .map(message -> ChatResponse.of( message, - message.getName().equals(name), - isMafia, - playerJobs.findJobByName(message.getName()).getJobType() - )) - .toList(); + message.getName().equals(name) + )).toList(); } @Transactional - public void saveChat(final String code, final String name, final ChatRequest chatRequest) { - final Chat chat = chatRepository.findById(code) - .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); - final Message message = Message.ofChat(name, chatRequest.content()); + public Message enter(final String name, final String code) { + return saveChat(code, () -> Message.fromEnter(name)); + } + + @Transactional + public Message leave(final String name, final String code) { + return saveChat(code, () -> Message.fromLeave(name)); + } + + @Transactional + public Message chat(final String name, final String code, final String content) { + return saveChat(code, () -> Message.ofChat(name, content)); + } + + private Message saveChat(final String code, Supplier messageFunction) { + Chat chat = chatRepository.findById(code) + .orElseThrow(NoSuchElementException::new); + Message message = messageFunction.get(); chat.saveMessage(message); chatRepository.save(chat); + return message; } + + } diff --git a/src/main/java/mafia/mafiatogether/chat/application/ChatV2Service.java b/src/main/java/mafia/mafiatogether/chat/application/ChatV2Service.java deleted file mode 100644 index 8d037bad..00000000 --- a/src/main/java/mafia/mafiatogether/chat/application/ChatV2Service.java +++ /dev/null @@ -1,61 +0,0 @@ -package mafia.mafiatogether.chat.application; - -import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.chat.application.dto.ChatV2Response; -import mafia.mafiatogether.chat.domain.Chat; -import mafia.mafiatogether.chat.domain.ChatRepository; -import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.common.exception.ExceptionCode; -import mafia.mafiatogether.common.exception.GameException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.NoSuchElementException; -import java.util.function.Supplier; - -@Service -@RequiredArgsConstructor -public class ChatV2Service { - - private final ChatRepository chatRepository; - - @Transactional(readOnly = true) - public List findAllChat(final String code, final String name) { - final Chat chat = chatRepository.findById(code) - .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); - - return chat.getMessages() - .stream() - .map(message -> ChatV2Response.of( - message, - message.getName().equals(name) - )).toList(); - } - - @Transactional - public Message enter(final String name, final String code) { - return saveChat(code, () -> Message.fromEnter(name)); - } - - @Transactional - public Message leave(final String name, final String code) { - return saveChat(code, () -> Message.fromLeave(name)); - } - - @Transactional - public Message chat(final String name, final String code, final String content) { - return saveChat(code, () -> Message.ofChat(name, content)); - } - - private Message saveChat(final String code, Supplier messageFunction) { - Chat chat = chatRepository.findById(code) - .orElseThrow(NoSuchElementException::new); - Message message = messageFunction.get(); - chat.saveMessage(message); - chatRepository.save(chat); - return message; - } - - -} diff --git a/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java b/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java deleted file mode 100644 index a5dce74d..00000000 --- a/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java +++ /dev/null @@ -1,29 +0,0 @@ -package mafia.mafiatogether.chat.application.dto; - -import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.chat.domain.vo.MessageType; - -import java.sql.Timestamp; - - -public record ChatV2Response( - String name, - String contents, - MessageType messageType, - Timestamp timestamp, - Boolean isOwner -) { - - public static ChatV2Response of( - Message message, - boolean isOwner - ) { - return new ChatV2Response( - message.getName(), - message.getContents(), - message.getMessageType(), - new Timestamp(message.getTimestamp()), - isOwner - ); - } -} diff --git a/src/main/java/mafia/mafiatogether/chat/application/dto/response/ChatResponse.java b/src/main/java/mafia/mafiatogether/chat/application/dto/response/ChatResponse.java index da94b523..1a7c2ce9 100644 --- a/src/main/java/mafia/mafiatogether/chat/application/dto/response/ChatResponse.java +++ b/src/main/java/mafia/mafiatogether/chat/application/dto/response/ChatResponse.java @@ -1,44 +1,29 @@ package mafia.mafiatogether.chat.application.dto.response; -import java.sql.Timestamp; import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.job.domain.jobtype.JobType; +import mafia.mafiatogether.chat.domain.vo.MessageType; + +import java.sql.Timestamp; + public record ChatResponse( String name, - String contents, + String content, + MessageType messageType, Timestamp timestamp, - Boolean isOwner, - JobType job + Boolean isOwner ) { - public static ChatResponse of( Message message, - boolean isOwner, - boolean isMafia, - JobType jobType + boolean isOwner ) { return new ChatResponse( message.getName(), - message.getContents(), + message.getContent(), + message.getMessageType(), new Timestamp(message.getTimestamp()), - isOwner, - filteringMafia(isOwner, isMafia, jobType) + isOwner ); } - - private static JobType filteringMafia( - final boolean isOwner, - final boolean isMafia, - final JobType jobType - ) { - if (isMafia && jobType.equals(JobType.MAFIA)) { - return jobType; - } - if (isOwner) { - return jobType; - } - return null; - } } diff --git a/src/main/java/mafia/mafiatogether/chat/domain/Message.java b/src/main/java/mafia/mafiatogether/chat/domain/Message.java index afd0ea95..1a0a30b3 100644 --- a/src/main/java/mafia/mafiatogether/chat/domain/Message.java +++ b/src/main/java/mafia/mafiatogether/chat/domain/Message.java @@ -17,7 +17,7 @@ public class Message { private static final String LEAVE_MESSAGE_FORMAT = "%s님이 퇴장하셨습니다."; private String name; - private String contents; + private String content; private MessageType messageType; private long timestamp; diff --git a/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java index 0b839645..6bd5d51f 100644 --- a/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java +++ b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java @@ -1,41 +1,58 @@ package mafia.mafiatogether.chat.ui; -import jakarta.validation.Valid; - import java.util.List; - import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.common.annotation.PlayerInfo; +import mafia.mafiatogether.chat.annotation.SendToChatWithRedis; import mafia.mafiatogether.chat.application.ChatService; import mafia.mafiatogether.chat.application.dto.request.ChatRequest; import mafia.mafiatogether.chat.application.dto.response.ChatResponse; +import mafia.mafiatogether.chat.domain.Message; +import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; 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 +@Controller @RequiredArgsConstructor -@RequestMapping("/chat") public class ChatController { private final ChatService chatService; - @GetMapping + @GetMapping("/chat") public ResponseEntity> findAllChat(@PlayerInfo PlayerInfoDto playerInfoDto) { return ResponseEntity.ok(chatService.findAllChat(playerInfoDto.code(), playerInfoDto.name())); } - @PostMapping - public ResponseEntity saveChat( - @PlayerInfo PlayerInfoDto playerInfoDto, - @Valid @RequestBody ChatRequest request + @MessageMapping("/chat/enter/{code}/{name}") + @SendToChatWithRedis("/sub/chat/{code}") + public Message enterChat( + @DestinationVariable("code") String code, + @DestinationVariable("name") String name ) { - chatService.saveChat(playerInfoDto.code(), playerInfoDto.name(), request); - return ResponseEntity.status(HttpStatus.CREATED).build(); + return chatService.enter(name, code); } + + @MessageMapping("/chat/leave/{code}/{name}") + @SendToChatWithRedis("/sub/chat/{code}") + public Message leaveChat( + @DestinationVariable("code") String code, + @DestinationVariable("name") String name + ) { + return chatService.leave(name, code); + } + + @MessageMapping("/chat/{code}/{name}") + @SendToChatWithRedis("/sub/chat/{code}") + public Message createChat( + @DestinationVariable("code") String code, + @DestinationVariable("name") String name, + @Payload ChatRequest request + ) { + return chatService.chat(name, code, request.content()); + } + } diff --git a/src/main/java/mafia/mafiatogether/chat/ui/ChatV2Controller.java b/src/main/java/mafia/mafiatogether/chat/ui/ChatV2Controller.java deleted file mode 100644 index 115a5f9c..00000000 --- a/src/main/java/mafia/mafiatogether/chat/ui/ChatV2Controller.java +++ /dev/null @@ -1,60 +0,0 @@ -package mafia.mafiatogether.chat.ui; - -import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.chat.annotation.SendToChatWithRedis; -import mafia.mafiatogether.chat.application.ChatV2Service; -import mafia.mafiatogether.chat.application.dto.ChatV2Response; -import mafia.mafiatogether.chat.application.dto.request.ChatRequest; -import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.common.annotation.PlayerInfo; -import mafia.mafiatogether.common.resolver.PlayerInfoDto; -import org.springframework.http.ResponseEntity; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -import java.util.List; - -@Controller -@RequiredArgsConstructor -public class ChatV2Controller { - - private final ChatV2Service chatService; - - @GetMapping("/v2/chat") - public ResponseEntity> findAllChat(@PlayerInfo PlayerInfoDto playerInfoDto) { - return ResponseEntity.ok(chatService.findAllChat(playerInfoDto.code(), playerInfoDto.name())); - } - - @MessageMapping("/chat/enter/{code}/{name}") - @SendToChatWithRedis("/sub/chat/{code}") - public Message enterChat( - @DestinationVariable("code") String code, - @DestinationVariable("name") String name - ) { - return chatService.enter(name, code); - } - - @MessageMapping("/chat/leave/{code}/{name}") - @SendToChatWithRedis("/sub/chat/{code}") - public Message leaveChat( - @DestinationVariable("code") String code, - @DestinationVariable("name") String name - ) { - return chatService.leave(name, code); - } - - @MessageMapping("/chat/{code}/{name}") - @SendToChatWithRedis("/sub/chat/{code}") - public Message createChat( - @DestinationVariable("code") String code, - @DestinationVariable("name") String name, - @Payload ChatRequest request - ) { - return chatService.chat(name, code, request.content()); - } - - -} diff --git a/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java new file mode 100644 index 00000000..03f6c7b8 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java @@ -0,0 +1,33 @@ +package mafia.mafiatogether.common.annotation; + +import java.lang.annotation.*; + +/** + * 이 어노테이션은 서비스단에서 락대상을 정하는 메서드입니다. + * 해당 메서드에서 조회하는 모든 키에 대해 Lock을 거는 것이 아닌 특정 key에만 설정해줍니다. + * 또한 이 Lock이 어노테이션이 없는 대상은 해당 자원에 접근할 수 있습니다. + * 이 어노테이션은 이 어노테이션을 사용하는 메서드끼리만의 자원 접근을 막을 수 있습니다. + *

+ * 작성자: waterricecake + *

+ * 수정일시 : 20241021 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedisLock { + /** + * key()는 자원의 유니크한 키를 사용하기 위해 사용합니다. + *
+     *  ex) @RedisLock(key = "lobby") 를 사용할 경우 키는 "mafiatogether:lock:lobby"가 완성됩니다. 이후 뒤는 랜덤한 값(code)를 사용하여 유니크하게 유지합니다.
+     * 
+ * @exception mafia.mafiatogether.common.exception.ServerException if {@link mafia.mafiatogether.common.annotation.RedisLockTarget} not exists + *

+ * 작성자: waterricecake + *

+ *

+ * 수정일시 : 20241021 + *

+ */ + String[] key(); +} diff --git a/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java new file mode 100644 index 00000000..cfe675f6 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java @@ -0,0 +1,16 @@ +package mafia.mafiatogether.common.annotation; + +import java.lang.annotation.*; + +/** + * 이 어노테이션은 {@link mafia.mafiatogether.common.annotation.RedisLock}을 사용한 메서드의 String파라미터에 사용됩니다. + *

+ * 작성자: waterricecake + *

+ * 수정일시 : 20241021 + */ +@Documented +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface RedisLockTarget { +} diff --git a/src/main/java/mafia/mafiatogether/game/annotation/SseSubscribe.java b/src/main/java/mafia/mafiatogether/common/annotation/SseSubscribe.java similarity index 73% rename from src/main/java/mafia/mafiatogether/game/annotation/SseSubscribe.java rename to src/main/java/mafia/mafiatogether/common/annotation/SseSubscribe.java index d7eca873..4cef58b7 100644 --- a/src/main/java/mafia/mafiatogether/game/annotation/SseSubscribe.java +++ b/src/main/java/mafia/mafiatogether/common/annotation/SseSubscribe.java @@ -1,4 +1,4 @@ -package mafia.mafiatogether.game.annotation; +package mafia.mafiatogether.common.annotation; import java.lang.annotation.*; @Target({ElementType.METHOD}) diff --git a/src/main/java/mafia/mafiatogether/common/application/ErrorNotificationService.java b/src/main/java/mafia/mafiatogether/common/application/ErrorNotificationService.java new file mode 100644 index 00000000..fc63c6f3 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/application/ErrorNotificationService.java @@ -0,0 +1,36 @@ +package mafia.mafiatogether.common.application; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.application.dto.ErrorDiscordMessageRequest; +import mafia.mafiatogether.common.domain.DiscordMessage; +import mafia.mafiatogether.common.domain.ErrorNotificationClient; +import mafia.mafiatogether.common.domain.WarningNotificationClient; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ErrorNotificationService { + + private final ErrorNotificationClient errorNotificationClient; + private final WarningNotificationClient warningNotificationClient; + + public void notifyError(boolean isError, String exceptionMessage) { + if (isError) { + errorNotificationClient.notifyError(DiscordMessage.createErrorDiscordMessage(exceptionMessage)); + return; + } + + warningNotificationClient.notifyWarning(DiscordMessage.createWarningDiscordMessage(exceptionMessage)); + } + + public void notifyError(boolean isError, ErrorDiscordMessageRequest errorDiscordMessageRequest) { + if (isError) { + errorNotificationClient.notifyError(DiscordMessage.createErrorDiscordMessage(errorDiscordMessageRequest.toErrorMessage())); + return; + } + + warningNotificationClient.notifyWarning(DiscordMessage.createWarningDiscordMessage(errorDiscordMessageRequest.toErrorMessage())); + } + + +} diff --git a/src/main/java/mafia/mafiatogether/common/application/dto/ErrorDiscordMessageRequest.java b/src/main/java/mafia/mafiatogether/common/application/dto/ErrorDiscordMessageRequest.java new file mode 100644 index 00000000..3bda9992 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/application/dto/ErrorDiscordMessageRequest.java @@ -0,0 +1,49 @@ +package mafia.mafiatogether.common.application.dto; + +import java.time.LocalDateTime; +import java.util.Arrays; + +public record ErrorDiscordMessageRequest( + String[] profile, + String requestUri, + String requestMethod, + String remoteAddr, + String remoteUser, + String headers, + String parameters, + Exception exception +) { + + public String toErrorMessage() { + return "### 🕖 발생 시간\n" + + LocalDateTime.now() + "\n" + + "### Profile\n" + + Arrays.toString(profile) + "\n" + + "### 📎 요청 URI\n" + + requestUri + " (" + requestMethod + ")\n" + + "### 🛠 요청자 정보\n" + + "- IP: " + remoteAddr + "\n" + + "- 사용자: " + (remoteUser != null ? remoteUser : "Unknown") + "\n" + + "- 헤더:\n" + + "```\n" + + headers + + "```\n" + + "- 요청 파라미터:\n" + + "```\n" + + parameters + + "```\n" + + "### ✅ 예외 정보\n" + + "- 예외 클래스: " + exception.getClass().getCanonicalName() + "\n" + + "- 예외 메시지: " + (exception.getMessage() != null ? exception.getMessage() : "No message") + "\n" + + "- 발생 위치: " + extractExceptionSource(exception) + "\n"; + } + + private String extractExceptionSource(Exception exception) { + StackTraceElement[] stackTrace = exception.getStackTrace(); + if (stackTrace.length > 0) { + return stackTrace[0].toString(); + } + return "Unknown Source"; + } + +} diff --git a/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java new file mode 100644 index 00000000..f9c5bd98 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java @@ -0,0 +1,91 @@ +package mafia.mafiatogether.common.aspect; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.annotation.RedisLockTarget; +import mafia.mafiatogether.common.annotation.RedisLock; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.exception.ServerException; +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.redisson.RedissonMultiLock; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +@Aspect +@Component +@RequiredArgsConstructor +public class RedisLockAspect { + + private static final String LOCK_KEY_PREFIX = "mafiatogether:lock:"; + private static final int WAIT_TIME = 5; + private static final int LEASE_TIME = 15; + private final RedissonClient redissonClient; + + @Around("@annotation(mafia.mafiatogether.common.annotation.RedisLock)") + public Object lock(final ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + final String[] keys = getRedisLockKey(proceedingJoinPoint); + final RLock[] locks = getRLocks(keys); + final RedissonMultiLock multiLock = new RedissonMultiLock(locks); + + boolean isLocked; + + try { + isLocked = multiLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new ServerException(ExceptionCode.GETTING_LOCK_FAIL_EXCEPTION); + } + + if (!isLocked) { + throw new ServerException(ExceptionCode.GETTING_LOCK_FAIL_EXCEPTION); + } + + try { + return proceedingJoinPoint.proceed(); + } finally { + multiLock.unlock(); + } + } + + private String[] getRedisLockKey(ProceedingJoinPoint proceedingJoinPoint) { + MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); + Method method = signature.getMethod(); + RedisLock redisLock = method.getAnnotation(RedisLock.class); + + Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + Object[] args = proceedingJoinPoint.getArgs(); + + for (int i = 0; i < parameterAnnotations.length; i++) { + if (hasTarget(parameterAnnotations[i])) { + return parsingToKeyList(redisLock.key(), args[i].toString()); + } + } + throw new ServerException(ExceptionCode.LOCK_CODE_EXCEPTION); + } + + private String[] parsingToKeyList(final String[] keys, final String target) { + for (int i = 0; i < keys.length; i++) { + keys[i] = LOCK_KEY_PREFIX + keys[i] + ":" + target; + } + return keys; + } + + private RLock[] getRLocks(final String[] keys) { + RLock[] rLocks = new RLock[keys.length]; + for (int i = 0; i < keys.length; i++) { + rLocks[i] = redissonClient.getLock(keys[i]); + } + return rLocks; + } + + private boolean hasTarget(Annotation[] annotations) { + return Arrays.stream(annotations).anyMatch(RedisLockTarget.class::isInstance); + } +} diff --git a/src/main/java/mafia/mafiatogether/common/aspect/SseAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/SseAspect.java new file mode 100644 index 00000000..6ec70dcd --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/aspect/SseAspect.java @@ -0,0 +1,63 @@ +package mafia.mafiatogether.common.aspect; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.annotation.PlayerInfo; +import mafia.mafiatogether.common.exception.AuthException; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.resolver.PlayerInfoDto; +import mafia.mafiatogether.common.domain.SseEmitterSession; +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.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; + +@Aspect +@Component +@RequiredArgsConstructor +public class SseAspect { + + private final SseEmitterSession sseEmitterSession; + + @Around("@annotation(mafia.mafiatogether.common.annotation.SseSubscribe)") + public Object subscribe(final ProceedingJoinPoint joinPoint) throws Throwable { + final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + final Method method = methodSignature.getMethod(); + + final Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + final Object[] args = joinPoint.getArgs(); + + final String[] codeAndName = new String[2]; + for (int i = 0; i < parameterAnnotations.length; i++) { + if (hasPlayerInfo(parameterAnnotations[i])) { + PlayerInfoDto playerInfoDto = (PlayerInfoDto) args[i]; + codeAndName[0] = playerInfoDto.code(); + codeAndName[1] = playerInfoDto.name(); + break; + } + } + + if (codeAndName[0] == null || codeAndName[1] == null) { + throw new AuthException(ExceptionCode.INVALID_AUTHENTICATION_FORM); + } + + final String code = codeAndName[0]; + final String name = codeAndName[1]; + + final SseEmitter sseEmitter = (SseEmitter) joinPoint.proceed(); + sseEmitterSession.save(code, name, sseEmitter); + sseEmitter.onCompletion(() -> sseEmitterSession.deleteByCodeAndEmitter(code, name)); + sseEmitter.onTimeout(sseEmitter::complete); + + return sseEmitter; + } + + private boolean hasPlayerInfo(final Annotation[] annotations) { + return Arrays.stream(annotations).anyMatch(PlayerInfo.class::isInstance); + } +} diff --git a/src/main/java/mafia/mafiatogether/common/config/FeignConfig.java b/src/main/java/mafia/mafiatogether/common/config/FeignConfig.java new file mode 100644 index 00000000..d9b13b61 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/config/FeignConfig.java @@ -0,0 +1,38 @@ +package mafia.mafiatogether.common.config; + +import feign.Logger; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@Configuration +@ImportAutoConfiguration({FeignAutoConfiguration.class}) +@EnableFeignClients(basePackages = "mafia.mafiatogether.common") +class FeignConfig { + + @Bean + public MappingJackson2HttpMessageConverter feignMessageConverter() { + return new MappingJackson2HttpMessageConverter(); + } + + @Bean + public RestTemplate restTemplate(List> messageConverters) { + return new RestTemplateBuilder() + .additionalMessageConverters(messageConverters) + .build(); + } + + @Bean + public Logger.Level feignLoggerLevel() { + return Logger.Level.BASIC; + } + +} diff --git a/src/main/java/mafia/mafiatogether/common/config/RedisConfig.java b/src/main/java/mafia/mafiatogether/common/config/RedisConfig.java index 1a911283..58ef5f6e 100644 --- a/src/main/java/mafia/mafiatogether/common/config/RedisConfig.java +++ b/src/main/java/mafia/mafiatogether/common/config/RedisConfig.java @@ -1,10 +1,12 @@ package mafia.mafiatogether.common.config; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @@ -17,6 +19,8 @@ @EnableRedisRepositories public class RedisConfig { + private static final String REDISSON_HOST_PREFIX = "redis://"; + @Value("${spring.data.redis.host}") private String host; @@ -24,8 +28,10 @@ public class RedisConfig { private int port; @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(host, port); + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port); + return Redisson.create(config); } @Bean diff --git a/src/main/java/mafia/mafiatogether/common/config/WebMvcConfig.java b/src/main/java/mafia/mafiatogether/common/config/WebMvcConfig.java index 988dd515..6023c3f8 100644 --- a/src/main/java/mafia/mafiatogether/common/config/WebMvcConfig.java +++ b/src/main/java/mafia/mafiatogether/common/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package mafia.mafiatogether.common.config; +import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.resolver.PlayerArgumentResolver; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; @@ -10,11 +11,14 @@ import java.util.List; @Configuration +@RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { + private final PlayerArgumentResolver playerArgumentResolver; + @Override public void addArgumentResolvers(final List resolvers) { - resolvers.add(new PlayerArgumentResolver()); + resolvers.add(playerArgumentResolver); } @Override diff --git a/src/main/java/mafia/mafiatogether/common/config/WebSocketConfig.java b/src/main/java/mafia/mafiatogether/common/config/WebSocketConfig.java index b5e20e9c..6599d160 100644 --- a/src/main/java/mafia/mafiatogether/common/config/WebSocketConfig.java +++ b/src/main/java/mafia/mafiatogether/common/config/WebSocketConfig.java @@ -1,11 +1,15 @@ package mafia.mafiatogether.common.config; - +import java.util.List; import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.common.interceptor.StompChannelInterceptor; +import mafia.mafiatogether.common.interceptor.ChatInterceptor; +import mafia.mafiatogether.common.interceptor.PathMatcherInterceptor; +import mafia.mafiatogether.common.resolver.WebsocketPlayerArgumentResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @@ -15,7 +19,8 @@ @RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - private final StompChannelInterceptor stompChannelInterceptor; + private final ChatInterceptor chatInterceptor; + private final WebsocketPlayerArgumentResolver websocketPlayerArgumentResolver; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { @@ -28,6 +33,11 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { ); } + @Override + public void addArgumentResolvers(final List argumentResolvers) { + argumentResolvers.add(websocketPlayerArgumentResolver); + } + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/pub"); @@ -36,7 +46,12 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { @Override public void configureClientInboundChannel(ChannelRegistration registry) { - registry.interceptors(stompChannelInterceptor); + registry.interceptors( + new PathMatcherInterceptor(chatInterceptor) + .includePathPattern("/sub/chat/**", StompCommand.SUBSCRIBE) + .includePathPattern("/pub/chat/**", StompCommand.MESSAGE) + .includePathPattern("/pub/chat/**", StompCommand.SEND) + ); } } diff --git a/src/main/java/mafia/mafiatogether/common/domain/DiscordMessage.java b/src/main/java/mafia/mafiatogether/common/domain/DiscordMessage.java new file mode 100644 index 00000000..9327815c --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/domain/DiscordMessage.java @@ -0,0 +1,29 @@ +package mafia.mafiatogether.common.domain; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +public record DiscordMessage( + String content, + List embeds +) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + public static DiscordMessage createWarningDiscordMessage(String message) { + return new DiscordMessage( + "# Warning이 발생했어요.. 파워 엉덩이 가져와", + List.of(Embed.createWarningEmbed(message)) + ); + } + + public static DiscordMessage createErrorDiscordMessage(String message) { + return new DiscordMessage( + "# Error가 발생했어요!!!!!!!!!!!!!! 달리 엉덩이 가져와 매 맞게", + List.of(Embed.createErrorEmbed(message)) + ); + } + +} diff --git a/src/main/java/mafia/mafiatogether/common/domain/Embed.java b/src/main/java/mafia/mafiatogether/common/domain/Embed.java new file mode 100644 index 00000000..b612431f --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/domain/Embed.java @@ -0,0 +1,23 @@ +package mafia.mafiatogether.common.domain; + +import java.io.Serial; +import java.io.Serializable; + + +public record Embed( + String title, + String description +) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + public static Embed createWarningEmbed(String message) { + return new Embed("# Warning 정보 \uD83D\uDEA8", message); + } + + public static Embed createErrorEmbed(String message) { + return new Embed("# Error 정보 \uD83D\uDEA8", message); + } + +} diff --git a/src/main/java/mafia/mafiatogether/common/domain/ErrorNotificationClient.java b/src/main/java/mafia/mafiatogether/common/domain/ErrorNotificationClient.java new file mode 100644 index 00000000..71e158f5 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/domain/ErrorNotificationClient.java @@ -0,0 +1,16 @@ +package mafia.mafiatogether.common.domain; + +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; + +@FeignClient( + name = "discord-error-notification-client", + url = "${discord.error.webhook-url}" +) +public interface ErrorNotificationClient { + + @PostMapping(produces = {"application/json"}, consumes = {"application/json"}) + void notifyError(@RequestBody DiscordMessage message); + +} diff --git a/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java b/src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java similarity index 69% rename from src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java rename to src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java index 34052959..bf28bc6c 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java @@ -1,17 +1,19 @@ -package mafia.mafiatogether.game.domain; +package mafia.mafiatogether.common.domain; import java.util.List; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -public interface SseEmitterRepository { - +public interface SseEmitterSession { void save(final String code, final String name, final SseEmitter sseEmitter); List findByCode(final String code); + SseEmitter findByCodeAndName(final String code, final String name); + void deleteByCode(final String code); void deleteByCodeAndEmitter(final String code, final String name); + } diff --git a/src/main/java/mafia/mafiatogether/common/domain/WarningNotificationClient.java b/src/main/java/mafia/mafiatogether/common/domain/WarningNotificationClient.java new file mode 100644 index 00000000..ec5b993d --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/domain/WarningNotificationClient.java @@ -0,0 +1,16 @@ +package mafia.mafiatogether.common.domain; + +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; + +@FeignClient( + name = "discord-warning-notification-client", + url = "${discord.warning.webhook-url}" +) +public interface WarningNotificationClient { + + @PostMapping(produces = {"application/json"}, consumes = {"application/json"}) + void notifyWarning(@RequestBody DiscordMessage message); + +} diff --git a/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java index 8635b156..c3f4163e 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java +++ b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java @@ -30,7 +30,12 @@ public enum ExceptionCode { NOT_FOUND_REQUEST(300, "HttpServletRequest를 찾을 수 없습니다."), MISSING_AUTHENTICATION_HEADER(301, "인증 헤더가 없습니다."), - INVALID_AUTHENTICATION_FORM(302, "올바른 인증 형식이 아닙니다."); + INVALID_AUTHENTICATION_FORM(302, "올바른 인증 형식이 아닙니다."), + + LOCK_CODE_EXCEPTION(501, "RedisLockTarget 파라미터를 찾을 수 없습니다."), + GETTING_LOCK_FAIL_EXCEPTION(502, "레디스 락을 얻는데 실패하였습니다."), + + NO_STATIC_RESOURCE(404, "리소스를 찾을 수 없습니다."); private final int code; private final String message; diff --git a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java index 5e40150b..76539a5c 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,16 @@ package mafia.mafiatogether.common.exception; +import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; import java.util.List; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import mafia.mafiatogether.common.application.ErrorNotificationService; +import mafia.mafiatogether.common.application.dto.ErrorDiscordMessageRequest; import mafia.mafiatogether.common.exception.ErrorResponse.FieldErrorResponse; +import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; @@ -13,24 +20,42 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GlobalExceptionHandler { + private final Environment environment; + private final ErrorNotificationService errorNotificationService; + private static final String LOCAL_PROFILE_NAME = "local"; + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ResponseEntity handleNoHandlerFoundException() { + final ErrorResponse errorResponse = ErrorResponse.create( + ExceptionCode.NO_STATIC_RESOURCE.getCode(), + "리소스를 찾을 수 없습니다." + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + @ExceptionHandler(Exception.class) - protected ResponseEntity Exception(Exception e) { + protected ResponseEntity Exception(Exception e, HttpServletRequest request) { final ErrorResponse errorResponse = ErrorResponse.create( ExceptionCode.UNEXPECTED_EXCEPTION.getCode(), "예기치 않은 예외가 발생 했습니다." ); log.error(e.getMessage(), e); + notifyException(true, request, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } @ExceptionHandler({BindException.class, MethodArgumentNotValidException.class}) - protected ResponseEntity BindException(BindException e) { + protected ResponseEntity BindException(BindException e, HttpServletRequest request) { List errors = new ArrayList<>(); List fieldErrors = e.getFieldErrors(); @@ -49,39 +74,116 @@ protected ResponseEntity BindException(BindException e) { ); log.error(e.getMessage(), e); + notifyException(false, request, e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @ExceptionHandler(GlobalException.class) - protected ResponseEntity GlobalException(GlobalException e) { + protected ResponseEntity GlobalException(GlobalException e, HttpServletRequest request) { final ErrorResponse errorResponse = ErrorResponse.create( e.getCodes(), e.getMessage() ); log.error(e.getMessage(), e); + notifyException(false, request, e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) protected ResponseEntity HttpRequestMethodNotSupportedException( - final HttpRequestMethodNotSupportedException e + final HttpRequestMethodNotSupportedException e, + HttpServletRequest request ) { final ErrorResponse errorResponse = ErrorResponse.create( ExceptionCode.MISSING_AUTHENTICATION_HEADER.getCode(), "HTTP 메서드를 확인해 주세요" ); + notifyException(false, request, e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @ExceptionHandler(MissingServletRequestParameterException.class) protected ResponseEntity handleMissingServletRequestParameter( - final MissingServletRequestParameterException e + final MissingServletRequestParameterException e, + HttpServletRequest request ) { final ErrorResponse errorResponse = ErrorResponse.create( ExceptionCode.INVALID_CONTENT.getCode(), "요청 파라미터를 확인해 주세요" ); + notifyException(false, request, e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } + + private void notifyException(boolean isError, HttpServletRequest request, Exception exception) { + if (Arrays.asList(environment.getActiveProfiles()).contains(LOCAL_PROFILE_NAME)) { + return; + } + String headers = getInformationFromHeader(request); + String parameters = getInformationFromParameter(request); + String fullStackTrace = getFullStackTrace(exception); + ErrorDiscordMessageRequest errorDiscordMessageRequest = new ErrorDiscordMessageRequest( + environment.getActiveProfiles(), + request.getRequestURI(), + request.getMethod(), + request.getRemoteAddr(), + request.getRemoteUser(), + headers, + parameters, + exception + ); + + errorNotificationService.notifyError( + isError, + errorDiscordMessageRequest + ); + errorNotificationService.notifyError( + isError, + "### 🗂 스택 트레이스 (부분)\n" + + "```\n" + + fullStackTrace + + "```" + ); + } + + private String getInformationFromHeader(HttpServletRequest request) { + StringBuilder headersBuilder = new StringBuilder(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headersBuilder.append(headerName) + .append(": ") + .append(request.getHeader(headerName)) + .append("\n"); + } + return headersBuilder.toString(); + } + + private String getInformationFromParameter(HttpServletRequest request) { + StringBuilder parametersBuilder = new StringBuilder(); + Enumeration parameterNames = request.getParameterNames(); + while (parameterNames.hasMoreElements()) { + String paramName = parameterNames.nextElement(); + parametersBuilder.append(paramName) + .append(": ") + .append(request.getParameter(paramName)) + .append("\n"); + } + return parametersBuilder.toString(); + } + + private String getFullStackTrace(Exception exception) { + int restrictionMessageLengthOfDiscord = 3000; + StringBuilder fullStackTrace = new StringBuilder(exception.toString() + "\n"); + + for (StackTraceElement element : exception.getStackTrace()) { + fullStackTrace.append(element.toString()).append("\n"); + } + + int stackTraceLength = Math.min(fullStackTrace.length(), restrictionMessageLengthOfDiscord); + return fullStackTrace.substring(0, stackTraceLength); + } + } + diff --git a/src/main/java/mafia/mafiatogether/common/exception/ServerException.java b/src/main/java/mafia/mafiatogether/common/exception/ServerException.java new file mode 100644 index 00000000..023fd4e5 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/exception/ServerException.java @@ -0,0 +1,7 @@ +package mafia.mafiatogether.common.exception; + +public class ServerException extends GlobalException{ + public ServerException(final ExceptionCode exceptionCode) { + super(exceptionCode.getCode(), exceptionCode.getMessage()); + } +} diff --git a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java b/src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java similarity index 51% rename from src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java rename to src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java index ab578b7a..787e4c08 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java @@ -1,19 +1,22 @@ -package mafia.mafiatogether.game.domain; +package mafia.mafiatogether.common.infra; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Repository; +import mafia.mafiatogether.common.domain.SseEmitterSession; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.exception.ServerException; +import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -@Repository -public class InMemorySseEmitterRepository implements SseEmitterRepository { +@Component +public class InMemorySseEmitterSession implements SseEmitterSession { private final Map> emitters; - public InMemorySseEmitterRepository() { + public InMemorySseEmitterSession() { this.emitters = new ConcurrentHashMap<>(); } @@ -26,7 +29,7 @@ public void save(final String code, final String name, final SseEmitter sseEmitt } @Override - public List findByCode(String code) { + public List findByCode(final String code) { if (!emitters.containsKey(code)) { return new ArrayList<>(); } @@ -34,12 +37,20 @@ public List findByCode(String code) { } @Override - public void deleteByCode(String code) { + public SseEmitter findByCodeAndName(final String code, final String name) { + if (!emitters.containsKey(code) || !emitters.get(code).containsKey(name)) { + throw new ServerException(ExceptionCode.INVALID_PLAYER); + } + return emitters.get(code).get(name); + } + + @Override + public void deleteByCode(final String code) { emitters.remove(code); } @Override - public void deleteByCodeAndEmitter(String code, final String name) { + public void deleteByCodeAndEmitter(final String code, final String name) { if (!emitters.containsKey(code)) { return; } diff --git a/src/main/java/mafia/mafiatogether/common/infra/SseEventPublisher.java b/src/main/java/mafia/mafiatogether/common/infra/SseEventPublisher.java new file mode 100644 index 00000000..b0fb1c38 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/infra/SseEventPublisher.java @@ -0,0 +1,65 @@ +package mafia.mafiatogether.common.infra; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.domain.SseEmitterSession; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter.SseEventBuilder; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class SseEventPublisher { + + private static final long HOURS_12 = 43200_000L; + private static final long SECOND_30 = 30_000L; + private final SseEmitterSession sseEmitterSession; + + public void publishEventByCode(final String code, final String eventName, final Object event) { + List emitters = sseEmitterSession.findByCode(code); + for (SseEmitter emitter : emitters) { + SseEventBuilder builder = getSseEventBuilder(eventName, event); + publishSseEvent(emitter, builder); + } + } + + private void publishSseEvent(final SseEmitter emitter, final SseEventBuilder eventBuilder) { + try { + emitter.send(eventBuilder); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + public static SseEventBuilder getSseEventBuilder(final String eventName, final Object event) { + return SseEmitter.event() + .name(eventName) + .data(event) + .reconnectTime(SECOND_30); + } + + public void publishEventByCodeAndName( + final String code, + final String participantName, + final String eventName, + final Object event + ) { + SseEmitter sseEmitter = sseEmitterSession.findByCodeAndName(code, participantName); + SseEventBuilder eventBuilder = getSseEventBuilder(eventName, event); + publishSseEvent(sseEmitter, eventBuilder); + } + + public void disconnectSseByCode(final String code) { + sseEmitterSession.deleteByCode(code); + } + + + public static SseEmitter getSseEmitter(final String name, final Object event) throws IOException { + final SseEmitter sseEmitter = new SseEmitter(HOURS_12); + SseEventBuilder sseEventBuilder = SseEventPublisher.getSseEventBuilder(name, event); + sseEmitter.send(sseEventBuilder); + return sseEmitter; + } +} diff --git a/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java b/src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java similarity index 90% rename from src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java rename to src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java index a66de0d5..427ae6ac 100644 --- a/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java +++ b/src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java @@ -1,6 +1,8 @@ package mafia.mafiatogether.common.interceptor; -import lombok.RequiredArgsConstructor; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; import mafia.mafiatogether.common.util.AuthExtractor; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; @@ -11,16 +13,11 @@ import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.stereotype.Component; -import java.util.Map; -import java.util.Objects; -import java.util.function.Consumer; - @Component @Configuration -@RequiredArgsConstructor -public class StompChannelInterceptor implements ChannelInterceptor { +public class ChatInterceptor implements ChannelInterceptor { - private static final String SUBSCRIBE_FORMAT = "/sub/chat/%s"; + private static final String SUBSCRIBE_FORMAT = "%s/%s"; private static final String PUBLISHING_FORMAT = "%s/%s/%s"; private final Map> actionByCommand = Map.of( @@ -44,7 +41,9 @@ public Message preSend(Message message, MessageChannel channel) { private void consumeWhenSubscribe(StompHeaderAccessor headerAccessor) { String[] information = getInformation(headerAccessor); - headerAccessor.setDestination(SUBSCRIBE_FORMAT.formatted(information[0])); + String prefixUrl = headerAccessor.getDestination() + .substring(0, headerAccessor.getDestination().lastIndexOf('/')); + headerAccessor.setDestination(SUBSCRIBE_FORMAT.formatted(prefixUrl, information[0])); } private String[] getInformation(StompHeaderAccessor headerAccessor) { @@ -76,4 +75,4 @@ private void consumeWhenPublish(StompHeaderAccessor headerAccessor) { headerAccessor.setDestination(PUBLISHING_FORMAT.formatted(prefixUrl, information[0], information[1])); } -} \ No newline at end of file +} diff --git a/src/main/java/mafia/mafiatogether/common/interceptor/PathMatcherInterceptor.java b/src/main/java/mafia/mafiatogether/common/interceptor/PathMatcherInterceptor.java new file mode 100644 index 00000000..f9e86d32 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/interceptor/PathMatcherInterceptor.java @@ -0,0 +1,66 @@ +package mafia.mafiatogether.common.interceptor; + +import java.util.ArrayList; +import java.util.List; +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.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; + +public class PathMatcherInterceptor implements ChannelInterceptor { + + private final ChannelInterceptor channelInterceptor; + private final PathMatcher pathMatcher; + private final List includePathPatterns; + private final List excludePathPatterns; + + public PathMatcherInterceptor(final ChannelInterceptor channelInterceptor) { + this.channelInterceptor = channelInterceptor; + this.pathMatcher = new AntPathMatcher(); + this.includePathPatterns = new ArrayList<>(); + this.excludePathPatterns = new ArrayList<>(); + } + + @Override + public Message preSend(final Message message, final MessageChannel channel) { + StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (headerAccessor != null && + headerAccessor.getDestination() != null && + shouldIntercept(headerAccessor.getDestination(), headerAccessor.getCommand())) { + return channelInterceptor.preSend(message, channel); + } + + return ChannelInterceptor.super.preSend(message, channel); + } + + private boolean shouldIntercept(String destination, StompCommand command) { + boolean isExcluded = excludePathPatterns.stream() + .anyMatch(stompMapping -> matchesPathAndCommand(destination, command, stompMapping)); + + boolean isIncluded = includePathPatterns.stream() + .anyMatch(stompMapping -> matchesPathAndCommand(destination, command, stompMapping)); + + return isIncluded && !isExcluded; + } + + private boolean matchesPathAndCommand(String destination, StompCommand command, StompMapping stompMapping) { + return pathMatcher.match(stompMapping.destination(), destination) && + stompMapping.command() == command; + } + + public PathMatcherInterceptor includePathPattern(String targetPath, StompCommand command) { + this.includePathPatterns.add(new StompMapping(targetPath, command)); + return this; + } + + public PathMatcherInterceptor excludePathPattern(String targetPath, StompCommand command) { + this.excludePathPatterns.add(new StompMapping(targetPath, command)); + return this; + } + +} diff --git a/src/main/java/mafia/mafiatogether/common/interceptor/StompMapping.java b/src/main/java/mafia/mafiatogether/common/interceptor/StompMapping.java new file mode 100644 index 00000000..686145cc --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/interceptor/StompMapping.java @@ -0,0 +1,9 @@ +package mafia.mafiatogether.common.interceptor; + +import org.springframework.messaging.simp.stomp.StompCommand; + +public record StompMapping( + String destination, + StompCommand command +) { +} diff --git a/src/main/java/mafia/mafiatogether/common/resolver/BasicAuthResolver.java b/src/main/java/mafia/mafiatogether/common/resolver/BasicAuthResolver.java new file mode 100644 index 00000000..d0552193 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/resolver/BasicAuthResolver.java @@ -0,0 +1,22 @@ +package mafia.mafiatogether.common.resolver; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.exception.AuthException; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.util.AuthExtractor; +import org.springframework.stereotype.Component; + +@Component +public class BasicAuthResolver { + + public String[] resolve(final String authorization) { + if (authorization == null) { + throw new AuthException(ExceptionCode.MISSING_AUTHENTICATION_HEADER); + } + if (!authorization.startsWith("Basic")) { + throw new AuthException(ExceptionCode.MISSING_AUTHENTICATION_HEADER); + } + + return AuthExtractor.extractByAuthorization(authorization); + } +} diff --git a/src/main/java/mafia/mafiatogether/common/resolver/PlayerArgumentResolver.java b/src/main/java/mafia/mafiatogether/common/resolver/PlayerArgumentResolver.java index 945d1a85..2eac36d0 100644 --- a/src/main/java/mafia/mafiatogether/common/resolver/PlayerArgumentResolver.java +++ b/src/main/java/mafia/mafiatogether/common/resolver/PlayerArgumentResolver.java @@ -1,19 +1,23 @@ package mafia.mafiatogether.common.resolver; import jakarta.servlet.http.HttpServletRequest; - -import mafia.mafiatogether.common.util.AuthExtractor; +import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.exception.AuthException; import mafia.mafiatogether.common.exception.ExceptionCode; import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +@Component +@RequiredArgsConstructor public class PlayerArgumentResolver implements HandlerMethodArgumentResolver { + private final BasicAuthResolver basicAuthResolver; + @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.hasParameterAnnotation(PlayerInfo.class); @@ -23,17 +27,12 @@ public boolean supportsParameter(final MethodParameter parameter) { public PlayerInfoDto resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { HttpServletRequest httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class); - if (httpServletRequest == null) { throw new AuthException(ExceptionCode.NOT_FOUND_REQUEST); } String authorization = httpServletRequest.getHeader("Authorization"); - if (authorization == null) { - throw new AuthException(ExceptionCode.MISSING_AUTHENTICATION_HEADER); - } - - String[] information = AuthExtractor.extractByAuthorization(authorization); + String[] information = basicAuthResolver.resolve(authorization); return new PlayerInfoDto(information[0], information[1]); } } diff --git a/src/main/java/mafia/mafiatogether/common/resolver/WebsocketPlayerArgumentResolver.java b/src/main/java/mafia/mafiatogether/common/resolver/WebsocketPlayerArgumentResolver.java new file mode 100644 index 00000000..c35d4437 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/resolver/WebsocketPlayerArgumentResolver.java @@ -0,0 +1,31 @@ +package mafia.mafiatogether.common.resolver; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.annotation.PlayerInfo; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WebsocketPlayerArgumentResolver implements HandlerMethodArgumentResolver { + + private final BasicAuthResolver basicAuthResolver; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(PlayerInfo.class); + } + + @Override + public Object resolveArgument(final MethodParameter parameter, final Message message) throws Exception { + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(message); + String authorization = headerAccessor.getFirstNativeHeader("Authorization"); + String[] information = basicAuthResolver.resolve(authorization); + + return new PlayerInfoDto(information[0], information[1]); + } + +} diff --git a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java index 67c47be8..d59b051a 100644 --- a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java +++ b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import mafia.mafiatogether.chat.domain.Chat; import mafia.mafiatogether.chat.domain.ChatRepository; +import mafia.mafiatogether.common.infra.SseEventPublisher; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.common.exception.GameException; import mafia.mafiatogether.game.application.dto.event.*; @@ -10,7 +11,6 @@ import mafia.mafiatogether.game.domain.Game; import mafia.mafiatogether.game.domain.GameRepository; import mafia.mafiatogether.game.domain.Player; -import mafia.mafiatogether.game.domain.SseEmitterRepository; import mafia.mafiatogether.game.domain.status.StatusType; import mafia.mafiatogether.job.domain.JobTarget; import mafia.mafiatogether.job.domain.JobTargetRepository; @@ -24,26 +24,23 @@ import mafia.mafiatogether.vote.domain.VoteRepository; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter.SseEventBuilder; -import java.io.IOException; import java.time.Clock; import java.util.ArrayList; import java.util.HashMap; -import java.util.List; @Component @RequiredArgsConstructor public class GameEventListener { + private static final String GAME_STATUS_EVENT_NAME = "gameStatus"; private final GameRepository gameRepository; private final VoteRepository voteRepository; private final LobbyRepository lobbyRepository; private final JobTargetRepository jobTargetRepository; private final PlayerJobRepository playerJobRepository; private final ChatRepository chatRepository; - private final SseEmitterRepository sseEmitterRepository; + private final SseEventPublisher sseEventPublisher; @EventListener public void listenVoteExecuteEvent(final VoteExecuteEvent voteExecuteEvent) { @@ -100,20 +97,21 @@ public void listenStartGameEvent(final StartGameEvent startGameEvent) { @EventListener public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) { - playerJobRepository.deleteById(deleteGameEvent.code()); - jobTargetRepository.deleteById(deleteGameEvent.code()); - chatRepository.deleteById(deleteGameEvent.code()); - voteRepository.deleteById(deleteGameEvent.code()); - sseEmitterRepository.deleteByCode(deleteGameEvent.code()); - gameRepository.deleteById(deleteGameEvent.code()); - - final Lobby room = lobbyRepository.findById(deleteGameEvent.code()) + final String code = deleteGameEvent.code(); + playerJobRepository.deleteById(code); + jobTargetRepository.deleteById(code); + chatRepository.deleteById(code); + voteRepository.deleteById(code); + sendStatusChangeEventToSseClient(code, StatusType.WAIT); + gameRepository.deleteById(code); + + final Lobby room = lobbyRepository.findById(code) .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); room.updateLastUpdateTime(); } @EventListener - public void listenAllPlayerVoteEvent(final AllPlayerVotedEvent allPlayerVotedEvent) throws IOException { + public void listenAllPlayerVoteEvent(final AllPlayerVotedEvent allPlayerVotedEvent) { final Game game = gameRepository.findById(allPlayerVotedEvent.code()) .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); if (!game.getStatus().getType().equals(StatusType.DAY)) { @@ -130,30 +128,26 @@ public void listenAllPlayerVoteEvent(final AllPlayerVotedEvent allPlayerVotedEve @EventListener public void listenDeleteLobbyEvent(final DeleteLobbyEvent deleteLobbyEvent) { - playerJobRepository.deleteById(deleteLobbyEvent.code()); - jobTargetRepository.deleteById(deleteLobbyEvent.code()); - chatRepository.deleteById(deleteLobbyEvent.code()); - voteRepository.deleteById(deleteLobbyEvent.code()); - sseEmitterRepository.deleteByCode(deleteLobbyEvent.code()); - gameRepository.deleteById(deleteLobbyEvent.code()); - lobbyRepository.deleteById(deleteLobbyEvent.code()); + final String code = deleteLobbyEvent.code(); + playerJobRepository.deleteById(code); + jobTargetRepository.deleteById(code); + chatRepository.deleteById(code); + voteRepository.deleteById(code); + sseEventPublisher.disconnectSseByCode(code); + gameRepository.deleteById(code); + lobbyRepository.deleteById(code); } @EventListener - public void listenGameStatusChangeEvent(final GameStatusChangeEvent gameStatusChangeEvent) throws IOException { - sendStatusChangeEventToSseClient(gameStatusChangeEvent.code(), gameStatusChangeEvent.statusType()); - } - - private void sendStatusChangeEventToSseClient(final String code, final StatusType statusType) throws IOException { - List emitters = sseEmitterRepository.findByCode(code); - for (SseEmitter emitter : emitters) { - emitter.send(getSseEvent(statusType)); + public void listenGameStatusChangeEvent(final GameStatusChangeEvent gameStatusChangeEvent) { + if (gameStatusChangeEvent.statusType().equals(StatusType.DELETED)) { + return; } + sendStatusChangeEventToSseClient(gameStatusChangeEvent.code(), gameStatusChangeEvent.statusType()); } - private SseEventBuilder getSseEvent(StatusType statusType) { - return SseEmitter.event() - .name("gameStatus") - .data(new GameStatusResponse(statusType)); + private void sendStatusChangeEventToSseClient(final String code, final StatusType statusType) { + final GameStatusResponse gameStatusResponse = new GameStatusResponse(statusType); + sseEventPublisher.publishEventByCode(code, GAME_STATUS_EVENT_NAME, gameStatusResponse); } } diff --git a/src/main/java/mafia/mafiatogether/game/aspect/SseService.java b/src/main/java/mafia/mafiatogether/game/aspect/SseService.java deleted file mode 100644 index 79c7b146..00000000 --- a/src/main/java/mafia/mafiatogether/game/aspect/SseService.java +++ /dev/null @@ -1,89 +0,0 @@ -package mafia.mafiatogether.game.aspect; - -import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.common.annotation.PlayerInfo; -import mafia.mafiatogether.common.exception.AuthException; -import mafia.mafiatogether.common.exception.ExceptionCode; -import mafia.mafiatogether.common.resolver.PlayerInfoDto; -import mafia.mafiatogether.game.application.dto.response.GameStatusResponse; -import mafia.mafiatogether.game.domain.Game; -import mafia.mafiatogether.game.domain.GameRepository; -import mafia.mafiatogether.game.domain.SseEmitterRepository; -import mafia.mafiatogether.game.domain.status.StatusType; -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.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.Optional; - -@Aspect -@Component -@RequiredArgsConstructor -public class SseService { - - private static final String SSE_STATUS = "gameStatus"; - public static final long HOURS_12 = 43200_000L; - public static final long SECOND_30 = 30_000L; - private final SseEmitterRepository sseEmitterRepository; - private final GameRepository gameRepository; - - @Around("@annotation(mafia.mafiatogether.game.annotation.SseSubscribe)") - public ResponseEntity subscribe(final ProceedingJoinPoint joinPoint) throws Throwable { - MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); - Method method = methodSignature.getMethod(); - - Annotation[][] parameterAnnotations = method.getParameterAnnotations(); - Object[] args = joinPoint.getArgs(); - - String[] codeAndName = new String[2]; - for (int i = 0; i < parameterAnnotations.length; i++) { - if (hasPlayerInfo(parameterAnnotations[i])) { - PlayerInfoDto playerInfoDto = (PlayerInfoDto) args[i]; - codeAndName[0] = playerInfoDto.code(); - codeAndName[1] = playerInfoDto.name(); - break; - } - } - - if (codeAndName[0] == null || codeAndName[1] == null) { - throw new AuthException(ExceptionCode.INVALID_AUTHENTICATION_FORM); - } - - SseEmitter sseEmitter = createSseEmitter(codeAndName[0], codeAndName[1]); - sseEmitterRepository.save(codeAndName[0], codeAndName[1], sseEmitter); - return ResponseEntity.ok(sseEmitter); - } - - private boolean hasPlayerInfo(Annotation[] annotations) { - return Arrays.stream(annotations).anyMatch(PlayerInfo.class::isInstance); - } - - private SseEmitter createSseEmitter(String code, String name) throws IOException { - SseEmitter sseEmitter = new SseEmitter(HOURS_12); - sseEmitter.send(getSseEvent(code)); - sseEmitter.onCompletion(() -> sseEmitterRepository.deleteByCodeAndEmitter(code, name)); - sseEmitter.onTimeout(sseEmitter::complete); - return sseEmitter; - } - - private SseEmitter.SseEventBuilder getSseEvent(final String code) { - Optional game = gameRepository.findById(code); - return game.map(value -> getSseEventBuilder(value.getStatus().getType())) - .orElseGet(() -> getSseEventBuilder(StatusType.WAIT)); - } - - private static SseEmitter.SseEventBuilder getSseEventBuilder(final StatusType statusType) { - return SseEmitter.event() - .name(SSE_STATUS) - .data(new GameStatusResponse(statusType)) - .reconnectTime(SECOND_30); - } -} diff --git a/src/main/java/mafia/mafiatogether/game/ui/GameController.java b/src/main/java/mafia/mafiatogether/game/ui/GameController.java index aa6d3c53..776ce875 100644 --- a/src/main/java/mafia/mafiatogether/game/ui/GameController.java +++ b/src/main/java/mafia/mafiatogether/game/ui/GameController.java @@ -2,8 +2,9 @@ import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.annotation.PlayerInfo; +import mafia.mafiatogether.common.infra.SseEventPublisher; import mafia.mafiatogether.common.resolver.PlayerInfoDto; -import mafia.mafiatogether.game.annotation.SseSubscribe; +import mafia.mafiatogether.common.annotation.SseSubscribe; import mafia.mafiatogether.game.application.GameService; import mafia.mafiatogether.game.application.dto.response.GameExistResponse; import mafia.mafiatogether.game.application.dto.response.GameInfoResponse; @@ -17,19 +18,15 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.IOException; + @RestController @RequiredArgsConstructor @RequestMapping("/games") public class GameController { private final GameService gameService; - - @GetMapping("/status") - public ResponseEntity findStatus( - @PlayerInfo final PlayerInfoDto playerInfoDto - ) { - return ResponseEntity.ok(gameService.findStatus(playerInfoDto.code())); - } + private static final String SSE_STATUS = "gameStatus"; @PostMapping("/start") public ResponseEntity startGame( @@ -53,10 +50,11 @@ public ResponseEntity findGameInfo( return ResponseEntity.ok(gameService.findGameInfo(playerInfoDto.code(), playerInfoDto.name())); } - @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @SseSubscribe - public ResponseEntity subscribe(@PlayerInfo final PlayerInfoDto playerInfoDto) { - return ResponseEntity.ok(new SseEmitter()); + @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe(@PlayerInfo final PlayerInfoDto playerInfoDto) throws IOException { + GameStatusResponse gameStatusResponse = gameService.findStatus(playerInfoDto.code()); + return SseEventPublisher.getSseEmitter(SSE_STATUS, gameStatusResponse); } @GetMapping("/valid") diff --git a/src/main/java/mafia/mafiatogether/job/application/JobService.java b/src/main/java/mafia/mafiatogether/job/application/JobService.java index c89c9f73..d199ed24 100644 --- a/src/main/java/mafia/mafiatogether/job/application/JobService.java +++ b/src/main/java/mafia/mafiatogether/job/application/JobService.java @@ -2,13 +2,13 @@ import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.exception.ExceptionCode; -import mafia.mafiatogether.common.exception.PlayerException; import mafia.mafiatogether.common.exception.GameException; +import mafia.mafiatogether.common.exception.PlayerException; import mafia.mafiatogether.job.application.dto.request.JobExecuteAbilityRequest; -import mafia.mafiatogether.job.application.dto.response.JobResponse; -import mafia.mafiatogether.job.application.dto.response.MafiaTargetResponse; import mafia.mafiatogether.job.application.dto.response.JobExecuteAbilityResponse; +import mafia.mafiatogether.job.application.dto.response.JobResponse; import mafia.mafiatogether.job.application.dto.response.JobResultResponse; +import mafia.mafiatogether.job.application.dto.response.MafiaTargetResponse; import mafia.mafiatogether.job.domain.JobTarget; import mafia.mafiatogether.job.domain.JobTargetRepository; import mafia.mafiatogether.job.domain.PlayerJob; diff --git a/src/main/java/mafia/mafiatogether/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index 0921b9b4..23e7d283 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -1,15 +1,21 @@ package mafia.mafiatogether.job.ui; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Base64; import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; import mafia.mafiatogether.job.application.JobService; import mafia.mafiatogether.job.application.dto.request.JobExecuteAbilityRequest; -import mafia.mafiatogether.job.application.dto.response.JobResponse; -import mafia.mafiatogether.job.application.dto.response.MafiaTargetResponse; import mafia.mafiatogether.job.application.dto.response.JobExecuteAbilityResponse; +import mafia.mafiatogether.job.application.dto.response.JobResponse; import mafia.mafiatogether.job.application.dto.response.JobResultResponse; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -21,6 +27,8 @@ @RequestMapping("/jobs") public class JobController { + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; private final JobService jobService; @GetMapping("/my") @@ -28,6 +36,20 @@ public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDt return ResponseEntity.ok(jobService.getPlayerJob(playerInfoDto.code(), playerInfoDto.name())); } + @MessageMapping("/jobs/skill") + public void executeWebSocketSkill( + @PlayerInfo PlayerInfoDto playerInfoDto, + @Payload JobExecuteAbilityRequest request + ) throws JsonProcessingException { + JobExecuteAbilityResponse response = jobService.executeSkill(playerInfoDto.code(), playerInfoDto.name(), + request); + + stringRedisTemplate.convertAndSend( + String.format("/sub/jobs/skill/%s/%s", response.job().toLowerCase(),playerInfoDto.code()), + objectMapper.writeValueAsString(response) + ); + } + @PostMapping("/skill") public ResponseEntity executeSkill( @PlayerInfo PlayerInfoDto playerInfoDto, @@ -40,16 +62,6 @@ public ResponseEntity executeSkill( )); } - @GetMapping("/skill") - public ResponseEntity getTarget( - @PlayerInfo PlayerInfoDto playerInfoDto - ) { - return ResponseEntity.ok(jobService.getTarget( - playerInfoDto.code(), - playerInfoDto.name() - )); - } - @GetMapping("/skill/result") public ResponseEntity findNightResult( @PlayerInfo final PlayerInfoDto playerInfoDto diff --git a/src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java b/src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java new file mode 100644 index 00000000..6e71b2a4 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java @@ -0,0 +1,43 @@ +package mafia.mafiatogether.lobby.application; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.infra.SseEventPublisher; +import mafia.mafiatogether.lobby.application.dto.event.ParticipantJoinEvent; +import mafia.mafiatogether.lobby.application.dto.response.LobbyInfoResponse; +import mafia.mafiatogether.lobby.domain.Lobby; +import mafia.mafiatogether.lobby.domain.Participant; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class LobbyEventListener { + + private static final String LOBBY_EVENT_NAME = "lobbyInfo"; + private final SseEventPublisher sseEventPublisher; + + @EventListener + public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) { + Lobby lobby = participantJoinEvent.lobby(); + String ignoreName = participantJoinEvent.name(); + List participants = lobby.getParticipants() + .getParticipants(); + + + for (Participant participant : participants) { + String eachParticipantName = participant.getName(); + sendEvent(lobby, eachParticipantName, ignoreName); + } + } + + private void sendEvent(Lobby lobby, String participantName, String ignoreName) { + if (participantName.equals(ignoreName)) { + return; + } + final String code = lobby.getCode(); + final LobbyInfoResponse lobbyInfoResponse = LobbyInfoResponse.of(lobby, participantName); + sseEventPublisher.publishEventByCodeAndName(code, participantName, LOBBY_EVENT_NAME, lobbyInfoResponse); + } +} diff --git a/src/main/java/mafia/mafiatogether/lobby/application/LobbyService.java b/src/main/java/mafia/mafiatogether/lobby/application/LobbyService.java index c5e11475..ec9ca219 100644 --- a/src/main/java/mafia/mafiatogether/lobby/application/LobbyService.java +++ b/src/main/java/mafia/mafiatogether/lobby/application/LobbyService.java @@ -1,6 +1,8 @@ package mafia.mafiatogether.lobby.application; import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.common.annotation.RedisLock; +import mafia.mafiatogether.common.annotation.RedisLockTarget; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.common.exception.GameException; import mafia.mafiatogether.lobby.application.dto.request.LobbyCreateRequest; @@ -22,7 +24,7 @@ public class LobbyService { @Transactional public LobbyCodeResponse create(final LobbyCreateRequest request) { String code = CodeGenerator.generate(); - while (lobbyRepository.existsById(code)){ + while (lobbyRepository.existsById(code)) { code = CodeGenerator.generate(); } final LobbyInfo lobbyInfo = LobbyInfo.of(request.total(), request.mafia(), request.doctor(), request.police()); @@ -32,7 +34,8 @@ public LobbyCodeResponse create(final LobbyCreateRequest request) { } @Transactional - public void join(final String code, final String name) { + @RedisLock(key = "lobby") + public void join(@RedisLockTarget final String code, final String name) { final Lobby lobby = lobbyRepository.findById(code) .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); lobby.joinPlayer(name); diff --git a/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java b/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java new file mode 100644 index 00000000..c80fbf25 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java @@ -0,0 +1,9 @@ +package mafia.mafiatogether.lobby.application.dto.event; + +import mafia.mafiatogether.lobby.domain.Lobby; + +public record ParticipantJoinEvent( + Lobby lobby, + String name +) { +} diff --git a/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponse.java b/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponse.java new file mode 100644 index 00000000..b1859464 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponse.java @@ -0,0 +1,27 @@ +package mafia.mafiatogether.lobby.application.dto.response; + +import mafia.mafiatogether.lobby.domain.Lobby; + +import java.util.List; + +public record LobbyInfoResponse( + Integer totalPlayers, + Boolean isMaster, + String myName, + List lobbyPlayerResponses +) { + + public static LobbyInfoResponse of(Lobby lobby, String myName) { + return new LobbyInfoResponse( + lobby.getLobbyInfo().getTotal(), + lobby.isMaster(myName), + myName, + lobby.getParticipants() + .getParticipants() + .stream() + .map(LobbyPlayerResponse::from) + .toList() + ); + } + +} diff --git a/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyPlayerResponse.java b/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyPlayerResponse.java new file mode 100644 index 00000000..5ce3bd01 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyPlayerResponse.java @@ -0,0 +1,15 @@ +package mafia.mafiatogether.lobby.application.dto.response; + +import mafia.mafiatogether.lobby.domain.Participant; + +public record LobbyPlayerResponse( + String name +) { + + public static LobbyPlayerResponse from(Participant participant) { + return new LobbyPlayerResponse( + participant.getName() + ); + } + +} diff --git a/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java b/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java index e1a1daa2..c505c267 100644 --- a/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java +++ b/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java @@ -1,18 +1,21 @@ package mafia.mafiatogether.lobby.domain; import java.time.Clock; + import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.common.exception.GameException; +import mafia.mafiatogether.lobby.application.dto.event.ParticipantJoinEvent; import org.springframework.data.annotation.Id; +import org.springframework.data.domain.AbstractAggregateRoot; import org.springframework.data.redis.core.RedisHash; @Getter @RedisHash("lobby") @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Lobby { +public class Lobby extends AbstractAggregateRoot { @Id private String code; @@ -21,7 +24,7 @@ public class Lobby { private Participant master; private Long lastUpdateTime; - public Lobby(){ + public Lobby() { this.participants = new ParticipantCollection(); } @@ -51,6 +54,7 @@ public void joinPlayer(final String name) { if (master.equals(Participant.NONE)) { master = participant; } + registerEvent(new ParticipantJoinEvent(this, name)); } public void validateToStart() { @@ -66,4 +70,9 @@ public void updateLastUpdateTime() { public boolean isParticipantExist(final String name) { return participants.contains(name); } + + public boolean isMaster(String myName) { + return master.getName() + .equals(myName); + } } diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 818333ea..af95ba3f 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -8,6 +8,10 @@ stompClient.onConnect = (frame) => { stompClient.subscribe('/sub/chat/aGVsbG86cG93ZXJhc3M=', (greeting) => { showGreeting(JSON.parse(greeting.body)); }); + + stompClient.subscribe('/sub/job/skill/mafia/aGVsbG86cG93ZXJhc3M=', (greeting) => { + showSkill(JSON.parse(greeting.body)); + }); }; stompClient.onWebSocketError = (error) => { @@ -59,11 +63,26 @@ function leaveRoom() { }); } +function executeSkill() { + stompClient.publish({ + destination: "/pub/jobs/skill", + body: JSON.stringify({'target': $("#target").val()}), + headers: { + Authorization: 'Basic aGVsbG86cG93ZXJhc3M=', + }, + }); +} + function showGreeting(message) { console.log('Received: ' + JSON.stringify(message)); $("#greeting").append("" + JSON.stringify(message) + ""); } +function showSkill(message) { + console.log('Received: ' + JSON.stringify(message)); + $("#greeting").append("" + JSON.stringify(message) + ""); +} + $(function () { $("form").on('submit', (e) => e.preventDefault()); $("#connect").click(() => connect()); @@ -71,4 +90,5 @@ $(function () { $("#send").click(() => sendName()); $("#enter").click(() => enterRoom()); $("#leave").click(() => leaveRoom()); -}); \ No newline at end of file + $("#skill").click(() => executeSkill()); +}); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 7b86f54f..b395080f 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -34,6 +34,13 @@ +
+
+ + +
+ +
@@ -51,4 +58,4 @@
- \ No newline at end of file + diff --git a/src/test/java/mafia/mafiatogether/chat/ui/ChatControllerTest.java b/src/test/java/mafia/mafiatogether/chat/ui/ChatControllerTest.java deleted file mode 100644 index 06261d38..00000000 --- a/src/test/java/mafia/mafiatogether/chat/ui/ChatControllerTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package mafia.mafiatogether.chat.ui; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import java.time.Clock; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Map; -import mafia.mafiatogether.chat.application.dto.response.ChatResponse; -import mafia.mafiatogether.chat.domain.Chat; -import mafia.mafiatogether.chat.domain.ChatRepository; -import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.global.ControllerTest; -import mafia.mafiatogether.job.domain.jobtype.JobType; -import org.assertj.core.api.Assertions; -import org.assertj.core.api.SoftAssertions; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; - -@SuppressWarnings("NonAsciiCharacters") -class ChatControllerTest extends ControllerTest { - - @Autowired - private ChatRepository chatRepository; - - @BeforeEach - void setTest() { - setLobby(); - setGame(); - - Chat chat = new Chat(CODE, new ArrayList<>()); - chat.saveMessage(Message.ofChat(MAFIA1, "contents1")); - chat.saveMessage(Message.ofChat(MAFIA2, "contents2")); - chat.saveMessage(Message.ofChat(POLICE, "contents3")); - chat.saveMessage(Message.ofChat(DOCTOR, "contents4")); - chat.saveMessage(Message.ofChat(CITIZEN, "contents5")); - chatRepository.save(chat); - } - - @AfterEach - void clearTest() { - chatRepository.deleteById(CODE); - } - - @Test - void 시민이_채팅_내역을_조회할_수_있다() { - // given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + CITIZEN).getBytes()); - - // when & then - List responses = getChatResponses(basic); - - assuredResponse(responses, JobType.CITIZEN); - } - - @Test - void 의사이_채팅_내역을_조회할_수_있다() { - // given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + DOCTOR).getBytes()); - - // when & then - List responses = getChatResponses(basic); - - assuredResponse(responses, JobType.DOCTOR); - } - - @Test - void 경찰이_채팅_내역을_조회할_수_있다() { - // given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + POLICE).getBytes()); - - // when & then - List responses = getChatResponses(basic); - - assuredResponse(responses, JobType.POLICE); - } - - private static List getChatResponses(String basic) { - List responses = RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header("Authorization", "Basic " + basic) - .when().get("/chat") - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract() - .body() - .jsonPath().getList(".", ChatResponse.class); - return responses; - } - - private static void assuredResponse(List responses, JobType jobType) { - JobType playerJob = responses.stream() - .filter(ChatResponse::isOwner) - .findFirst() - .get() - .job(); - Long countOtherJobs = responses.stream() - .filter(response -> response.job() != null && !response.isOwner()) - .count(); - SoftAssertions.assertSoftly( - softAssertions -> { - softAssertions.assertThat(playerJob).isEqualTo(jobType); - softAssertions.assertThat(countOtherJobs).isEqualTo(0); - } - ); - } - - @Test - void 마피아_채팅_조회시_다른_마피아들을_확인할_수_있다() { - // given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + MAFIA1).getBytes()); - - // when & then - List responses = getChatResponses(basic); - - JobType playerJob = responses.stream() - .filter(ChatResponse::isOwner) - .findFirst() - .get() - .job(); - JobType otherMafia = responses.stream() - .filter(response -> response.name().equals(MAFIA2)) - .findFirst() - .get() - .job(); - Long countOtherJobs = responses.stream() - .filter(response -> response.job() != null && response.job() != JobType.MAFIA) - .count(); - Long countMafia = responses.stream() - .filter(response -> response.job() != null && response.job().equals(JobType.MAFIA)) - .count(); - - SoftAssertions.assertSoftly( - softAssertions -> { - softAssertions.assertThat(playerJob).isEqualTo(JobType.MAFIA); - softAssertions.assertThat(otherMafia).isEqualTo(JobType.MAFIA); - softAssertions.assertThat(countOtherJobs).isEqualTo(0); - softAssertions.assertThat(countMafia).isEqualTo(2); - } - ); - } - - @Test - void 채팅_전송을_할_수_있다() { - // given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + CITIZEN).getBytes()); - - // when - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header("Authorization", "Basic " + basic) - .body(Map.of("content", "content")) - .when().post("/chat") - .then().log().all() - .statusCode(HttpStatus.CREATED.value()); - - // then - final Long actual = chatRepository.findById(CODE).get().getMessages().stream().count(); - Assertions.assertThat(actual).isEqualTo(6); - } -} diff --git a/src/test/java/mafia/mafiatogether/common/aspect/SseAspectTest.java b/src/test/java/mafia/mafiatogether/common/aspect/SseAspectTest.java new file mode 100644 index 00000000..4cea2d14 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/common/aspect/SseAspectTest.java @@ -0,0 +1,66 @@ +package mafia.mafiatogether.common.aspect; + +import mafia.mafiatogether.common.annotation.PlayerInfo; +import mafia.mafiatogether.common.domain.SseEmitterSession; +import mafia.mafiatogether.common.resolver.PlayerInfoDto; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +class SseAspectTest { + + @InjectMocks + private SseAspect sseAspect; + + @Mock + private SseEmitterSession sseEmitterSession; + + @Test + void SSE_구독을_한다() throws Throwable { + // given + final String code = "code"; + final String name = "name"; + PlayerInfoDto playerInfoDto = new PlayerInfoDto(code, name); + + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + MethodSignature methodSignature = mock(MethodSignature.class); + + Method method = mock(Method.class); + Annotation[][] annotations = new Annotation[1][1]; + annotations[0][0] = mock(PlayerInfo.class); + + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getMethod()).willReturn(method); + given(joinPoint.getArgs()).willReturn(new Object[]{playerInfoDto}); + given(method.getParameterAnnotations()).willReturn(annotations); + + + SseEmitter sseEmitter = mock(SseEmitter.class); + given(joinPoint.proceed()).willReturn(sseEmitter); + + // when + SseEmitter actual = (SseEmitter) sseAspect.subscribe(joinPoint); + + // then + verify(sseEmitterSession).save(code, name, sseEmitter); + assertThat(actual).isEqualTo(sseEmitter); + verify(sseEmitter).onCompletion(any()); + verify(sseEmitter).onTimeout(any()); + } +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java b/src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java new file mode 100644 index 00000000..f500ce1f --- /dev/null +++ b/src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java @@ -0,0 +1,60 @@ +package mafia.mafiatogether.common.infra; + +import mafia.mafiatogether.common.exception.ServerException; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +@SuppressWarnings("NonAsciiCharacters") +class InMemorySseEmitterSessionTest { + + private InMemorySseEmitterSession inMemorySseEmitterSession; + + @BeforeEach + void setUp() { + inMemorySseEmitterSession = new InMemorySseEmitterSession(); + } + + @Test + void findByCodeAndName을_호출해서_SSE_Emitter를_가져옵니다() { + // given + SseEmitter sseEmitter = mock(SseEmitter.class); + SseEmitter findOutSseEmitter = mock(SseEmitter.class); + inMemorySseEmitterSession.save("code", "name", sseEmitter); + inMemorySseEmitterSession.save("code", "name2", findOutSseEmitter); + + // when + SseEmitter actual = inMemorySseEmitterSession.findByCodeAndName("code", "name2"); + + // given + assertThat(actual).isEqualTo(findOutSseEmitter); + } + + @Test + void 존재하지_않는_코드를_입력하면_예외가_발생합니다() { + // givne when + ThrowingCallable actual = () -> inMemorySseEmitterSession.findByCodeAndName("code", "name"); + + // then + assertThatThrownBy(actual).isInstanceOf(ServerException.class); + } + + @Test + void 존재하지_않는_유저를_입력하면_예외가_발생합니다() { + // givne + SseEmitter sseEmitter = mock(SseEmitter.class); + inMemorySseEmitterSession.save("code", "name2", sseEmitter); + + // when + ThrowingCallable actual = () -> inMemorySseEmitterSession.findByCodeAndName("code", "name"); + + // then + assertThatThrownBy(actual).isInstanceOf(ServerException.class); + } + +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/common/infra/SseEventPublisherTest.java b/src/test/java/mafia/mafiatogether/common/infra/SseEventPublisherTest.java new file mode 100644 index 00000000..9bf4ac08 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/common/infra/SseEventPublisherTest.java @@ -0,0 +1,86 @@ +package mafia.mafiatogether.common.infra; + +import mafia.mafiatogether.common.domain.SseEmitterSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; + +import static org.mockito.Mockito.*; + +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class SseEventPublisherTest { + + @InjectMocks + private SseEventPublisher sseEventPublisher; + + @Mock + private SseEmitterSession sseEmitterSession; + + private static final String EVENT_NAME = "EVENT_NAME"; + private static final String CODE = "CODE"; + private SseEmitter SSE_EMITTER_1; + private SseEmitter SSE_EMITTER_2; + private SseEmitter SSE_EMITTER_3; + + @BeforeEach + void setSseEmitters() { + SSE_EMITTER_1 = mock(SseEmitter.class); + SSE_EMITTER_2 = mock(SseEmitter.class); + SSE_EMITTER_3 = mock(SseEmitter.class); + } + + @Test + void 모든_구독자에게_같은_이벤트를_발행한다() throws IOException { + // given + List sseEmitters = List.of(SSE_EMITTER_1, SSE_EMITTER_2, SSE_EMITTER_3); + when(sseEmitterSession.findByCode(CODE)).thenReturn(sseEmitters); + String event = "event"; + + // when + sseEventPublisher.publishEventByCode(CODE, EVENT_NAME, event); + + // then + verify(SSE_EMITTER_1).send(any(SseEmitter.SseEventBuilder.class)); + verify(SSE_EMITTER_2).send(any(SseEmitter.SseEventBuilder.class)); + verify(SSE_EMITTER_3).send(any(SseEmitter.SseEventBuilder.class)); + } + + @Test + void 모든_구독자에게_개별로_다른_이벤트를_발행한다() throws IOException { + // given + List players = List.of("player1","player2","player3"); + String event = "event"; + when(sseEmitterSession.findByCodeAndName(CODE, players.get(0))).thenReturn(SSE_EMITTER_1); + when(sseEmitterSession.findByCodeAndName(CODE, players.get(1))).thenReturn(SSE_EMITTER_2); + when(sseEmitterSession.findByCodeAndName(CODE, players.get(2))).thenReturn(SSE_EMITTER_3); + + // when + for (String player : players) { + sseEventPublisher.publishEventByCodeAndName(CODE, player, EVENT_NAME, event); + } + + // then + verify(SSE_EMITTER_1).send(any(SseEmitter.SseEventBuilder.class)); + verify(SSE_EMITTER_2).send(any(SseEmitter.SseEventBuilder.class)); + verify(SSE_EMITTER_3).send(any(SseEmitter.SseEventBuilder.class)); + } + + @Test + void 코드에_구독한_모든_SSE_연결을_끊는다(){ + // then + + // when + sseEventPublisher.disconnectSseByCode(CODE); + + // verify + verify(sseEmitterSession).deleteByCode(CODE); + } +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/game/application/GameEventListenerTest.java b/src/test/java/mafia/mafiatogether/game/application/GameEventListenerTest.java index 7edcc372..ca39eec9 100644 --- a/src/test/java/mafia/mafiatogether/game/application/GameEventListenerTest.java +++ b/src/test/java/mafia/mafiatogether/game/application/GameEventListenerTest.java @@ -9,7 +9,7 @@ import mafia.mafiatogether.game.domain.Game; import mafia.mafiatogether.game.domain.GameRepository; import mafia.mafiatogether.game.domain.Player; -import mafia.mafiatogether.game.domain.SseEmitterRepository; +import mafia.mafiatogether.common.domain.SseEmitterSession; import mafia.mafiatogether.game.domain.status.StatusType; import mafia.mafiatogether.global.ControllerTest; import mafia.mafiatogether.job.application.JobService; @@ -59,7 +59,7 @@ class GameEventListenerTest extends ControllerTest { private ChatRepository chatRepository; @MockBean - private SseEmitterRepository sseEmitterRepository; + private SseEmitterSession sseEmitterSession; private static final String CODE = "1234567890"; private static final String PLAYER1_NAME = "player1"; @@ -240,7 +240,7 @@ void setTest() { final String target = PLAYER1_NAME; game.skipStatus(Clock.systemDefaultZone().millis()); // NOTICE game.skipStatus(Clock.systemDefaultZone().millis()); // DAY - Mockito.when(sseEmitterRepository.findByCode(any())).thenReturn(List.of()); + Mockito.when(sseEmitterSession.findByCode(any())).thenReturn(List.of()); gameRepository.save(game); // when @@ -254,6 +254,6 @@ void setTest() { // then final StatusType actual = gameRepository.findById(CODE).get().getStatus().getType(); Assertions.assertThat(actual).isEqualTo(StatusType.VOTE); - Mockito.verify(sseEmitterRepository, Mockito.atLeast(1)).findByCode(CODE); + Mockito.verify(sseEmitterSession, Mockito.atLeast(1)).findByCode(CODE); } } diff --git a/src/test/java/mafia/mafiatogether/game/application/GameServiceTest.java b/src/test/java/mafia/mafiatogether/game/application/GameServiceTest.java index 2fce8a04..ab416051 100644 --- a/src/test/java/mafia/mafiatogether/game/application/GameServiceTest.java +++ b/src/test/java/mafia/mafiatogether/game/application/GameServiceTest.java @@ -2,111 +2,51 @@ import mafia.mafiatogether.game.domain.Game; import mafia.mafiatogether.game.domain.GameRepository; -import mafia.mafiatogether.game.domain.PlayerCollection; -import mafia.mafiatogether.game.domain.SseEmitterRepository; -import mafia.mafiatogether.game.domain.status.DayIntroStatus; import mafia.mafiatogether.game.domain.status.StatusType; -import mafia.mafiatogether.global.RedisTestContainerSpringBootTest; -import mafia.mafiatogether.job.domain.JobTarget; -import mafia.mafiatogether.job.domain.JobTargetRepository; -import mafia.mafiatogether.lobby.domain.Lobby; -import mafia.mafiatogether.lobby.domain.LobbyInfo; import mafia.mafiatogether.lobby.domain.LobbyRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; -import java.time.Clock; import java.util.List; -import java.util.Optional; import static org.assertj.core.api.SoftAssertions.assertSoftly; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; @SuppressWarnings("NonAsciiCharacters") -public class GameServiceTest extends RedisTestContainerSpringBootTest { +@ExtendWith(MockitoExtension.class) +public class GameServiceTest { - @Autowired - protected LobbyRepository lobbyRepository; - - @Autowired - protected GameRepository gameRepository; - - @Autowired + @InjectMocks private GameService gameService; - @MockBean - private SseEmitterRepository sseEmitterRepository; - - @MockBean - private JobTargetRepository jobTargetRepository; + @Mock + private GameRepository gameRepository; - private static Game STATUSCHANGEDGAME; - private static Game NOTCHANGEDGAME; - private static Long now; - - @BeforeEach - void setGames() { - now = Clock.systemDefaultZone().millis(); - STATUSCHANGEDGAME = new Game( - "STATUSCHANGEDGAME", - DayIntroStatus.create(now - 10_000L), - LobbyInfo.of(5, 2, 1, 1), - "master", - new PlayerCollection(), - DayIntroStatus.create(now - 10_000L) - ); - NOTCHANGEDGAME = new Game( - "NOTCHANGEDGAME", - DayIntroStatus.create(now + 10_000L), - LobbyInfo.of(5, 2, 1, 1), - "master", - new PlayerCollection(), - DayIntroStatus.create(now + 10_000L) - ); - lobbyRepository.save(Lobby.create(STATUSCHANGEDGAME.getCode(), LobbyInfo.of(5, 2, 1, 1))); - lobbyRepository.save(Lobby.create(NOTCHANGEDGAME.getCode(), LobbyInfo.of(5, 2, 1, 1))); - gameRepository.save(STATUSCHANGEDGAME); - gameRepository.save(NOTCHANGEDGAME); - } + @Mock + private LobbyRepository lobbyRepository; @Test void 스케쥴러에_의해_방의_시간이_변경된다() { // given - JobTarget mockedJobTarget = Mockito.mock(JobTarget.class); - Mockito.when(sseEmitterRepository.findByCode(any())).thenReturn(List.of()); - Mockito.when(jobTargetRepository.findById(any())).thenReturn(Optional.of(mockedJobTarget)); + Game changedGame = mock(Game.class); + Game notChangedGame = mock(Game.class); + when(gameRepository.findAll()).thenReturn(List.of(changedGame, notChangedGame)); + when(notChangedGame.getStatusType(anyLong())).thenReturn(StatusType.WAIT); + when(notChangedGame.isStatusChanged()).thenReturn(false); + when(changedGame.getStatusType(anyLong())).thenReturn(StatusType.DAY); + when(changedGame.isStatusChanged()).thenReturn(true); - // when & then + // when gameService.changeStatus(); - final StatusType actualChanged = gameRepository.findById(STATUSCHANGEDGAME.getCode()).get().getStatus() - .getType(); - final StatusType actualNotChanged = gameRepository.findById(NOTCHANGEDGAME.getCode()).get().getStatus() - .getType(); + // then assertSoftly( softly -> { - softly.assertThat(actualChanged).isEqualTo(StatusType.NOTICE); - softly.assertThat(actualNotChanged).isEqualTo(StatusType.DAY_INTRO); + verify(gameRepository).save(changedGame); } ); } - - @Test - void 상태가_변경시_이벤트가_발행된다() throws IOException { - // given - JobTarget mockedJobTarget = Mockito.mock(JobTarget.class); - Mockito.when(sseEmitterRepository.findByCode(any())).thenReturn(List.of()); - Mockito.when(jobTargetRepository.findById(any())).thenReturn(Optional.of(mockedJobTarget)); - - // when - gameService.changeStatus(); - - // then - Mockito.verify(sseEmitterRepository, Mockito.times(1)).findByCode(STATUSCHANGEDGAME.getCode()); - Mockito.verify(sseEmitterRepository, Mockito.times(0)).findByCode(NOTCHANGEDGAME.getCode()); - } } diff --git a/src/test/java/mafia/mafiatogether/game/ui/GameControllerTest.java b/src/test/java/mafia/mafiatogether/game/ui/GameControllerTest.java index 8104c150..cc26368b 100644 --- a/src/test/java/mafia/mafiatogether/game/ui/GameControllerTest.java +++ b/src/test/java/mafia/mafiatogether/game/ui/GameControllerTest.java @@ -31,45 +31,6 @@ void setTest() { setLobby(); } - @Test - void 대기방을_상태를_확인할_수_있다() { - //given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + PLAYER1_NAME).getBytes()); - - //when - final GameStatusResponse response = RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header("Authorization", "Basic " + basic) - .when().get("/games/status") - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(GameStatusResponse.class); - - //then - Assertions.assertThat(response.statusType()).isEqualTo(StatusType.WAIT); - } - - @Test - void 게임의_상태를_확인할_수_있다() { - //given - final String basic = Base64.getEncoder().encodeToString((CODE + ":" + PLAYER1_NAME).getBytes()); - setGame(); - - //when - final GameStatusResponse response = RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header("Authorization", "Basic " + basic) - .when().get("/games/status") - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(GameStatusResponse.class); - - //then - Assertions.assertThat(response.statusType()).isEqualTo(StatusType.DAY_INTRO); - } - @Test void 방을_상태를_변경할_수_있다() { //given & when diff --git a/src/test/java/mafia/mafiatogether/global/ControllerTest.java b/src/test/java/mafia/mafiatogether/global/ControllerTest.java index f7a0068e..a48def9b 100644 --- a/src/test/java/mafia/mafiatogether/global/ControllerTest.java +++ b/src/test/java/mafia/mafiatogether/global/ControllerTest.java @@ -2,6 +2,7 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; +import mafia.mafiatogether.lobby.application.LobbyEventListener; import mafia.mafiatogether.game.domain.GameRepository; import mafia.mafiatogether.game.domain.PlayerCollection; import mafia.mafiatogether.game.domain.status.StatusType; @@ -12,12 +13,13 @@ import mafia.mafiatogether.vote.domain.VoteRepository; import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import java.util.Base64; import java.util.Map; -public abstract class ControllerTest extends RedisTestContainerSpringBootTest{ +public abstract class ControllerTest extends RedisTestContainerSpringBootTest { @Autowired protected LobbyRepository lobbyRepository; @@ -28,12 +30,15 @@ public abstract class ControllerTest extends RedisTestContainerSpringBootTest{ @Autowired protected VoteRepository voteRepository; - protected final static String CODE = "1234567890"; - protected final static String PLAYER1_NAME = "player1"; - protected final static String PLAYER2_NAME = "player2"; - protected final static String PLAYER3_NAME = "player3"; - protected final static String PLAYER4_NAME = "player4"; - protected final static String PLAYER5_NAME = "player5"; + @MockBean + protected LobbyEventListener lobbyEventListener; + + protected static final String CODE = "1234567890"; + protected static final String PLAYER1_NAME = "player1"; + protected static final String PLAYER2_NAME = "player2"; + protected static final String PLAYER3_NAME = "player3"; + protected static final String PLAYER4_NAME = "player4"; + protected static final String PLAYER5_NAME = "player5"; protected String MAFIA1; protected String MAFIA2; protected String DOCTOR; diff --git a/src/test/java/mafia/mafiatogether/job/application/JobServiceTest.java b/src/test/java/mafia/mafiatogether/job/application/JobServiceTest.java new file mode 100644 index 00000000..b7b329be --- /dev/null +++ b/src/test/java/mafia/mafiatogether/job/application/JobServiceTest.java @@ -0,0 +1,84 @@ +package mafia.mafiatogether.job.application; + +import static org.mockito.BDDMockito.given; + +import java.util.HashMap; +import java.util.Optional; +import mafia.mafiatogether.job.application.dto.request.JobExecuteAbilityRequest; +import mafia.mafiatogether.job.application.dto.response.JobExecuteAbilityResponse; +import mafia.mafiatogether.job.domain.JobTarget; +import mafia.mafiatogether.job.domain.JobTargetRepository; +import mafia.mafiatogether.job.domain.PlayerJob; +import mafia.mafiatogether.job.domain.PlayerJobRepository; +import mafia.mafiatogether.job.domain.jobtype.Citizen; +import mafia.mafiatogether.job.domain.jobtype.Doctor; +import mafia.mafiatogether.job.domain.jobtype.Mafia; +import mafia.mafiatogether.job.domain.jobtype.Police; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; + +@SuppressWarnings("NonAsciiCharacters") +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +class JobServiceTest { + + @Autowired + private JobService jobService; + + @MockBean + private JobTargetRepository jobTargetRepository; + @MockBean + private PlayerJobRepository playerJobRepository; + + @Test + void 마피아_능력을_사용할_수_있다() { + //given + final String code = "test"; + given(jobTargetRepository.findById(code)).willReturn(Optional.of(new JobTarget(code, new HashMap<>()))); + given(playerJobRepository.findById(code)).willReturn(Optional.of(new PlayerJob(code, new HashMap<>() {{ + put("파워", new Mafia()); + put("달리", new Citizen()); + put("매튜", new Doctor()); + put("지윤", new Police()); + }}))); + + //when + JobExecuteAbilityResponse response = jobService.executeSkill( + code, + "파워", + new JobExecuteAbilityRequest("매튜") + ); + + //then + Assertions.assertThat(response.job()).isEqualTo("MAFIA"); + Assertions.assertThat(response.result()).isEqualTo("매튜"); + } + + @Test + void 경찰_능력을_사용할_수_있다() { + //given + final String code = "test"; + given(jobTargetRepository.findById(code)).willReturn(Optional.of(new JobTarget(code, new HashMap<>()))); + given(playerJobRepository.findById(code)).willReturn(Optional.of(new PlayerJob(code, new HashMap<>() {{ + put("파워", new Mafia()); + put("달리", new Citizen()); + put("매튜", new Doctor()); + put("지윤", new Police()); + }}))); + + //when + JobExecuteAbilityResponse response = jobService.executeSkill( + code, + "지윤", + new JobExecuteAbilityRequest("파워") + ); + + //then + Assertions.assertThat(response.job()).isEqualTo("POLICE"); + Assertions.assertThat(response.result()).isEqualTo("MAFIA"); + + } +} diff --git a/src/test/java/mafia/mafiatogether/job/ui/JobControllerTest.java b/src/test/java/mafia/mafiatogether/job/ui/JobControllerTest.java index bf670339..b42d6e64 100644 --- a/src/test/java/mafia/mafiatogether/job/ui/JobControllerTest.java +++ b/src/test/java/mafia/mafiatogether/job/ui/JobControllerTest.java @@ -4,8 +4,10 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; + import java.util.Base64; import java.util.Map; + import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.global.ControllerTest; import mafia.mafiatogether.job.domain.JobTargetRepository; @@ -58,24 +60,6 @@ void clearTest() { Assertions.assertThat(actual).isEqualTo(CITIZEN); } - @Test - void 초기_마피아_타겟은_NULL_값이다() { - // given - String basic = Base64.getEncoder().encodeToString((CODE + ":" + MAFIA1).getBytes()); - - // when & then - final String actual = RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header("Authorization", "Basic " + basic) - .when().get("/jobs/skill") - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract() - .body().jsonPath().getString("target"); - - Assertions.assertThat(actual).isNull(); - } - @Test void 마피아가_빈문자열을_보낼시_아무도_죽지않는다() { // given diff --git a/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java new file mode 100644 index 00000000..650154e0 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java @@ -0,0 +1,47 @@ +package mafia.mafiatogether.lobby.application; + +import mafia.mafiatogether.common.infra.SseEventPublisher; +import mafia.mafiatogether.lobby.application.dto.event.ParticipantJoinEvent; +import mafia.mafiatogether.lobby.domain.Lobby; +import mafia.mafiatogether.lobby.domain.LobbyInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class LobbyEventListenerTest { + + @InjectMocks + private LobbyEventListener lobbyEventListener; + + @Mock + private SseEventPublisher sseEventPublisher; + + @Test + void 새로운_유저가_접속하면_각각의_유저에게_SSE_Event를_발송합니다(){ + // given + LobbyInfo lobbyInfo = LobbyInfo.of(3, 1, 1, 1); + String roomCode = "code"; + String nameOfPlayer1 = "name1"; + String nameOfPlayer2 = "name2"; + final String newPlayer = "newPlayer"; + String eventName = "lobbyInfo"; + Lobby lobby = Lobby.create(roomCode, lobbyInfo); + lobby.joinPlayer(nameOfPlayer1); + lobby.joinPlayer(nameOfPlayer2); + + // when + lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby, newPlayer)); + + // then + verify(sseEventPublisher).publishEventByCodeAndName(eq(roomCode), eq(nameOfPlayer1), eq(eventName), any()); + verify(sseEventPublisher).publishEventByCodeAndName(eq(roomCode), eq(nameOfPlayer2), eq(eventName), any()); + verify(sseEventPublisher, times(0)).publishEventByCodeAndName(eq(roomCode), eq(newPlayer), eq(eventName), any()); + } +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/lobby/application/LobbyRemoveTest.java b/src/test/java/mafia/mafiatogether/lobby/application/LobbyRemoveTest.java index 48282be6..39560b3f 100644 --- a/src/test/java/mafia/mafiatogether/lobby/application/LobbyRemoveTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/application/LobbyRemoveTest.java @@ -1,39 +1,46 @@ package mafia.mafiatogether.lobby.application; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import java.time.Instant; import java.util.List; + +import mafia.mafiatogether.lobby.application.dto.event.DeleteLobbyEvent; import mafia.mafiatogether.lobby.domain.Lobby; import mafia.mafiatogether.lobby.domain.LobbyRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.TestPropertySource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; -@SpringBootTest @SuppressWarnings("NonAsciiCharacters") -@TestPropertySource(properties = {"application.scheduling-enable=false"}) +@ExtendWith(MockitoExtension.class) class LobbyRemoveTest { - @MockBean + @Mock private LobbyRepository lobbyRepository; - @Autowired + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks private LobbyRemoveService lobbyRemoveService; @Test void 스케줄러_동작_테스트() { Lobby mockLobby = Mockito.mock(Lobby.class); - Mockito.when(mockLobby.getLastUpdateTime()).thenReturn(Instant.now().getEpochSecond() - 3700); - Mockito.when(mockLobby.getCode()).thenReturn("1234567890"); - Mockito.when(lobbyRepository.findAll()).thenReturn(List.of(mockLobby)); + given(mockLobby.getLastUpdateTime()).willReturn(Instant.now().getEpochSecond() - 3700); + given(mockLobby.getCode()).willReturn("1234567890"); + given(lobbyRepository.findAll()).willReturn(List.of(mockLobby)); lobbyRemoveService.remove(); - verify(lobbyRepository, times(1)).deleteById(mockLobby.getCode()); + verify(applicationEventPublisher, times(1)).publishEvent(any(DeleteLobbyEvent.class)); } } diff --git a/src/test/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponseTest.java b/src/test/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponseTest.java new file mode 100644 index 00000000..81549b9a --- /dev/null +++ b/src/test/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponseTest.java @@ -0,0 +1,35 @@ +package mafia.mafiatogether.lobby.application.dto.response; + +import mafia.mafiatogether.lobby.domain.Lobby; +import mafia.mafiatogether.lobby.domain.LobbyInfo; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +class LobbyInfoResponseTest { + + @Test + void 유저를_받아_로비_정보를_반환한다() { + // given + LobbyInfo lobbyInfo = LobbyInfo.of(3, 1, 1, 1); + String roomCode = "code"; + String nameOfPlayer1 = "name1"; + String nameOfPlayer2 = "name2"; + Lobby lobby = Lobby.create(roomCode, lobbyInfo); + lobby.joinPlayer(nameOfPlayer1); + lobby.joinPlayer(nameOfPlayer2); + + // when + LobbyInfoResponse actual = LobbyInfoResponse.of(lobby, nameOfPlayer1); + + // then + assertThat(actual.isMaster()).isTrue(); + assertThat(actual.myName()).isEqualTo(nameOfPlayer1); + assertThat(actual.totalPlayers()).isEqualTo(3); + assertThat(actual.lobbyPlayerResponses()) + .extracting(LobbyPlayerResponse::name) + .containsExactlyInAnyOrder(nameOfPlayer1, nameOfPlayer2); + } + +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java index 83ae5247..3c45edc0 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java @@ -2,9 +2,14 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; + import java.util.Base64; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.stream.Stream; + import mafia.mafiatogether.common.exception.ErrorResponse; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.global.ControllerTest; @@ -22,22 +27,28 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; + +@Import(RedisLockTestLobbyService.class) @SuppressWarnings("NonAsciiCharacters") class LobbyControllerTest extends ControllerTest { @Autowired private LobbyRepository lobbyRepository; + @Autowired + private RedisLockTestLobbyService redisLockTestLobbyService; + @BeforeEach - void setTest(){ - final Lobby lobby = Lobby.create(CODE, LobbyInfo.of(3,1,1,1)); + void setTest() { + final Lobby lobby = Lobby.create(CODE, LobbyInfo.of(3, 1, 1, 1)); lobbyRepository.save(lobby); } @AfterEach - void clearTest(){ + void clearTest() { lobbyRepository.deleteById(CODE); } @@ -196,4 +207,48 @@ static Stream provideRoomCreateFailCase() { .statusCode(HttpStatus.OK.value()) .body("exist", Matchers.equalTo(true)); } + + @Test + void 방_한자리_남았을때_동시접근시_최초1인만_입장할_수_있다() throws InterruptedException { + // given + String expect = "C"; + Lobby lobby = lobbyRepository.findById(CODE).get(); + lobby.joinPlayer("A"); + lobby.joinPlayer("B"); + lobbyRepository.save(lobby); + int count = 2; + ExecutorService executorService = Executors.newFixedThreadPool(count); + CountDownLatch countDownLatch = new CountDownLatch(count); + + // when + executorService.execute( + () -> { + try { + redisLockTestLobbyService.waitAndInput(CODE, expect); + } catch (Exception e) { + e.printStackTrace(); + } finally { + countDownLatch.countDown(); + } + } + ); + + Thread.sleep(10); + + for (int i = 1; i < count; i++) { + executorService.execute( + () -> { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/lobbies?code=" + CODE + "&name=power") + .then().log().all(); + countDownLatch.countDown(); + } + ); + } + countDownLatch.await(); + + final Lobby actual = lobbyRepository.findById(CODE).get(); + Assertions.assertThat(actual.isParticipantExist(expect)).isTrue(); + } } diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java new file mode 100644 index 00000000..9b2ec5bd --- /dev/null +++ b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java @@ -0,0 +1,28 @@ +package mafia.mafiatogether.lobby.ui; + +import mafia.mafiatogether.common.annotation.RedisLock; +import mafia.mafiatogether.common.annotation.RedisLockTarget; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.exception.GameException; +import mafia.mafiatogether.lobby.domain.Lobby; +import mafia.mafiatogether.lobby.domain.LobbyRepository; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +class RedisLockTestLobbyService { + + private final LobbyRepository lobbyRepository; + + protected RedisLockTestLobbyService(LobbyRepository lobbyRepository) { + this.lobbyRepository = lobbyRepository; + } + + @RedisLock(key = "lobby") + protected void waitAndInput(@RedisLockTarget String code, String name) throws InterruptedException { + Thread.sleep(50); + final Lobby lobby = lobbyRepository.findById(code) + .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); + lobby.joinPlayer(name); + lobbyRepository.save(lobby); + } +}