From c49a93a8c227794562d1f8f0b4779d5b38e2a514 Mon Sep 17 00:00:00 2001 From: waterricecake <91263263+waterricecake@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:06:15 +0900 Subject: [PATCH 01/28] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index abb9d6ef..8f3a7936 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ ## 기술 스택 --- -![Skill](https://github.com/user-attachments/assets/e5b18101-c81d-4c54-9001-e9d5772c4c5f) - +![Skill](https://github.com/user-attachments/assets/f5f2bf5a-8249-4d88-b8d2-aff91a79fc76) ## 인프라 아키텍쳐 --- -![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) + ## 실시간 통신 --- From e1cc61669f7c30c95249acc7065278ed4cc9431b Mon Sep 17 00:00:00 2001 From: waterricecake <91263263+waterricecake@users.noreply.github.com> Date: Mon, 14 Oct 2024 19:01:44 +0900 Subject: [PATCH 02/28] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f3a7936..3b255975 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ ## 기술 스택 --- -![Skill](https://github.com/user-attachments/assets/f5f2bf5a-8249-4d88-b8d2-aff91a79fc76) +![Skill](https://github.com/user-attachments/assets/4695beb1-2ff3-4649-b6ee-80c67900a2a9) + ## 인프라 아키텍쳐 --- From 39847bd464bfe6f4e85bea9ae162cff44826e6f5 Mon Sep 17 00:00:00 2001 From: kpeel5839 <89840550+kpeel5839@users.noreply.github.com> Date: Sat, 19 Oct 2024 02:25:23 +0900 Subject: [PATCH 03/28] =?UTF-8?q?[feature]=20=EC=A0=84=EC=B2=B4=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B0=EB=8F=99=ED=95=9C=EB=8B=A4.=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: CI/CD에도 깃허브 연동 추가 * chore: FeignClient 의존성 추가 * feat: Service 구성 * feat: Exception Handler에 연결 * chore: discord 적용 * chore: 문구 수정 * chore: build.gradle 코드들과 컨벤션 일치 * refactor: 코드 정리 * refactor: Component Annotation 제거 --- .github/workflows/backend-CI.yml | 70 +++++++++++- .github/workflows/backend-dev-CICD.yml | 71 +++++++++++- .github/workflows/backend-prod-CICD.yml | 69 +++++++++++- backend-submodule | 2 +- build.gradle | 12 +- .../MafiaTogetherApplication.java | 1 + .../application/ErrorNotificationService.java | 36 ++++++ .../dto/ErrorDiscordMessageRequest.java | 49 ++++++++ .../common/config/FeignConfig.java | 38 +++++++ .../common/domain/DiscordMessage.java | 29 +++++ .../mafiatogether/common/domain/Embed.java | 23 ++++ .../domain/ErrorNotificationClient.java | 16 +++ .../domain/WarningNotificationClient.java | 16 +++ .../exception/GlobalExceptionHandler.java | 105 ++++++++++++++++-- 14 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 src/main/java/mafia/mafiatogether/common/application/ErrorNotificationService.java create mode 100644 src/main/java/mafia/mafiatogether/common/application/dto/ErrorDiscordMessageRequest.java create mode 100644 src/main/java/mafia/mafiatogether/common/config/FeignConfig.java create mode 100644 src/main/java/mafia/mafiatogether/common/domain/DiscordMessage.java create mode 100644 src/main/java/mafia/mafiatogether/common/domain/Embed.java create mode 100644 src/main/java/mafia/mafiatogether/common/domain/ErrorNotificationClient.java create mode 100644 src/main/java/mafia/mafiatogether/common/domain/WarningNotificationClient.java diff --git a/.github/workflows/backend-CI.yml b/.github/workflows/backend-CI.yml index 4cd376ad..c81e193d 100644 --- a/.github/workflows/backend-CI.yml +++ b/.github/workflows/backend-CI.yml @@ -2,7 +2,7 @@ name: Java CI with Gradle on: pull_request: - branches: [dev] + branches: [ dev ] permissions: contents: read @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 3 token: ${{ secrets.SUBMODULE_TOKEN }} - submodules: true + submodules: true - run: git log --pretty=oneline - name: JDK 21을 설치합니다. @@ -34,3 +34,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/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..13991e03 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,16 @@ 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' testImplementation 'org.testcontainers:testcontainers' @@ -39,7 +49,7 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'org.springframework.boot:spring-boot-starter-websocket' - + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' } 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/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/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/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/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/GlobalExceptionHandler.java b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java index 5e40150b..075bc23e 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,12 @@ package mafia.mafiatogether.common.exception; -import java.util.ArrayList; -import java.util.List; +import jakarta.servlet.http.HttpServletRequest; +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; @@ -14,23 +17,34 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; + @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GlobalExceptionHandler { + private final Environment environment; + private final ErrorNotificationService errorNotificationService; + private static final String LOCAL_PROFILE_NAME = "local"; + @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 +63,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); + } + } + From bdbcf613379efa64c8479a62726dfeeac24cb419 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Sat, 19 Oct 2024 02:55:09 +0900 Subject: [PATCH 04/28] =?UTF-8?q?fix=20:=20=EA=B2=8C=EC=9E=84=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=B4=ED=9B=84=20sse=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=EC=9E=90=EC=97=90=EA=B2=8C=20wait=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/game/application/GameEventListener.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java index 67c47be8..bc6181cf 100644 --- a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java +++ b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java @@ -99,11 +99,12 @@ public void listenStartGameEvent(final StartGameEvent startGameEvent) { } @EventListener - public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) { + public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) throws IOException { playerJobRepository.deleteById(deleteGameEvent.code()); jobTargetRepository.deleteById(deleteGameEvent.code()); chatRepository.deleteById(deleteGameEvent.code()); voteRepository.deleteById(deleteGameEvent.code()); + sendStatusChangeEventToSseClient(deleteGameEvent.code(), StatusType.WAIT); sseEmitterRepository.deleteByCode(deleteGameEvent.code()); gameRepository.deleteById(deleteGameEvent.code()); From 87b9d4e6d8af897fc8426dec22d5fe9aba79945a Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 18:44:04 +0900 Subject: [PATCH 05/28] =?UTF-8?q?feat=20:=20redis=20lock=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +- .../common/annotation/RedisLock.java | 32 ++++++ .../common/annotation/RedisLockTarget.java | 11 ++ .../common/aspect/RedisTransactionAspect.java | 104 ++++++++++++++++++ .../common/config/RedisConfig.java | 12 +- .../common/exception/ExceptionCode.java | 5 +- .../common/exception/ServerException.java | 7 ++ .../lobby/application/LobbyService.java | 7 +- 8 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java create mode 100644 src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java create mode 100644 src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java create mode 100644 src/main/java/mafia/mafiatogether/common/exception/ServerException.java diff --git a/build.gradle b/build.gradle index 13991e03..94c1361c 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencyManagement { 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' @@ -45,9 +46,6 @@ 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' } 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..145598b8 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java @@ -0,0 +1,32 @@ +package mafia.mafiatogether.common.annotation; + +import java.lang.annotation.*; + +/** + * 이 어노테이션은 서비스단에서 락대상을 정하는 메서드입니다. + * 해당 메서드에서 조회하는 모든 키에 대해 Lock을 거는 것이 아닌 특정 key에만 설정해줍니다. + * 또한 이 Lock이 어노테이션이 없는 대상은 해당 자원에 접근할 수 있습니다. + * 이 어노테이션은 이 어노테이션을 사용하는 메서드끼리만의 자원 접근을 막을 수 있습니다. + *

+ * 이 어노테이션을 사용하는 메서드는 꼭 @RedisLockTarget을 사용하여 잠금 대상을 지정해주어야 합니다. + *

+ * 작성자: waterricecake + *

+ * 수정일시 : 20241021 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedisLock { + /** + * key()는 자원의 유니크한 키를 사용하기 위해 사용합니다. + *

+     *  ex) @RedisLock(key = "lobby") 를 사용할 경우 키는 "mafiatogether:lock:lobby"가 완성됩니다. 이후 뒤는 랜덤한 값(code)를 사용하여 유니크하게 유지합니다.
+     * 
+ *

+ * 작성자: 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..053fd3ee --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java @@ -0,0 +1,11 @@ +package mafia.mafiatogether.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface RedisLockTarget { +} diff --git a/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java new file mode 100644 index 00000000..d6ec3501 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java @@ -0,0 +1,104 @@ +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.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 RedisTransactionAspect { + + private static final String LOCK_KEY_PREFIX = "mafiatogether:lock:"; + public static final int WAIT_TIME = 5; + public 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); + + boolean isLocked; + + try { + isLocked = tryLockAll(locks); + } 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 { + unlockAll(locks); + } + } + + 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 tryLockAll(final RLock[] rLocks) throws InterruptedException { + for (RLock rLock : rLocks) { + if(!rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) { + return false; + } + } + return true; + } + + private void unlockAll(final RLock[] rLocks) { + for (RLock rLock : rLocks) { + rLock.unlock(); + } + } + + private boolean hasTarget(Annotation[] annotations) { + return Arrays.stream(annotations).anyMatch(RedisLockTarget.class::isInstance); + } +} 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/exception/ExceptionCode.java b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java index 8635b156..ab75e0ef 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java +++ b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java @@ -30,7 +30,10 @@ 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, "레디스 락을 얻는데 실패하였습니다."); private final int code; private final String message; 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/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); From b2f2d3e8c511d591e302620e2dce2aeb4ac0c015 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 18:44:24 +0900 Subject: [PATCH 06/28] =?UTF-8?q?test=20:=20lobby=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lobby/ui/LobbyControllerTest.java | 61 ++++++++++++++++++- .../lobby/ui/RedisLockTestLobbyService.java | 27 ++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java index 83ae5247..22b2471b 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; @@ -13,6 +18,7 @@ import mafia.mafiatogether.lobby.domain.Lobby; import mafia.mafiatogether.lobby.domain.LobbyInfo; import mafia.mafiatogether.lobby.domain.LobbyRepository; +import mafia.mafiatogether.lobby.domain.Participant; import org.assertj.core.api.Assertions; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; @@ -31,13 +37,13 @@ class LobbyControllerTest extends ControllerTest { private LobbyRepository lobbyRepository; @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 +202,53 @@ 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 = 100; + ExecutorService executorService = Executors.newFixedThreadPool(count); + CountDownLatch countDownLatch = new CountDownLatch(count); + + // when + executorService.execute( + () -> { + RedisLockTestLobbyService redisLockTestLobbyService = new RedisLockTestLobbyService(lobbyRepository); + try { + redisLockTestLobbyService.waitAndInput(CODE, expect); + } catch (Exception e) { + e.printStackTrace(); + } finally { + countDownLatch.countDown(); + } + } + ); + + Thread.sleep(500); + + 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(); + System.out.println(actual.getParticipants().size()); + for (Participant name : lobby.getParticipants().getParticipants()) { + System.out.println(name.getName()); + } + 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..bffbac30 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java @@ -0,0 +1,27 @@ +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; + +public class RedisLockTestLobbyService { + + private final LobbyRepository lobbyRepository; + + public RedisLockTestLobbyService(LobbyRepository lobbyRepository) { + this.lobbyRepository = lobbyRepository; + } + + @RedisLock(key = "lobby") + public 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)); + System.out.println("Wait and Input"); + lobby.joinPlayer(name); + lobbyRepository.save(lobby); + } +} From ecd240a3a7072ad579a5d8dfbe1ce25ef3595b0c Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 19:08:53 +0900 Subject: [PATCH 07/28] =?UTF-8?q?feat=20:=20RedissonMultiLock=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EB=8B=A4=EC=A4=91=EB=9D=BD=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/aspect/RedisTransactionAspect.java | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java index d6ec3501..7d2d42f9 100644 --- a/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java +++ b/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java @@ -9,6 +9,7 @@ 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; @@ -32,11 +33,12 @@ public class RedisTransactionAspect { 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 = tryLockAll(locks); + isLocked = multiLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new ServerException(ExceptionCode.GETTING_LOCK_FAIL_EXCEPTION); } @@ -48,7 +50,7 @@ public Object lock(final ProceedingJoinPoint proceedingJoinPoint) throws Throwab try { return proceedingJoinPoint.proceed(); } finally { - unlockAll(locks); + multiLock.unlock(); } } @@ -83,21 +85,6 @@ private RLock[] getRLocks(final String[] keys) { return rLocks; } - private boolean tryLockAll(final RLock[] rLocks) throws InterruptedException { - for (RLock rLock : rLocks) { - if(!rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) { - return false; - } - } - return true; - } - - private void unlockAll(final RLock[] rLocks) { - for (RLock rLock : rLocks) { - rLock.unlock(); - } - } - private boolean hasTarget(Annotation[] annotations) { return Arrays.stream(annotations).anyMatch(RedisLockTarget.class::isInstance); } From 076f8be8c396956d3c942fcf6bceb003f4b229ad Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 19:13:42 +0900 Subject: [PATCH 08/28] =?UTF-8?q?refactor=20:=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=B6=9C=EB=A0=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafia/mafiatogether/lobby/ui/LobbyControllerTest.java | 5 ----- .../mafiatogether/lobby/ui/RedisLockTestLobbyService.java | 1 - 2 files changed, 6 deletions(-) diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java index 22b2471b..d8b9e7e9 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java @@ -18,7 +18,6 @@ import mafia.mafiatogether.lobby.domain.Lobby; import mafia.mafiatogether.lobby.domain.LobbyInfo; import mafia.mafiatogether.lobby.domain.LobbyRepository; -import mafia.mafiatogether.lobby.domain.Participant; import org.assertj.core.api.Assertions; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; @@ -245,10 +244,6 @@ static Stream provideRoomCreateFailCase() { countDownLatch.await(); final Lobby actual = lobbyRepository.findById(CODE).get(); - System.out.println(actual.getParticipants().size()); - for (Participant name : lobby.getParticipants().getParticipants()) { - System.out.println(name.getName()); - } 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 index bffbac30..807d423e 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java @@ -20,7 +20,6 @@ public void waitAndInput(@RedisLockTarget String code, String name) throws Inter Thread.sleep(50); final Lobby lobby = lobbyRepository.findById(code) .orElseThrow(() -> new GameException(ExceptionCode.INVALID_NOT_FOUND_ROOM_CODE)); - System.out.println("Wait and Input"); lobby.joinPlayer(name); lobbyRepository.save(lobby); } From 388bf58ba393bb2760e9b6fffa0fdefc1aac2214 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 19:44:38 +0900 Subject: [PATCH 09/28] =?UTF-8?q?refactor=20:=20testService=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=9E=90=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/lobby/ui/RedisLockTestLobbyService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java index 807d423e..0a9695aa 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java @@ -7,16 +7,16 @@ import mafia.mafiatogether.lobby.domain.Lobby; import mafia.mafiatogether.lobby.domain.LobbyRepository; -public class RedisLockTestLobbyService { +class RedisLockTestLobbyService { private final LobbyRepository lobbyRepository; - public RedisLockTestLobbyService(LobbyRepository lobbyRepository) { + protected RedisLockTestLobbyService(LobbyRepository lobbyRepository) { this.lobbyRepository = lobbyRepository; } @RedisLock(key = "lobby") - public void waitAndInput(@RedisLockTarget String code, String name) throws InterruptedException { + 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)); From 62a9674a7daedb15e97083f83cf2a16aa8cfc458 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Mon, 21 Oct 2024 19:59:29 +0900 Subject: [PATCH 10/28] =?UTF-8?q?refactor=20:=20RedisLockAspect=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{RedisTransactionAspect.java => RedisLockAspect.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/mafia/mafiatogether/common/aspect/{RedisTransactionAspect.java => RedisLockAspect.java} (98%) diff --git a/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java similarity index 98% rename from src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java rename to src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java index 7d2d42f9..cfb4c4e0 100644 --- a/src/main/java/mafia/mafiatogether/common/aspect/RedisTransactionAspect.java +++ b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java @@ -22,7 +22,7 @@ @Aspect @Component @RequiredArgsConstructor -public class RedisTransactionAspect { +public class RedisLockAspect { private static final String LOCK_KEY_PREFIX = "mafiatogether:lock:"; public static final int WAIT_TIME = 5; From 71b2d45700af4eb71b7763d48c911d5b45a502ec Mon Sep 17 00:00:00 2001 From: waterricecake Date: Wed, 23 Oct 2024 03:44:37 +0900 Subject: [PATCH 11/28] =?UTF-8?q?refactor=20:=20aspect=20=EB=82=B4=20stati?= =?UTF-8?q?c=20=EB=B3=80=EC=88=98=20=EC=A0=91=EA=B7=BC=EC=9E=90=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafia/mafiatogether/common/aspect/RedisLockAspect.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java index cfb4c4e0..f9c5bd98 100644 --- a/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java +++ b/src/main/java/mafia/mafiatogether/common/aspect/RedisLockAspect.java @@ -25,8 +25,8 @@ public class RedisLockAspect { private static final String LOCK_KEY_PREFIX = "mafiatogether:lock:"; - public static final int WAIT_TIME = 5; - public static final int LEASE_TIME = 15; + 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)") From 9af901e85cffcc2ca9abedbdfdaaf7a73d1ea479 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Wed, 23 Oct 2024 03:53:01 +0900 Subject: [PATCH 12/28] =?UTF-8?q?refactor=20:=20@Link=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EB=8C=80=EC=83=81=20=EB=A7=81=ED=81=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/common/annotation/RedisLock.java | 2 +- .../common/annotation/RedisLockTarget.java | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java index 145598b8..afd90975 100644 --- a/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java @@ -8,7 +8,7 @@ * 또한 이 Lock이 어노테이션이 없는 대상은 해당 자원에 접근할 수 있습니다. * 이 어노테이션은 이 어노테이션을 사용하는 메서드끼리만의 자원 접근을 막을 수 있습니다. *

- * 이 어노테이션을 사용하는 메서드는 꼭 @RedisLockTarget을 사용하여 잠금 대상을 지정해주어야 합니다. + * 이 어노테이션을 사용하는 메서드는 꼭 {@link mafia.mafiatogether.common.annotation.RedisLockTarget}을 사용하여 잠금 대상을 지정해주어야 합니다. *

* 작성자: waterricecake *

diff --git a/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java index 053fd3ee..03393f57 100644 --- a/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java @@ -1,10 +1,15 @@ package mafia.mafiatogether.common.annotation; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; +/** + * 이 어노테이션은 {@link mafia.mafiatogether.common.annotation.RedisLock}을 사용한 메서드의 String파라미터에 사용됩니다 + *

+ * 작성자: waterricecake + *

+ * 수정일시 : 20241021 + */ +@Documented @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface RedisLockTarget { From 5170e00ad0c86ddfd0f340978e10b6df243e0399 Mon Sep 17 00:00:00 2001 From: waterricecake Date: Wed, 23 Oct 2024 16:48:49 +0900 Subject: [PATCH 13/28] =?UTF-8?q?docs=20:=20@exception=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EC=98=88=EC=99=B8=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafia/mafiatogether/common/annotation/RedisLock.java | 7 ++++--- .../mafiatogether/common/annotation/RedisLockTarget.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java index afd90975..03f6c7b8 100644 --- a/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLock.java @@ -8,10 +8,8 @@ * 또한 이 Lock이 어노테이션이 없는 대상은 해당 자원에 접근할 수 있습니다. * 이 어노테이션은 이 어노테이션을 사용하는 메서드끼리만의 자원 접근을 막을 수 있습니다. *

- * 이 어노테이션을 사용하는 메서드는 꼭 {@link mafia.mafiatogether.common.annotation.RedisLockTarget}을 사용하여 잠금 대상을 지정해주어야 합니다. - *

* 작성자: waterricecake - *

+ *

* 수정일시 : 20241021 */ @Target(ElementType.METHOD) @@ -23,10 +21,13 @@ *
      *  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 index 03393f57..cfe675f6 100644 --- a/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java +++ b/src/main/java/mafia/mafiatogether/common/annotation/RedisLockTarget.java @@ -3,7 +3,7 @@ import java.lang.annotation.*; /** - * 이 어노테이션은 {@link mafia.mafiatogether.common.annotation.RedisLock}을 사용한 메서드의 String파라미터에 사용됩니다 + * 이 어노테이션은 {@link mafia.mafiatogether.common.annotation.RedisLock}을 사용한 메서드의 String파라미터에 사용됩니다. *

* 작성자: waterricecake *

From 7468f93f7cfee1a8e894b7315e5e6f01f92ab8ec Mon Sep 17 00:00:00 2001 From: waterricecake Date: Wed, 23 Oct 2024 18:06:31 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat=20:=20@TestComponent=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9D=B4=20=EC=A0=81=EC=9A=A9=EC=95=88?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/lobby/ui/LobbyControllerTest.java | 11 ++++++++--- .../lobby/ui/RedisLockTestLobbyService.java | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java index d8b9e7e9..3c45edc0 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/LobbyControllerTest.java @@ -27,14 +27,20 @@ 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)); @@ -210,14 +216,13 @@ static Stream provideRoomCreateFailCase() { lobby.joinPlayer("A"); lobby.joinPlayer("B"); lobbyRepository.save(lobby); - int count = 100; + int count = 2; ExecutorService executorService = Executors.newFixedThreadPool(count); CountDownLatch countDownLatch = new CountDownLatch(count); // when executorService.execute( () -> { - RedisLockTestLobbyService redisLockTestLobbyService = new RedisLockTestLobbyService(lobbyRepository); try { redisLockTestLobbyService.waitAndInput(CODE, expect); } catch (Exception e) { @@ -228,7 +233,7 @@ static Stream provideRoomCreateFailCase() { } ); - Thread.sleep(500); + Thread.sleep(10); for (int i = 1; i < count; i++) { executorService.execute( diff --git a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java index 0a9695aa..9b2ec5bd 100644 --- a/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java +++ b/src/test/java/mafia/mafiatogether/lobby/ui/RedisLockTestLobbyService.java @@ -6,7 +6,9 @@ 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; From 14f36ddef76d48121e6c4816da46c8e5acf6b0e3 Mon Sep 17 00:00:00 2001 From: kpeel5839 <89840550+kpeel5839@users.noreply.github.com> Date: Sun, 27 Oct 2024 21:14:44 +0900 Subject: [PATCH 15/28] refactor: change field name [contents --> content] (#141) --- .../mafiatogether/chat/application/dto/ChatV2Response.java | 4 ++-- .../chat/application/dto/response/ChatResponse.java | 5 +++-- src/main/java/mafia/mafiatogether/chat/domain/Message.java | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java b/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java index a5dce74d..7581f8d5 100644 --- a/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java +++ b/src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java @@ -8,7 +8,7 @@ public record ChatV2Response( String name, - String contents, + String content, MessageType messageType, Timestamp timestamp, Boolean isOwner @@ -20,7 +20,7 @@ public static ChatV2Response of( ) { return new ChatV2Response( message.getName(), - message.getContents(), + message.getContent(), 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..1f5b09a2 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,12 +1,13 @@ package mafia.mafiatogether.chat.application.dto.response; import java.sql.Timestamp; + import mafia.mafiatogether.chat.domain.Message; import mafia.mafiatogether.job.domain.jobtype.JobType; public record ChatResponse( String name, - String contents, + String content, Timestamp timestamp, Boolean isOwner, JobType job @@ -21,7 +22,7 @@ public static ChatResponse of( ) { return new ChatResponse( message.getName(), - message.getContents(), + message.getContent(), new Timestamp(message.getTimestamp()), isOwner, filteringMafia(isOwner, isMafia, jobType) 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; From a221af45b21b85b9f654b37e9ef4b11a2375525b Mon Sep 17 00:00:00 2001 From: kpeel5839 <89840550+kpeel5839@users.noreply.github.com> Date: Thu, 31 Oct 2024 21:26:00 +0900 Subject: [PATCH 16/28] =?UTF-8?q?#142=20-=20=EB=8C=80=EA=B8=B0=EB=B0=A9?= =?UTF-8?q?=EC=97=90=EC=84=9C=20SSE=EB=A5=BC=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=9B=90=EC=9D=84=20=EB=B0=9B=EC=95=84=EC=98=A8?= =?UTF-8?q?=EB=8B=A4.=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: findByCodeAndName 메서드 추가 * feat: ParticipantJoinEvent 추가하여 접속 시 SSE 요청 보낼 수 있도록 설정 * test: InMemorySseEmitterRepository Test 추가 * test: EventListener Test 추가 * test: LobbyInfoResponse Test 수정 및, EventListener MockBean 처리를 통해 에러 해결 * refactor: 패키지 이동 * refactor: 예외 변경 * refactor: Suppress 추가하여 Ascii Code Error 해결 * refactor: Lobby에 isMaster 구현 --- .../game/application/GameEventListener.java | 1 + .../game/application/LobbyEventListener.java | 43 +++++++++++++ .../domain/InMemorySseEmitterRepository.java | 10 ++++ .../game/domain/SseEmitterRepository.java | 4 +- .../mafiatogether/game/ui/GameController.java | 2 +- .../dto/event/ParticipantJoinEvent.java | 8 +++ .../dto/response/LobbyInfoResponse.java | 27 +++++++++ .../dto/response/LobbyPlayerResponse.java | 15 +++++ .../mafiatogether/lobby/domain/Lobby.java | 13 +++- .../InMemorySseEmitterRepositoryTest.java | 60 +++++++++++++++++++ .../mafiatogether/global/ControllerTest.java | 19 +++--- .../application/LobbyEventListenerTest.java | 56 +++++++++++++++++ .../dto/response/LobbyInfoResponseTest.java | 35 +++++++++++ 13 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java create mode 100644 src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java create mode 100644 src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponse.java create mode 100644 src/main/java/mafia/mafiatogether/lobby/application/dto/response/LobbyPlayerResponse.java create mode 100644 src/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java create mode 100644 src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java create mode 100644 src/test/java/mafia/mafiatogether/lobby/application/dto/response/LobbyInfoResponseTest.java diff --git a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java index bc6181cf..1fe5114e 100644 --- a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java +++ b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java @@ -157,4 +157,5 @@ private SseEventBuilder getSseEvent(StatusType statusType) { .name("gameStatus") .data(new GameStatusResponse(statusType)); } + } diff --git a/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java b/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java new file mode 100644 index 00000000..c165b702 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java @@ -0,0 +1,43 @@ +package mafia.mafiatogether.game.application; + +import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.game.domain.SseEmitterRepository; +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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class LobbyEventListener { + + private static final String LOBBY_EVENT_NAME = "lobbyInfo"; + private final SseEmitterRepository sseEmitterRepository; + + @EventListener + public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) throws IOException { + Lobby lobby = participantJoinEvent.lobby(); + String roomCode = lobby.getCode(); + List participants = lobby.getParticipants() + .getParticipants(); + + for (Participant participant : participants) { + String eachParticipantName = participant.getName(); + SseEmitter sseEmitter = sseEmitterRepository.findByCodeAndName(roomCode, eachParticipantName); + sseEmitter.send(getSseEvent(lobby, eachParticipantName)); + } + } + + private SseEmitter.SseEventBuilder getSseEvent(Lobby lobby, String eachParticipantName) { + return SseEmitter.event() + .name(LOBBY_EVENT_NAME) + .data(LobbyInfoResponse.of(lobby, eachParticipantName)); + } + +} diff --git a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java b/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java index ab578b7a..de0864b9 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java @@ -5,6 +5,8 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import mafia.mafiatogether.common.exception.ExceptionCode; +import mafia.mafiatogether.common.exception.ServerException; import org.springframework.stereotype.Repository; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -33,6 +35,14 @@ public List findByCode(String code) { return emitters.get(code).values().stream().toList(); } + @Override + public SseEmitter findByCodeAndName(String code, 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(String code) { emitters.remove(code); diff --git a/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java b/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java index 34052959..c9ce1358 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java @@ -6,12 +6,14 @@ public interface SseEmitterRepository { - 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/game/ui/GameController.java b/src/main/java/mafia/mafiatogether/game/ui/GameController.java index aa6d3c53..2b105def 100644 --- a/src/main/java/mafia/mafiatogether/game/ui/GameController.java +++ b/src/main/java/mafia/mafiatogether/game/ui/GameController.java @@ -53,8 +53,8 @@ public ResponseEntity findGameInfo( return ResponseEntity.ok(gameService.findGameInfo(playerInfoDto.code(), playerInfoDto.name())); } - @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @SseSubscribe + @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity subscribe(@PlayerInfo final PlayerInfoDto playerInfoDto) { return ResponseEntity.ok(new SseEmitter()); } 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..68940842 --- /dev/null +++ b/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java @@ -0,0 +1,8 @@ +package mafia.mafiatogether.lobby.application.dto.event; + +import mafia.mafiatogether.lobby.domain.Lobby; + +public record ParticipantJoinEvent( + Lobby lobby +) { +} 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..32615b52 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)); } 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/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java b/src/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java new file mode 100644 index 00000000..ddba7fc2 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java @@ -0,0 +1,60 @@ +package mafia.mafiatogether.game.domain; + +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 InMemorySseEmitterRepositoryTest { + + private InMemorySseEmitterRepository inMemorySseEmitterRepository; + + @BeforeEach + void setUp() { + inMemorySseEmitterRepository = new InMemorySseEmitterRepository(); + } + + @Test + void findByCodeAndName을_호출해서_SSE_Emitter를_가져옵니다() { + // given + SseEmitter sseEmitter = mock(SseEmitter.class); + SseEmitter findOutSseEmitter = mock(SseEmitter.class); + inMemorySseEmitterRepository.save("code", "name", sseEmitter); + inMemorySseEmitterRepository.save("code", "name2", findOutSseEmitter); + + // when + SseEmitter actual = inMemorySseEmitterRepository.findByCodeAndName("code", "name2"); + + // given + assertThat(actual).isEqualTo(findOutSseEmitter); + } + + @Test + void 존재하지_않는_코드를_입력하면_예외가_발생합니다() { + // givne when + ThrowingCallable actual = () -> inMemorySseEmitterRepository.findByCodeAndName("code", "name"); + + // then + assertThatThrownBy(actual).isInstanceOf(ServerException.class); + } + + @Test + void 존재하지_않는_유저를_입력하면_예외가_발생합니다() { + // givne + SseEmitter sseEmitter = mock(SseEmitter.class); + inMemorySseEmitterRepository.save("code", "name2", sseEmitter); + + // when + ThrowingCallable actual = () -> inMemorySseEmitterRepository.findByCodeAndName("code", "name"); + + // then + assertThatThrownBy(actual).isInstanceOf(ServerException.class); + } + +} \ No newline at end of file diff --git a/src/test/java/mafia/mafiatogether/global/ControllerTest.java b/src/test/java/mafia/mafiatogether/global/ControllerTest.java index f7a0068e..f165d85e 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.game.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/lobby/application/LobbyEventListenerTest.java b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java new file mode 100644 index 00000000..6a5d7e05 --- /dev/null +++ b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java @@ -0,0 +1,56 @@ +package mafia.mafiatogether.lobby.application; + +import mafia.mafiatogether.game.application.LobbyEventListener; +import mafia.mafiatogether.game.domain.SseEmitterRepository; +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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class LobbyEventListenerTest { + + @InjectMocks + private LobbyEventListener lobbyEventListener; + + @Mock + private SseEmitterRepository sseEmitterRepository; + + @Test + void 새로운_유저가_접속하면_각각의_유저에게_SSE_Event를_발송합니다() throws Exception { + // given + List sseEmitters = List.of(mock(SseEmitter.class), mock(SseEmitter.class)); + 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); + given(sseEmitterRepository.findByCodeAndName(roomCode, nameOfPlayer1)).willReturn(sseEmitters.get(0)); + given(sseEmitterRepository.findByCodeAndName(roomCode, nameOfPlayer2)).willReturn(sseEmitters.get(1)); + + // when + lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby)); + + // then + verify(sseEmitterRepository).findByCodeAndName(roomCode, nameOfPlayer1); + verify(sseEmitterRepository).findByCodeAndName(roomCode, nameOfPlayer2); + verify(sseEmitters.get(0)).send(any(SseEmitter.SseEventBuilder.class)); + verify(sseEmitters.get(1)).send(any(SseEmitter.SseEventBuilder.class)); + } + +} \ No newline at end of file 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 From d396dfee327ada1b8f4b621fd37a0eae49deaa84 Mon Sep 17 00:00:00 2001 From: waterricecake <91263263+waterricecake@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:17:39 +0900 Subject: [PATCH 17/28] =?UTF-8?q?hotfix=20:=20=EC=B0=B8=EA=B0=80=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20sse=EA=B5=AC=EB=8F=85=EC=A0=84=EC=97=90=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EB=8D=98=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/application/LobbyEventListener.java | 20 +++++++++++++++---- .../dto/event/ParticipantJoinEvent.java | 3 ++- .../mafiatogether/lobby/domain/Lobby.java | 2 +- .../application/LobbyEventListenerTest.java | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java b/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java index c165b702..b7748df0 100644 --- a/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java +++ b/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java @@ -21,16 +21,28 @@ public class LobbyEventListener { private final SseEmitterRepository sseEmitterRepository; @EventListener - public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) throws IOException { + public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) { Lobby lobby = participantJoinEvent.lobby(); - String roomCode = lobby.getCode(); + String ignoreName = participantJoinEvent.name(); List participants = lobby.getParticipants() .getParticipants(); for (Participant participant : participants) { String eachParticipantName = participant.getName(); - SseEmitter sseEmitter = sseEmitterRepository.findByCodeAndName(roomCode, eachParticipantName); - sseEmitter.send(getSseEvent(lobby, eachParticipantName)); + sendEvent(lobby, eachParticipantName, ignoreName); + } + } + + private void sendEvent(Lobby lobby, String participantName, String ignoreName) { + if (participantName.equals(ignoreName)) { + return; + } + String code = lobby.getCode(); + SseEmitter sseEmitter = sseEmitterRepository.findByCodeAndName(code, participantName); + try { + sseEmitter.send(getSseEvent(lobby, participantName)); + } catch (IOException e) { + sseEmitter.completeWithError(e); } } 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 index 68940842..c80fbf25 100644 --- a/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java +++ b/src/main/java/mafia/mafiatogether/lobby/application/dto/event/ParticipantJoinEvent.java @@ -3,6 +3,7 @@ import mafia.mafiatogether.lobby.domain.Lobby; public record ParticipantJoinEvent( - Lobby lobby + Lobby lobby, + String name ) { } diff --git a/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java b/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java index 32615b52..c505c267 100644 --- a/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java +++ b/src/main/java/mafia/mafiatogether/lobby/domain/Lobby.java @@ -54,7 +54,7 @@ public void joinPlayer(final String name) { if (master.equals(Participant.NONE)) { master = participant; } - registerEvent(new ParticipantJoinEvent(this)); + registerEvent(new ParticipantJoinEvent(this, name)); } public void validateToStart() { diff --git a/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java index 6a5d7e05..c97df40a 100644 --- a/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java @@ -44,7 +44,7 @@ class LobbyEventListenerTest { given(sseEmitterRepository.findByCodeAndName(roomCode, nameOfPlayer2)).willReturn(sseEmitters.get(1)); // when - lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby)); + lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby, "newParticipant")); // then verify(sseEmitterRepository).findByCodeAndName(roomCode, nameOfPlayer1); From 217de8e16bf4c8a3aa7a771db7e44d0b835dc878 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:58:36 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20=EC=A7=81=EC=97=85=20=EC=8A=A4?= =?UTF-8?q?=ED=82=AC=20=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 직업 스킬 사용을 웹소켓으로 변경 * refactor: JobController 통일 및 테스트 수정 --- .../interceptor/StompChannelInterceptor.java | 15 ++-- .../job/application/JobService.java | 6 +- .../mafiatogether/job/ui/JobController.java | 19 +++++ .../job/application/JobServiceTest.java | 84 +++++++++++++++++++ 4 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 src/test/java/mafia/mafiatogether/job/application/JobServiceTest.java diff --git a/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java b/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java index a66de0d5..435dc4b1 100644 --- a/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java +++ b/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java @@ -1,5 +1,8 @@ package mafia.mafiatogether.common.interceptor; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.util.AuthExtractor; import org.springframework.context.annotation.Configuration; @@ -11,16 +14,12 @@ 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 { - 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 +43,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 +77,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/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..b9102157 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -1,5 +1,7 @@ package mafia.mafiatogether.job.ui; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; @@ -9,7 +11,11 @@ import mafia.mafiatogether.job.application.dto.response.MafiaTargetResponse; import mafia.mafiatogether.job.application.dto.response.JobExecuteAbilityResponse; 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.DestinationVariable; +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; @@ -22,12 +28,25 @@ public class JobController { private final JobService jobService; + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; @GetMapping("/my") public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDto) { return ResponseEntity.ok(jobService.getPlayerJob(playerInfoDto.code(), playerInfoDto.name())); } + @MessageMapping("/skill/{code}/{name}") + public void executeSkillV2( + @DestinationVariable("code") String code, + @DestinationVariable("name") String name, + @Payload JobExecuteAbilityRequest request + ) throws JsonProcessingException { + JobExecuteAbilityResponse response = jobService.executeSkill(code, name, request); + String message = objectMapper.writeValueAsString(response); + stringRedisTemplate.convertAndSend("/sub/jobs/skill/" + response.job().toLowerCase() + "/" + code, message); + } + @PostMapping("/skill") public ResponseEntity executeSkill( @PlayerInfo PlayerInfoDto playerInfoDto, 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"); + + } +} From a1b3e8b2c7a8fb14a2e6dc28eca7fcc0733f1f7f Mon Sep 17 00:00:00 2001 From: waterricecake <91263263+waterricecake@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:38:41 +0900 Subject: [PATCH 19/28] =?UTF-8?q?#144=20-=20SSE=EB=A5=BC=20Game=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : SSE 로직 Game에서 분리 * refactor : SSE event publish 로직 bean을 통해 common으로 이동 * refactor : sse disconnect 로직 common으로 이동 * refactor : SseRepository 명 Session 변경 및 infra package로 이동 * Test : SseEventPublisher 테스트 작성 * test : SseAspectTest 구현 * refactor : 상수 함수 내부 변수로 변경 * refactor : final 키워드 추가 및 SseEventBuilder import 추가 * refactor : 메서드 네임 수정 및 Sse 생성 매서드 위치 이동 * refactor : aspect test 수정 * refactor : aspect test method 모킹 * refactor : mockito 버전 문제 해결 --- build.gradle | 2 + .../annotation/SseSubscribe.java | 2 +- .../common/aspect/SseAspect.java | 63 +++++++++++ .../domain/SseEmitterSession.java} | 4 +- .../infra/InMemorySseEmitterSession.java} | 19 ++-- .../common/infra/SseEventPublisher.java | 65 +++++++++++ .../game/application/GameEventListener.java | 64 +++++------ .../mafiatogether/game/aspect/SseService.java | 89 --------------- .../mafiatogether/game/ui/GameController.java | 11 +- .../application/LobbyEventListener.java | 26 ++--- .../common/aspect/SseAspectTest.java | 66 +++++++++++ .../infra/InMemorySseEmitterSessionTest.java} | 20 ++-- .../common/infra/SseEventPublisherTest.java | 86 +++++++++++++++ .../application/GameEventListenerTest.java | 8 +- .../game/application/GameServiceTest.java | 104 ++++-------------- .../mafiatogether/global/ControllerTest.java | 2 +- .../application/LobbyEventListenerTest.java | 29 ++--- .../lobby/application/LobbyRemoveTest.java | 31 ++++-- 18 files changed, 403 insertions(+), 288 deletions(-) rename src/main/java/mafia/mafiatogether/{game => common}/annotation/SseSubscribe.java (73%) create mode 100644 src/main/java/mafia/mafiatogether/common/aspect/SseAspect.java rename src/main/java/mafia/mafiatogether/{game/domain/SseEmitterRepository.java => common/domain/SseEmitterSession.java} (84%) rename src/main/java/mafia/mafiatogether/{game/domain/InMemorySseEmitterRepository.java => common/infra/InMemorySseEmitterSession.java} (70%) create mode 100644 src/main/java/mafia/mafiatogether/common/infra/SseEventPublisher.java delete mode 100644 src/main/java/mafia/mafiatogether/game/aspect/SseService.java rename src/main/java/mafia/mafiatogether/{game => lobby}/application/LobbyEventListener.java (60%) create mode 100644 src/test/java/mafia/mafiatogether/common/aspect/SseAspectTest.java rename src/test/java/mafia/mafiatogether/{game/domain/InMemorySseEmitterRepositoryTest.java => common/infra/InMemorySseEmitterSessionTest.java} (64%) create mode 100644 src/test/java/mafia/mafiatogether/common/infra/SseEventPublisherTest.java diff --git a/build.gradle b/build.gradle index 94c1361c..06ff0c11 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,8 @@ dependencies { 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/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/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/game/domain/SseEmitterRepository.java b/src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java similarity index 84% rename from src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java rename to src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java index c9ce1358..bf28bc6c 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/SseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/common/domain/SseEmitterSession.java @@ -1,10 +1,10 @@ -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); diff --git a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java b/src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java similarity index 70% rename from src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java rename to src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java index de0864b9..787e4c08 100644 --- a/src/main/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepository.java +++ b/src/main/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSession.java @@ -1,21 +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 mafia.mafiatogether.common.domain.SseEmitterSession; import mafia.mafiatogether.common.exception.ExceptionCode; import mafia.mafiatogether.common.exception.ServerException; -import org.springframework.stereotype.Repository; +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<>(); } @@ -28,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<>(); } @@ -36,7 +37,7 @@ public List findByCode(String code) { } @Override - public SseEmitter findByCodeAndName(String code, String name) { + public SseEmitter findByCodeAndName(final String code, final String name) { if (!emitters.containsKey(code) || !emitters.get(code).containsKey(name)) { throw new ServerException(ExceptionCode.INVALID_PLAYER); } @@ -44,12 +45,12 @@ public SseEmitter findByCodeAndName(String code, String name) { } @Override - public void deleteByCode(String code) { + 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/game/application/GameEventListener.java b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java index 1fe5114e..cd13edfa 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) { @@ -99,22 +96,23 @@ public void listenStartGameEvent(final StartGameEvent startGameEvent) { } @EventListener - public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) throws IOException { - playerJobRepository.deleteById(deleteGameEvent.code()); - jobTargetRepository.deleteById(deleteGameEvent.code()); - chatRepository.deleteById(deleteGameEvent.code()); - voteRepository.deleteById(deleteGameEvent.code()); - sendStatusChangeEventToSseClient(deleteGameEvent.code(), StatusType.WAIT); - sseEmitterRepository.deleteByCode(deleteGameEvent.code()); - gameRepository.deleteById(deleteGameEvent.code()); - - final Lobby room = lobbyRepository.findById(deleteGameEvent.code()) + public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) { + final String code = deleteGameEvent.code(); + playerJobRepository.deleteById(code); + jobTargetRepository.deleteById(code); + chatRepository.deleteById(code); + voteRepository.deleteById(code); + sendStatusChangeEventToSseClient(code, StatusType.WAIT); + sseEventPublisher.disconnectSseByCode(code); + 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)) { @@ -131,31 +129,23 @@ 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 { + public void listenGameStatusChangeEvent(final GameStatusChangeEvent gameStatusChangeEvent) { 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)); - } + private void sendStatusChangeEventToSseClient(final String code, final StatusType statusType) { + final GameStatusResponse gameStatusResponse = new GameStatusResponse(statusType); + sseEventPublisher.publishEventByCode(code, GAME_STATUS_EVENT_NAME, gameStatusResponse); } - - private SseEventBuilder getSseEvent(StatusType statusType) { - return SseEmitter.event() - .name("gameStatus") - .data(new GameStatusResponse(statusType)); - } - } 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 2b105def..08722166 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,12 +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; + private static final String SSE_STATUS = "gameStatus"; @GetMapping("/status") public ResponseEntity findStatus( @@ -55,8 +59,9 @@ public ResponseEntity findGameInfo( @SseSubscribe @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public ResponseEntity subscribe(@PlayerInfo final PlayerInfoDto playerInfoDto) { - return ResponseEntity.ok(new SseEmitter()); + 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/game/application/LobbyEventListener.java b/src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java similarity index 60% rename from src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java rename to src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java index b7748df0..6e71b2a4 100644 --- a/src/main/java/mafia/mafiatogether/game/application/LobbyEventListener.java +++ b/src/main/java/mafia/mafiatogether/lobby/application/LobbyEventListener.java @@ -1,16 +1,14 @@ -package mafia.mafiatogether.game.application; +package mafia.mafiatogether.lobby.application; import lombok.RequiredArgsConstructor; -import mafia.mafiatogether.game.domain.SseEmitterRepository; +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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import java.io.IOException; import java.util.List; @Component @@ -18,7 +16,7 @@ public class LobbyEventListener { private static final String LOBBY_EVENT_NAME = "lobbyInfo"; - private final SseEmitterRepository sseEmitterRepository; + private final SseEventPublisher sseEventPublisher; @EventListener public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) { @@ -27,6 +25,7 @@ public void handleJoinEvent(final ParticipantJoinEvent participantJoinEvent) { List participants = lobby.getParticipants() .getParticipants(); + for (Participant participant : participants) { String eachParticipantName = participant.getName(); sendEvent(lobby, eachParticipantName, ignoreName); @@ -37,19 +36,8 @@ private void sendEvent(Lobby lobby, String participantName, String ignoreName) { if (participantName.equals(ignoreName)) { return; } - String code = lobby.getCode(); - SseEmitter sseEmitter = sseEmitterRepository.findByCodeAndName(code, participantName); - try { - sseEmitter.send(getSseEvent(lobby, participantName)); - } catch (IOException e) { - sseEmitter.completeWithError(e); - } + final String code = lobby.getCode(); + final LobbyInfoResponse lobbyInfoResponse = LobbyInfoResponse.of(lobby, participantName); + sseEventPublisher.publishEventByCodeAndName(code, participantName, LOBBY_EVENT_NAME, lobbyInfoResponse); } - - private SseEmitter.SseEventBuilder getSseEvent(Lobby lobby, String eachParticipantName) { - return SseEmitter.event() - .name(LOBBY_EVENT_NAME) - .data(LobbyInfoResponse.of(lobby, eachParticipantName)); - } - } 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/game/domain/InMemorySseEmitterRepositoryTest.java b/src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java similarity index 64% rename from src/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java rename to src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java index ddba7fc2..f500ce1f 100644 --- a/src/test/java/mafia/mafiatogether/game/domain/InMemorySseEmitterRepositoryTest.java +++ b/src/test/java/mafia/mafiatogether/common/infra/InMemorySseEmitterSessionTest.java @@ -1,4 +1,4 @@ -package mafia.mafiatogether.game.domain; +package mafia.mafiatogether.common.infra; import mafia.mafiatogether.common.exception.ServerException; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; @@ -11,13 +11,13 @@ import static org.mockito.Mockito.mock; @SuppressWarnings("NonAsciiCharacters") -class InMemorySseEmitterRepositoryTest { +class InMemorySseEmitterSessionTest { - private InMemorySseEmitterRepository inMemorySseEmitterRepository; + private InMemorySseEmitterSession inMemorySseEmitterSession; @BeforeEach void setUp() { - inMemorySseEmitterRepository = new InMemorySseEmitterRepository(); + inMemorySseEmitterSession = new InMemorySseEmitterSession(); } @Test @@ -25,11 +25,11 @@ void setUp() { // given SseEmitter sseEmitter = mock(SseEmitter.class); SseEmitter findOutSseEmitter = mock(SseEmitter.class); - inMemorySseEmitterRepository.save("code", "name", sseEmitter); - inMemorySseEmitterRepository.save("code", "name2", findOutSseEmitter); + inMemorySseEmitterSession.save("code", "name", sseEmitter); + inMemorySseEmitterSession.save("code", "name2", findOutSseEmitter); // when - SseEmitter actual = inMemorySseEmitterRepository.findByCodeAndName("code", "name2"); + SseEmitter actual = inMemorySseEmitterSession.findByCodeAndName("code", "name2"); // given assertThat(actual).isEqualTo(findOutSseEmitter); @@ -38,7 +38,7 @@ void setUp() { @Test void 존재하지_않는_코드를_입력하면_예외가_발생합니다() { // givne when - ThrowingCallable actual = () -> inMemorySseEmitterRepository.findByCodeAndName("code", "name"); + ThrowingCallable actual = () -> inMemorySseEmitterSession.findByCodeAndName("code", "name"); // then assertThatThrownBy(actual).isInstanceOf(ServerException.class); @@ -48,10 +48,10 @@ void setUp() { void 존재하지_않는_유저를_입력하면_예외가_발생합니다() { // givne SseEmitter sseEmitter = mock(SseEmitter.class); - inMemorySseEmitterRepository.save("code", "name2", sseEmitter); + inMemorySseEmitterSession.save("code", "name2", sseEmitter); // when - ThrowingCallable actual = () -> inMemorySseEmitterRepository.findByCodeAndName("code", "name"); + ThrowingCallable actual = () -> inMemorySseEmitterSession.findByCodeAndName("code", "name"); // then assertThatThrownBy(actual).isInstanceOf(ServerException.class); 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/global/ControllerTest.java b/src/test/java/mafia/mafiatogether/global/ControllerTest.java index f165d85e..a48def9b 100644 --- a/src/test/java/mafia/mafiatogether/global/ControllerTest.java +++ b/src/test/java/mafia/mafiatogether/global/ControllerTest.java @@ -2,7 +2,7 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; -import mafia.mafiatogether.game.application.LobbyEventListener; +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; diff --git a/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java index c97df40a..650154e0 100644 --- a/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java +++ b/src/test/java/mafia/mafiatogether/lobby/application/LobbyEventListenerTest.java @@ -1,7 +1,6 @@ package mafia.mafiatogether.lobby.application; -import mafia.mafiatogether.game.application.LobbyEventListener; -import mafia.mafiatogether.game.domain.SseEmitterRepository; +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; @@ -10,14 +9,9 @@ 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.util.List; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; @SuppressWarnings("NonAsciiCharacters") @ExtendWith(MockitoExtension.class) @@ -27,30 +21,27 @@ class LobbyEventListenerTest { private LobbyEventListener lobbyEventListener; @Mock - private SseEmitterRepository sseEmitterRepository; + private SseEventPublisher sseEventPublisher; @Test - void 새로운_유저가_접속하면_각각의_유저에게_SSE_Event를_발송합니다() throws Exception { + void 새로운_유저가_접속하면_각각의_유저에게_SSE_Event를_발송합니다(){ // given - List sseEmitters = List.of(mock(SseEmitter.class), mock(SseEmitter.class)); 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); - given(sseEmitterRepository.findByCodeAndName(roomCode, nameOfPlayer1)).willReturn(sseEmitters.get(0)); - given(sseEmitterRepository.findByCodeAndName(roomCode, nameOfPlayer2)).willReturn(sseEmitters.get(1)); // when - lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby, "newParticipant")); + lobbyEventListener.handleJoinEvent(new ParticipantJoinEvent(lobby, newPlayer)); // then - verify(sseEmitterRepository).findByCodeAndName(roomCode, nameOfPlayer1); - verify(sseEmitterRepository).findByCodeAndName(roomCode, nameOfPlayer2); - verify(sseEmitters.get(0)).send(any(SseEmitter.SseEventBuilder.class)); - verify(sseEmitters.get(1)).send(any(SseEmitter.SseEventBuilder.class)); + 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)); } } From f470eae412a52912ae0e32b13c7a7500569185ef Mon Sep 17 00:00:00 2001 From: kpeel5839 <89840550+kpeel5839@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:11:22 +0900 Subject: [PATCH 20/28] =?UTF-8?q?#150=20-=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: status 제거 * refactor: Mafia 결과 조회 제거 및 v2 명시 제거 * refactor: Http 통신 제거 및 버전 표식 제거 * refactor: 테스트 수정 --- .../chat/application/ChatService.java | 50 +++--- .../chat/application/ChatV2Service.java | 61 ------- .../chat/application/dto/ChatV2Response.java | 29 --- .../dto/response/ChatResponse.java | 32 +--- .../mafiatogether/chat/ui/ChatController.java | 57 ++++-- .../chat/ui/ChatV2Controller.java | 60 ------- .../mafiatogether/game/ui/GameController.java | 8 - .../mafiatogether/job/ui/JobController.java | 21 +-- .../chat/ui/ChatControllerTest.java | 165 ------------------ .../game/ui/GameControllerTest.java | 39 ----- .../job/ui/JobControllerTest.java | 20 +-- 11 files changed, 81 insertions(+), 461 deletions(-) delete mode 100644 src/main/java/mafia/mafiatogether/chat/application/ChatV2Service.java delete mode 100644 src/main/java/mafia/mafiatogether/chat/application/dto/ChatV2Response.java delete mode 100644 src/main/java/mafia/mafiatogether/chat/ui/ChatV2Controller.java delete mode 100644 src/test/java/mafia/mafiatogether/chat/ui/ChatControllerTest.java 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 7581f8d5..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 content, - MessageType messageType, - Timestamp timestamp, - Boolean isOwner -) { - - public static ChatV2Response of( - Message message, - boolean isOwner - ) { - return new ChatV2Response( - message.getName(), - message.getContent(), - 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 1f5b09a2..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,45 +1,29 @@ package mafia.mafiatogether.chat.application.dto.response; +import mafia.mafiatogether.chat.domain.Message; +import mafia.mafiatogether.chat.domain.vo.MessageType; + import java.sql.Timestamp; -import mafia.mafiatogether.chat.domain.Message; -import mafia.mafiatogether.job.domain.jobtype.JobType; public record ChatResponse( String name, 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.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/ui/ChatController.java b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java index 0b839645..e91cbb2c 100644 --- a/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java +++ b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java @@ -1,41 +1,60 @@ 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 +import java.util.List; + +@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/game/ui/GameController.java b/src/main/java/mafia/mafiatogether/game/ui/GameController.java index 08722166..f057e1f1 100644 --- a/src/main/java/mafia/mafiatogether/game/ui/GameController.java +++ b/src/main/java/mafia/mafiatogether/game/ui/GameController.java @@ -9,7 +9,6 @@ import mafia.mafiatogether.game.application.dto.response.GameExistResponse; import mafia.mafiatogether.game.application.dto.response.GameInfoResponse; import mafia.mafiatogether.game.application.dto.response.GameResultResponse; -import mafia.mafiatogether.game.application.dto.response.GameStatusResponse; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -28,13 +27,6 @@ public class GameController { private final GameService gameService; private static final String SSE_STATUS = "gameStatus"; - @GetMapping("/status") - public ResponseEntity findStatus( - @PlayerInfo final PlayerInfoDto playerInfoDto - ) { - return ResponseEntity.ok(gameService.findStatus(playerInfoDto.code())); - } - @PostMapping("/start") public ResponseEntity startGame( @PlayerInfo final PlayerInfoDto playerInfoDto diff --git a/src/main/java/mafia/mafiatogether/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index b9102157..b9c9186a 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -7,20 +7,15 @@ 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.DestinationVariable; 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; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -37,7 +32,7 @@ public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDt } @MessageMapping("/skill/{code}/{name}") - public void executeSkillV2( + public void executeSkill( @DestinationVariable("code") String code, @DestinationVariable("name") String name, @Payload JobExecuteAbilityRequest request @@ -59,16 +54,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/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/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/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 From 93c9334eec71cc325f009f5534ebcacffe443517 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:11:36 +0900 Subject: [PATCH 21/28] =?UTF-8?q?chore:=20404=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=EC=B2=98=EB=A6=AC=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ExceptionCode.java | 4 +++- .../exception/GlobalExceptionHandler.java | 20 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java index ab75e0ef..c3f4163e 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java +++ b/src/main/java/mafia/mafiatogether/common/exception/ExceptionCode.java @@ -33,7 +33,9 @@ public enum ExceptionCode { INVALID_AUTHENTICATION_FORM(302, "올바른 인증 형식이 아닙니다."), LOCK_CODE_EXCEPTION(501, "RedisLockTarget 파라미터를 찾을 수 없습니다."), - GETTING_LOCK_FAIL_EXCEPTION(502, "레디스 락을 얻는데 실패하였습니다."); + 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 075bc23e..9812f472 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,10 @@ 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; @@ -16,11 +20,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Enumeration; -import java.util.List; +import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @RestControllerAdvice @@ -31,6 +31,16 @@ public class GlobalExceptionHandler { private final ErrorNotificationService errorNotificationService; private static final String LOCAL_PROFILE_NAME = "local"; + @ExceptionHandler(NoHandlerFoundException.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, HttpServletRequest request) { final ErrorResponse errorResponse = ErrorResponse.create( From 6099ce1fc8321a9bf957802e76c2b2b76d5863b8 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:24:04 +0900 Subject: [PATCH 22/28] =?UTF-8?q?#155=20-=20ci=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ci시 라벨 체크 삭제 * fix: import 추가 --- .github/workflows/backend-CI.yml | 1 - src/main/java/mafia/mafiatogether/game/ui/GameController.java | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-CI.yml b/.github/workflows/backend-CI.yml index c81e193d..2c2e7565 100644 --- a/.github/workflows/backend-CI.yml +++ b/.github/workflows/backend-CI.yml @@ -9,7 +9,6 @@ permissions: jobs: build: - if: contains(github.event.pull_request.labels.*.name, 'Backend') runs-on: ubuntu-22.04 permissions: pull-requests: write diff --git a/src/main/java/mafia/mafiatogether/game/ui/GameController.java b/src/main/java/mafia/mafiatogether/game/ui/GameController.java index f057e1f1..776ce875 100644 --- a/src/main/java/mafia/mafiatogether/game/ui/GameController.java +++ b/src/main/java/mafia/mafiatogether/game/ui/GameController.java @@ -9,6 +9,7 @@ import mafia.mafiatogether.game.application.dto.response.GameExistResponse; import mafia.mafiatogether.game.application.dto.response.GameInfoResponse; import mafia.mafiatogether.game.application.dto.response.GameResultResponse; +import mafia.mafiatogether.game.application.dto.response.GameStatusResponse; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; From 714a7d6692a6170cbcdc08a9965b590c7af2b38b Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:33:52 +0900 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20NoResourceFoundException=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/common/exception/GlobalExceptionHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java index 9812f472..76539a5c 100644 --- a/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/mafia/mafiatogether/common/exception/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ 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 @@ -31,7 +32,7 @@ public class GlobalExceptionHandler { private final ErrorNotificationService errorNotificationService; private static final String LOCAL_PROFILE_NAME = "local"; - @ExceptionHandler(NoHandlerFoundException.class) + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) public ResponseEntity handleNoHandlerFoundException() { final ErrorResponse errorResponse = ErrorResponse.create( ExceptionCode.NO_STATIC_RESOURCE.getCode(), From 5b5123092e948226cfafa911c2af8e5518380048 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Fri, 6 Dec 2024 23:50:28 +0900 Subject: [PATCH 24/28] =?UTF-8?q?feat:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#1?= =?UTF-8?q?62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mafiatogether/job/ui/JobController.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index b9c9186a..09629626 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -1,8 +1,7 @@ package mafia.mafiatogether.job.ui; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import mafia.mafiatogether.chat.annotation.SendToChatWithRedis; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; import mafia.mafiatogether.job.application.JobService; @@ -10,12 +9,15 @@ 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.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.web.bind.annotation.*; +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 @RequiredArgsConstructor @@ -23,8 +25,6 @@ public class JobController { private final JobService jobService; - private final StringRedisTemplate stringRedisTemplate; - private final ObjectMapper objectMapper; @GetMapping("/my") public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDto) { @@ -32,14 +32,13 @@ public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDt } @MessageMapping("/skill/{code}/{name}") - public void executeSkill( + @SendToChatWithRedis("/sub/mafia/{code}") + public JobExecuteAbilityResponse executeSkill( @DestinationVariable("code") String code, @DestinationVariable("name") String name, @Payload JobExecuteAbilityRequest request - ) throws JsonProcessingException { - JobExecuteAbilityResponse response = jobService.executeSkill(code, name, request); - String message = objectMapper.writeValueAsString(response); - stringRedisTemplate.convertAndSend("/sub/jobs/skill/" + response.job().toLowerCase() + "/" + code, message); + ) { + return jobService.executeSkill(code, name, request); } @PostMapping("/skill") From 4ad0bd10b09b250294fd34dd3791710fb50c2336 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:01:08 +0900 Subject: [PATCH 25/28] =?UTF-8?q?=EC=A7=81=EC=97=85=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=20response=20=ED=98=95=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20(#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웹소켓 채팅 리팩터링 * feat: 웹소켓 스킬 response 변경 --- src/main/java/mafia/mafiatogether/job/ui/JobController.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index 09629626..ea1f086d 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import mafia.mafiatogether.chat.annotation.SendToChatWithRedis; +import mafia.mafiatogether.chat.domain.Message; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; import mafia.mafiatogether.job.application.JobService; @@ -33,12 +34,13 @@ public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDt @MessageMapping("/skill/{code}/{name}") @SendToChatWithRedis("/sub/mafia/{code}") - public JobExecuteAbilityResponse executeSkill( + public Message executeSkill( @DestinationVariable("code") String code, @DestinationVariable("name") String name, @Payload JobExecuteAbilityRequest request ) { - return jobService.executeSkill(code, name, request); + JobExecuteAbilityResponse response = jobService.executeSkill(code, name, request); + return Message.ofChat(response.job(), response.result()); } @PostMapping("/skill") From 0cb1952692eda84bfca8121ca4bf3944c0388898 Mon Sep 17 00:00:00 2001 From: waterricecake <91263263+waterricecake@users.noreply.github.com> Date: Sun, 22 Dec 2024 22:05:15 +0900 Subject: [PATCH 26/28] Hotfix/#1 sse connect (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 게임 종료시 sse connect 유지 * fix: 게임 상태 delete event 송신하지 않게 수정 --- .../mafiatogether/game/application/GameEventListener.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java index cd13edfa..d59b051a 100644 --- a/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java +++ b/src/main/java/mafia/mafiatogether/game/application/GameEventListener.java @@ -103,7 +103,6 @@ public void listenDeleteGameEvent(final DeleteGameEvent deleteGameEvent) { chatRepository.deleteById(code); voteRepository.deleteById(code); sendStatusChangeEventToSseClient(code, StatusType.WAIT); - sseEventPublisher.disconnectSseByCode(code); gameRepository.deleteById(code); final Lobby room = lobbyRepository.findById(code) @@ -141,6 +140,9 @@ public void listenDeleteLobbyEvent(final DeleteLobbyEvent deleteLobbyEvent) { @EventListener public void listenGameStatusChangeEvent(final GameStatusChangeEvent gameStatusChangeEvent) { + if (gameStatusChangeEvent.statusType().equals(StatusType.DELETED)) { + return; + } sendStatusChangeEventToSseClient(gameStatusChangeEvent.code(), gameStatusChangeEvent.statusType()); } From 501f1d457ae4c0872a1cdd44912d1c529f2db0f2 Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Thu, 26 Dec 2024 15:39:56 +0900 Subject: [PATCH 27/28] =?UTF-8?q?=EC=9B=B9=EC=86=8C=EC=BC=93=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웹소켓 채팅 리팩터링 * feat: 채팅 롤백 Path 롤백 및 인터셉터 수정 * refactor: job skill 수정 * refactor: auth 인증부분 분리 * fix: WebsocketPlayerArgumentResolver 패키지 위치 수 --------- Co-authored-by: waterricecake --- .../mafiatogether/chat/ui/ChatController.java | 4 +- .../common/config/WebMvcConfig.java | 6 +- .../common/config/WebSocketConfig.java | 23 +++++-- ...lInterceptor.java => ChatInterceptor.java} | 4 +- .../interceptor/PathMatcherInterceptor.java | 66 +++++++++++++++++++ .../common/interceptor/StompMapping.java | 9 +++ .../common/resolver/BasicAuthResolver.java | 22 +++++++ .../resolver/PlayerArgumentResolver.java | 15 ++--- .../WebsocketPlayerArgumentResolver.java | 31 +++++++++ .../mafiatogether/job/ui/JobController.java | 31 +++++---- src/main/resources/static/app.js | 22 ++++++- src/main/resources/static/index.html | 9 ++- 12 files changed, 210 insertions(+), 32 deletions(-) rename src/main/java/mafia/mafiatogether/common/interceptor/{StompChannelInterceptor.java => ChatInterceptor.java} (95%) create mode 100644 src/main/java/mafia/mafiatogether/common/interceptor/PathMatcherInterceptor.java create mode 100644 src/main/java/mafia/mafiatogether/common/interceptor/StompMapping.java create mode 100644 src/main/java/mafia/mafiatogether/common/resolver/BasicAuthResolver.java create mode 100644 src/main/java/mafia/mafiatogether/common/resolver/WebsocketPlayerArgumentResolver.java diff --git a/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java index e91cbb2c..6bd5d51f 100644 --- a/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java +++ b/src/main/java/mafia/mafiatogether/chat/ui/ChatController.java @@ -1,5 +1,6 @@ package mafia.mafiatogether.chat.ui; +import java.util.List; import lombok.RequiredArgsConstructor; import mafia.mafiatogether.chat.annotation.SendToChatWithRedis; import mafia.mafiatogether.chat.application.ChatService; @@ -15,8 +16,6 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import java.util.List; - @Controller @RequiredArgsConstructor public class ChatController { @@ -56,5 +55,4 @@ public Message createChat( return chatService.chat(name, code, request.content()); } - } 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/interceptor/StompChannelInterceptor.java b/src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java similarity index 95% rename from src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java rename to src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java index 435dc4b1..427ae6ac 100644 --- a/src/main/java/mafia/mafiatogether/common/interceptor/StompChannelInterceptor.java +++ b/src/main/java/mafia/mafiatogether/common/interceptor/ChatInterceptor.java @@ -3,7 +3,6 @@ import java.util.Map; import java.util.Objects; import java.util.function.Consumer; -import lombok.RequiredArgsConstructor; import mafia.mafiatogether.common.util.AuthExtractor; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; @@ -16,8 +15,7 @@ @Component @Configuration -@RequiredArgsConstructor -public class StompChannelInterceptor implements ChannelInterceptor { +public class ChatInterceptor implements ChannelInterceptor { private static final String SUBSCRIBE_FORMAT = "%s/%s"; private static final String PUBLISHING_FORMAT = "%s/%s/%s"; 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/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index ea1f086d..21c9c544 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -1,8 +1,10 @@ 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.chat.annotation.SendToChatWithRedis; -import mafia.mafiatogether.chat.domain.Message; import mafia.mafiatogether.common.annotation.PlayerInfo; import mafia.mafiatogether.common.resolver.PlayerInfoDto; import mafia.mafiatogether.job.application.JobService; @@ -10,8 +12,8 @@ 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.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,8 @@ @RequestMapping("/jobs") public class JobController { + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; private final JobService jobService; @GetMapping("/my") @@ -32,15 +36,20 @@ public ResponseEntity getJob(@PlayerInfo PlayerInfoDto playerInfoDt return ResponseEntity.ok(jobService.getPlayerJob(playerInfoDto.code(), playerInfoDto.name())); } - @MessageMapping("/skill/{code}/{name}") - @SendToChatWithRedis("/sub/mafia/{code}") - public Message executeSkill( - @DestinationVariable("code") String code, - @DestinationVariable("name") String name, + @MessageMapping("/jobs/skill") + public void executeWebSocketSkill( + @PlayerInfo PlayerInfoDto playerInfoDto, @Payload JobExecuteAbilityRequest request - ) { - JobExecuteAbilityResponse response = jobService.executeSkill(code, name, request); - return Message.ofChat(response.job(), response.result()); + ) throws JsonProcessingException { + JobExecuteAbilityResponse response = jobService.executeSkill(playerInfoDto.code(), playerInfoDto.name(), + request); + String auth = Base64.getEncoder() + .encodeToString((playerInfoDto.code() + ":" + playerInfoDto.name()).getBytes()); + + stringRedisTemplate.convertAndSend( + String.format("/sub/job/skill/%s/%s", response.job().toLowerCase(), auth), + objectMapper.writeValueAsString(response) + ); } @PostMapping("/skill") 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 + From 238db163b05d034c298cb6f787861a5b79fc79fd Mon Sep 17 00:00:00 2001 From: Jae-Baek Song <83541246+thdwoqor@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:08:17 +0900 Subject: [PATCH 28/28] =?UTF-8?q?fix:=20skill=20=EA=B5=AC=EB=8F=85=20url?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/mafia/mafiatogether/job/ui/JobController.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/mafia/mafiatogether/job/ui/JobController.java b/src/main/java/mafia/mafiatogether/job/ui/JobController.java index 21c9c544..23e7d283 100644 --- a/src/main/java/mafia/mafiatogether/job/ui/JobController.java +++ b/src/main/java/mafia/mafiatogether/job/ui/JobController.java @@ -43,11 +43,9 @@ public void executeWebSocketSkill( ) throws JsonProcessingException { JobExecuteAbilityResponse response = jobService.executeSkill(playerInfoDto.code(), playerInfoDto.name(), request); - String auth = Base64.getEncoder() - .encodeToString((playerInfoDto.code() + ":" + playerInfoDto.name()).getBytes()); stringRedisTemplate.convertAndSend( - String.format("/sub/job/skill/%s/%s", response.job().toLowerCase(), auth), + String.format("/sub/jobs/skill/%s/%s", response.job().toLowerCase(),playerInfoDto.code()), objectMapper.writeValueAsString(response) ); }